Diagnostic Catalog Import
Duplicating a product scrambles variant option pairings
You duplicate a product to save time on a similar listing. The copy comes back with the right number of variants and the right SKUs, so at a glance everything looks fine. Then a customer orders a Small in Red and gets a Large in Blue, because somewhere in the copy, a variant got wired to the wrong option values. Here is why Medusa's duplication logic can mismatch a variant's title and value pairing, and a script that compares a source product against its duplicate and tells you exactly which variants are wrong.
Medusa links a ProductVariant to its options by title-to-value pairing, for example options: {"Size": "Small", "Color": "Red"}, not by a stable positional index. When you duplicate a product, its ProductOption and ProductOptionValue rows are recreated on the copy with brand new ids rather than reusing the source ids. The duplication logic then re-attaches each new variant to that new option and value set, and if that re-attachment happens by array or creation order instead of by matching each source variant's actual (option title, value) tuples, variants land on the wrong new rows whenever ordering differs even slightly between the transaction that creates the copies and the one that creates the values. Run a small Python or Node.js script that pulls both products, builds a normalized option signature per variant, and reports every variant whose signature does not match its source counterpart. Full code, tests, and a dry run guarded repair option are below.
The problem in plain words
Medusa never asks a variant "are you the first, second, or third row." It asks each variant "for the option called Size, what value do you hold, and for the option called Color, what value do you hold." That title-to-value pairing, not the variant's position in an array, is what tells Medusa which physical item a variant represents.
Duplicating a product has to recreate that whole structure from scratch. The copy gets its own ProductOption rows for Size and Color, its own ProductOptionValue rows for Small, Large, Red, and Blue, all with new ids, because nothing in the copy is allowed to point back at the source product's rows. The duplication step then has to reattach every new variant to the right new value rows. When that reattachment logic walks the new variants and the new option values in array or creation order rather than matching them by the actual title and value pairing from the source, a variant can end up wired to a value it never had. The count of variants is right. The SKUs are usually right, since those get copied as plain strings. But the pairing between a variant and its Size or Color is quietly wrong.
Why it happens
Since Medusa's duplication logic has to build a whole new option and value set for the copy and then wire every variant back up to it, a few conditions make the mismatch show up:
- The transaction that creates the copy's
ProductOptionandProductOptionValuerows runs separately from the one that creates the copy's variants, so any difference in the order each side settles in can desync the pairing. - The source product's options, values, or variants were not created or listed in a perfectly consistent order to begin with, which is common after manual edits or an earlier import, so an order-based reattachment has nothing reliable to walk.
- A product with more than two option axes, for example Size, Color, and Material, multiplies the number of ways a positional reattachment can land on the wrong combination.
- A product with more variants than option value combinations that are actually distinct, for example two variants that only differ by SKU but share otherwise similar values, gives an order-based match extra room to swap two variants for each other.
This is a common source of confusion because nothing errors. The duplicate finishes, the variant count matches, and the SKUs usually carry over with a suffix. The wrong pairing only shows up later, when someone builds a bundle from the duplicate, or a customer receives a Large in a color they never ordered. See the citations at the end for the exact upstream reports.
A variant's option pairing is identity, not metadata. Two variants with the same SKU count and the same value vocabulary can still be completely wrong if the values are attached to the wrong variant. So the safe pattern is not "trust the duplicate because the count and SKUs look right." It is "compare the actual title and value pairing on every variant, one against the other, and only report the ones that differ." A repair is possible, but it should never come from rewriting module tables directly, since variant options are owned by the Product Module's own linking logic.
The fix, as a flow
We do not touch the duplicate by default. We add a script that fetches the source product and the duplicate product side by side, builds a normalized signature for every variant from its option title and value pairs, matches source variants to duplicate variants by SKU, and reports any variant whose signature does not match. Only if a correction is explicitly requested, and only with dry run off, does it call the variant update route to fix the mismatched ones.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /admin/auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write
Fetch a product with its options and variant options expanded
Ask for *options, *options.values, *variants, *variants.sku, *variants.options, and *variants.options.option on both the source and the duplicate prod_* id. That gives you every option's title and allowed values, plus each variant's actual title-to-value pairing.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
FIELDS = "id,title,*options,*options.values,*variants,*variants.sku,*variants.options,*variants.options.option"
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/admin/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_product(token, product_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": FIELDS},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const FIELDS = "id,title,*options,*options.values,*variants,*variants.sku,*variants.options,*variants.options.option";
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/admin/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.token;
}
async function getProduct(token, productId) {
const url = new URL(`${BACKEND_URL}/admin/products/${productId}`);
url.searchParams.set("fields", FIELDS);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.product;
}
Turn each variant's options into a normalized shape
Before comparing anything, reduce each variant's options array down to plain {title, value} pairs, dropping variant.id, options[].id, and option_id. Those ids are regenerated per duplicate and are never meaningful across two different products, so keeping them around would make every comparison fail for the wrong reason.
def normalize_variants(product):
"""Turn the raw API shape into [{"sku": str, "options": [{"title": str, "value": str}]}]."""
normalized = []
for v in product.get("variants") or []:
pairs = []
for opt in v.get("options") or []:
title = (opt.get("option") or {}).get("title")
value = opt.get("value")
if title is not None and value is not None:
pairs.append({"title": title, "value": value})
normalized.append({"sku": v.get("sku"), "options": pairs})
return normalized
function normalizeVariants(product) {
const variants = product.variants || [];
return variants.map((v) => {
const pairs = (v.options || [])
.map((opt) => ({ title: opt.option?.title, value: opt.value }))
.filter((p) => p.title != null && p.value != null);
return { sku: v.sku, options: pairs };
});
}
Diff the signatures, with one pure function
Keep the comparison in its own function that takes the two normalized variant lists and returns only the mismatches. It sorts each variant's pairs alphabetically by title, joins them into a single string like Color:Red|Size:Small, matches source to duplicate by SKU, and returns an entry only when the two signatures differ. No I/O, so it is easy to test against fixture arrays.
def signature(options):
pairs = sorted(options, key=lambda p: p["title"])
return "|".join(f'{p["title"]}:{p["value"]}' for p in pairs)
def diff_variant_option_signatures(source_variants, dup_variants):
"""Pure function. No I/O.
source_variants / dup_variants: [{"sku": str, "options": [{"title": str, "value": str}]}]
Matches by sku when both sides have one, falls back to index when sku is
missing or collides. Returns [{"sku": str, "expected": str, "actual": str}]
only for variants whose signature differs.
"""
use_index = (
not source_variants
or not dup_variants
or any(not v.get("sku") for v in source_variants)
or any(not v.get("sku") for v in dup_variants)
or len({v.get("sku") for v in source_variants}) != len(source_variants)
or len({v.get("sku") for v in dup_variants}) != len(dup_variants)
)
mismatches = []
if use_index:
for i in range(min(len(source_variants), len(dup_variants))):
src, dup = source_variants[i], dup_variants[i]
expected, actual = signature(src["options"]), signature(dup["options"])
if expected != actual:
mismatches.append({"sku": dup.get("sku") or src.get("sku") or f"#{i}", "expected": expected, "actual": actual})
return mismatches
by_sku = {v["sku"]: v for v in dup_variants}
for src in source_variants:
dup = by_sku.get(src["sku"])
if dup is None:
continue
expected, actual = signature(src["options"]), signature(dup["options"])
if expected != actual:
mismatches.append({"sku": src["sku"], "expected": expected, "actual": actual})
return mismatches
function signature(options) {
const pairs = [...options].sort((a, b) => a.title.localeCompare(b.title));
return pairs.map((p) => `${p.title}:${p.value}`).join("|");
}
export function diffVariantOptionSignatures(sourceVariants, dupVariants) {
const hasDupSkus = sourceVariants.length && dupVariants.length &&
sourceVariants.every((v) => v.sku) && dupVariants.every((v) => v.sku) &&
new Set(sourceVariants.map((v) => v.sku)).size === sourceVariants.length &&
new Set(dupVariants.map((v) => v.sku)).size === dupVariants.length;
const mismatches = [];
if (!hasDupSkus) {
const len = Math.min(sourceVariants.length, dupVariants.length);
for (let i = 0; i < len; i++) {
const src = sourceVariants[i];
const dup = dupVariants[i];
const expected = signature(src.options);
const actual = signature(dup.options);
if (expected !== actual) {
mismatches.push({ sku: dup.sku || src.sku || `#${i}`, expected, actual });
}
}
return mismatches;
}
const bySku = new Map(dupVariants.map((v) => [v.sku, v]));
for (const src of sourceVariants) {
const dup = bySku.get(src.sku);
if (!dup) continue;
const expected = signature(src.options);
const actual = signature(dup.options);
if (expected !== actual) {
mismatches.push({ sku: src.sku, expected, actual });
}
}
return mismatches;
}
Repair a mismatched variant, only when asked
If a correction is explicitly requested, the supported repair is the variant update route with a title-to-value options map, the same input shape createProductVariantsWorkflow and updateProductVariantsWorkflow accept. Never write to the option or option value tables directly. This only runs when DRY_RUN is explicitly set to false, driven by the expected signature computed from the source product.
def fix_variant_options(token, product_id, variant_id, options_map):
r = requests.post(
f"{BACKEND_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"options": options_map},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
async function fixVariantOptions(token, productId, variantId, optionsMap) {
const res = await fetch(`${BACKEND_URL}/admin/products/${productId}/variants/${variantId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ options: optionsMap }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.product;
}
Wire it together with a dry run guard
The run loop fetches the source and duplicate product, normalizes both variant lists, diffs the signatures, and logs a full report for every mismatch: the SKU, the expected pairing from the source, and the actual pairing on the duplicate. On the first run, and every run by default, DRY_RUN stays on and nothing is written. Only with DRY_RUN=false does it call the variant update route to correct each mismatched variant on the duplicate.
This is a diagnostic pass first. Read the report and confirm the expected pairing against the source product before you let anything write, since a scrambled duplicate may already have shipped orders against the wrong pairing. Only set DRY_RUN=false once you have confirmed the expected signature for every flagged variant is correct.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs a full diff report for every mismatched variant it finds, respects the dry run flag, and only corrects a variant's options when you explicitly turn writing on.
"""Find Medusa variants whose option pairing got scrambled by product duplication.
Medusa links a ProductVariant to its options by title to value pairing, for
example options: {"Size": "Small", "Color": "Red"}, not by a stable positional
index. When a product is duplicated, its ProductOption and ProductOptionValue
rows are recreated on the copy with brand new ids, and the duplication step
re-attaches each new variant to that new set. If that re-attachment happens by
creation order instead of by matching each source variant's actual title and
value pairing, a variant in the duplicate can land on the wrong value even
though the variant count and SKUs still look correct.
This fetches the source product and the duplicate product, normalizes every
variant's options into a canonical signature string with a pure function, and
reports every duplicate variant whose signature does not match its source
counterpart. It never writes to the option or option value tables directly,
since those are owned by the product module's own linking logic. The only
write this script can make is correcting a mismatched variant's options
through the existing variant update route, and it only does that when
DRY_RUN is explicitly set to false. Run once per source and duplicate pair.
Safe to run again and again, since a corrected variant simply stops appearing
in the report.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("diff_variant_options")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
FIELDS = "id,title,*options,*options.values,*variants,*variants.sku,*variants.options,*variants.options.option"
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/admin/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def get_product(token, product_id):
r = requests.get(
f"{BACKEND_URL}/admin/products/{product_id}",
headers={"Authorization": f"Bearer {token}"},
params={"fields": FIELDS},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
def normalize_variants(product):
"""Turn the raw API shape into [{"sku": str, "options": [{"title": str, "value": str}]}]."""
normalized = []
for v in product.get("variants") or []:
pairs = []
for opt in v.get("options") or []:
title = (opt.get("option") or {}).get("title")
value = opt.get("value")
if title is not None and value is not None:
pairs.append({"title": title, "value": value})
normalized.append({"sku": v.get("sku"), "options": pairs})
return normalized
def signature(options):
pairs = sorted(options, key=lambda p: p["title"])
return "|".join(f'{p["title"]}:{p["value"]}' for p in pairs)
def diff_variant_option_signatures(source_variants, dup_variants):
"""Pure function. No I/O.
source_variants / dup_variants: [{"sku": str, "options": [{"title": str, "value": str}]}]
Matches by sku when both sides carry unique skus, falls back to index when
sku is missing or collides. Returns [{"sku": str, "expected": str,
"actual": str}] only for variants whose signature differs.
"""
use_index = (
not source_variants
or not dup_variants
or any(not v.get("sku") for v in source_variants)
or any(not v.get("sku") for v in dup_variants)
or len({v.get("sku") for v in source_variants}) != len(source_variants)
or len({v.get("sku") for v in dup_variants}) != len(dup_variants)
)
mismatches = []
if use_index:
for i in range(min(len(source_variants), len(dup_variants))):
src, dup = source_variants[i], dup_variants[i]
expected, actual = signature(src["options"]), signature(dup["options"])
if expected != actual:
mismatches.append({"sku": dup.get("sku") or src.get("sku") or f"#{i}", "expected": expected, "actual": actual})
return mismatches
by_sku = {v["sku"]: v for v in dup_variants}
for src in source_variants:
dup = by_sku.get(src["sku"])
if dup is None:
continue
expected, actual = signature(src["options"]), signature(dup["options"])
if expected != actual:
mismatches.append({"sku": src["sku"], "expected": expected, "actual": actual})
return mismatches
def fix_variant_options(token, product_id, variant_id, options_map):
r = requests.post(
f"{BACKEND_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"options": options_map},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]
def run(source_product_id=None, duplicate_product_id=None):
source_product_id = source_product_id or os.environ["SOURCE_PRODUCT_ID"]
duplicate_product_id = duplicate_product_id or os.environ["DUPLICATE_PRODUCT_ID"]
token = get_admin_token()
source = get_product(token, source_product_id)
duplicate = get_product(token, duplicate_product_id)
source_variants = normalize_variants(source)
dup_variants = normalize_variants(duplicate)
mismatches = diff_variant_option_signatures(source_variants, dup_variants)
if not mismatches:
log.info("Done. No scrambled variants found between %s and %s.", source_product_id, duplicate_product_id)
return
log.info("Found %d scrambled variant(s) on duplicate %s.", len(mismatches), duplicate_product_id)
dup_variant_by_sku = {v.get("sku"): v for v in duplicate.get("variants") or []}
for m in mismatches:
log.info(" sku=%s expected=%r actual=%r", m["sku"], m["expected"], m["actual"])
if not DRY_RUN:
variant = dup_variant_by_sku.get(m["sku"])
if variant is None:
continue
options_map = {pair.split(":", 1)[0]: pair.split(":", 1)[1] for pair in m["expected"].split("|") if pair}
log.info(" Fixing variant %s to %s", variant["id"], options_map)
fix_variant_options(token, duplicate_product_id, variant["id"], options_map)
else:
log.info(" Would fix this variant to match the expected signature.")
log.info("Done. %d scrambled variant(s) %s.", len(mismatches), "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Find Medusa variants whose option pairing got scrambled by product duplication.
*
* Medusa links a ProductVariant to its options by title to value pairing, for
* example options: {"Size": "Small", "Color": "Red"}, not by a stable
* positional index. When a product is duplicated, its ProductOption and
* ProductOptionValue rows are recreated on the copy with brand new ids, and
* the duplication step re-attaches each new variant to that new set. If that
* re-attachment happens by creation order instead of by matching each source
* variant's actual title and value pairing, a variant in the duplicate can
* land on the wrong value even though the variant count and SKUs still look
* correct.
*
* This fetches the source product and the duplicate product, normalizes every
* variant's options into a canonical signature string with a pure function,
* and reports every duplicate variant whose signature does not match its
* source counterpart. It never writes to the option or option value tables
* directly. The only write this script can make is correcting a mismatched
* variant's options through the existing variant update route, and it only
* does that when DRY_RUN is explicitly set to false. Run once per source and
* duplicate pair.
*
* Guide: https://www.allanninal.dev/medusa/duplicate-product-scrambles-variants/
*/
import { pathToFileURL } from "node:url";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const FIELDS = "id,title,*options,*options.values,*variants,*variants.sku,*variants.options,*variants.options.option";
export function normalizeVariants(product) {
const variants = product.variants || [];
return variants.map((v) => {
const pairs = (v.options || [])
.map((opt) => ({ title: opt.option?.title, value: opt.value }))
.filter((p) => p.title != null && p.value != null);
return { sku: v.sku, options: pairs };
});
}
function signature(options) {
const pairs = [...options].sort((a, b) => a.title.localeCompare(b.title));
return pairs.map((p) => `${p.title}:${p.value}`).join("|");
}
export function diffVariantOptionSignatures(sourceVariants, dupVariants) {
const hasDupSkus = sourceVariants.length && dupVariants.length &&
sourceVariants.every((v) => v.sku) && dupVariants.every((v) => v.sku) &&
new Set(sourceVariants.map((v) => v.sku)).size === sourceVariants.length &&
new Set(dupVariants.map((v) => v.sku)).size === dupVariants.length;
const mismatches = [];
if (!hasDupSkus) {
const len = Math.min(sourceVariants.length, dupVariants.length);
for (let i = 0; i < len; i++) {
const src = sourceVariants[i];
const dup = dupVariants[i];
const expected = signature(src.options);
const actual = signature(dup.options);
if (expected !== actual) {
mismatches.push({ sku: dup.sku || src.sku || `#${i}`, expected, actual });
}
}
return mismatches;
}
const bySku = new Map(dupVariants.map((v) => [v.sku, v]));
for (const src of sourceVariants) {
const dup = bySku.get(src.sku);
if (!dup) continue;
const expected = signature(src.options);
const actual = signature(dup.options);
if (expected !== actual) {
mismatches.push({ sku: src.sku, expected, actual });
}
}
return mismatches;
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/admin/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.token;
}
async function getProduct(token, productId) {
const url = new URL(`${BACKEND_URL}/admin/products/${productId}`);
url.searchParams.set("fields", FIELDS);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.product;
}
async function fixVariantOptions(token, productId, variantId, optionsMap) {
const res = await fetch(`${BACKEND_URL}/admin/products/${productId}/variants/${variantId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ options: optionsMap }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.product;
}
export async function run(sourceProductId, duplicateProductId) {
sourceProductId = sourceProductId || process.env.SOURCE_PRODUCT_ID;
duplicateProductId = duplicateProductId || process.env.DUPLICATE_PRODUCT_ID;
const token = await getAdminToken();
const source = await getProduct(token, sourceProductId);
const duplicate = await getProduct(token, duplicateProductId);
const sourceVariants = normalizeVariants(source);
const dupVariants = normalizeVariants(duplicate);
const mismatches = diffVariantOptionSignatures(sourceVariants, dupVariants);
if (mismatches.length === 0) {
console.log(`Done. No scrambled variants found between ${sourceProductId} and ${duplicateProductId}.`);
return;
}
console.log(`Found ${mismatches.length} scrambled variant(s) on duplicate ${duplicateProductId}.`);
const dupVariantBySku = new Map((duplicate.variants || []).map((v) => [v.sku, v]));
for (const m of mismatches) {
console.log(` sku=${m.sku} expected="${m.expected}" actual="${m.actual}"`);
if (!DRY_RUN) {
const variant = dupVariantBySku.get(m.sku);
if (!variant) continue;
const optionsMap = Object.fromEntries(
m.expected.split("|").filter(Boolean).map((pair) => pair.split(":"))
);
console.log(` Fixing variant ${variant.id} to ${JSON.stringify(optionsMap)}`);
await fixVariantOptions(token, duplicateProductId, variant.id, optionsMap);
} else {
console.log(" Would fix this variant to match the expected signature.");
}
}
console.log(`Done. ${mismatches.length} scrambled variant(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The signature diff is the part most worth testing, because it decides whether a variant gets reported as scrambled. Because we kept diff_variant_option_signatures pure, the test needs no network and no Medusa backend. It just feeds in plain arrays and checks the answer, covering identical order, shuffled option order, the actual scramble bug where values get shuffled between variants, and a synthetic collision where two duplicate variants end up with the same signature.
from diff_variant_options import diff_variant_option_signatures
def variant(sku, size, color):
return {"sku": sku, "options": [{"title": "Size", "value": size}, {"title": "Color", "value": color}]}
def test_identical_order_has_no_mismatches():
source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")]
dup = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")]
assert diff_variant_option_signatures(source, dup) == []
def test_shuffled_option_order_within_a_variant_is_not_a_mismatch():
source = [{"sku": "SKU-1", "options": [{"title": "Size", "value": "Small"}, {"title": "Color", "value": "Red"}]}]
dup = [{"sku": "SKU-1", "options": [{"title": "Color", "value": "Red"}, {"title": "Size", "value": "Small"}]}]
assert diff_variant_option_signatures(source, dup) == []
def test_scrambled_value_assignment_is_flagged():
source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")]
dup = [variant("SKU-1", "Large", "Blue"), variant("SKU-2", "Small", "Red")]
mismatches = diff_variant_option_signatures(source, dup)
assert len(mismatches) == 2
skus = {m["sku"] for m in mismatches}
assert skus == {"SKU-1", "SKU-2"}
def test_mismatch_reports_expected_and_actual():
source = [variant("SKU-1", "Small", "Red")]
dup = [variant("SKU-1", "Large", "Red")]
mismatches = diff_variant_option_signatures(source, dup)
assert mismatches == [{"sku": "SKU-1", "expected": "Color:Red|Size:Small", "actual": "Color:Red|Size:Large"}]
def test_collision_where_two_duplicate_variants_share_a_signature():
source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")]
dup = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Small", "Red")]
mismatches = diff_variant_option_signatures(source, dup)
assert len(mismatches) == 1
assert mismatches[0]["sku"] == "SKU-2"
assert mismatches[0]["actual"] == mismatches[0]["expected"].replace("Large", "Small").replace("Blue", "Red")
def test_falls_back_to_index_when_skus_are_missing():
source = [{"sku": None, "options": [{"title": "Size", "value": "Small"}]},
{"sku": None, "options": [{"title": "Size", "value": "Large"}]}]
dup = [{"sku": None, "options": [{"title": "Size", "value": "Large"}]},
{"sku": None, "options": [{"title": "Size", "value": "Small"}]}]
mismatches = diff_variant_option_signatures(source, dup)
assert len(mismatches) == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffVariantOptionSignatures } from "./diff-variant-options.js";
const variant = (sku, size, color) => ({
sku,
options: [{ title: "Size", value: size }, { title: "Color", value: color }],
});
test("identical order has no mismatches", () => {
const source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")];
const dup = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")];
assert.deepEqual(diffVariantOptionSignatures(source, dup), []);
});
test("shuffled option order within a variant is not a mismatch", () => {
const source = [{ sku: "SKU-1", options: [{ title: "Size", value: "Small" }, { title: "Color", value: "Red" }] }];
const dup = [{ sku: "SKU-1", options: [{ title: "Color", value: "Red" }, { title: "Size", value: "Small" }] }];
assert.deepEqual(diffVariantOptionSignatures(source, dup), []);
});
test("scrambled value assignment is flagged", () => {
const source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")];
const dup = [variant("SKU-1", "Large", "Blue"), variant("SKU-2", "Small", "Red")];
const mismatches = diffVariantOptionSignatures(source, dup);
assert.equal(mismatches.length, 2);
const skus = new Set(mismatches.map((m) => m.sku));
assert.deepEqual(skus, new Set(["SKU-1", "SKU-2"]));
});
test("mismatch reports expected and actual", () => {
const source = [variant("SKU-1", "Small", "Red")];
const dup = [variant("SKU-1", "Large", "Red")];
const mismatches = diffVariantOptionSignatures(source, dup);
assert.deepEqual(mismatches, [{ sku: "SKU-1", expected: "Color:Red|Size:Small", actual: "Color:Red|Size:Large" }]);
});
test("collision where two duplicate variants share a signature", () => {
const source = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Large", "Blue")];
const dup = [variant("SKU-1", "Small", "Red"), variant("SKU-2", "Small", "Red")];
const mismatches = diffVariantOptionSignatures(source, dup);
assert.equal(mismatches.length, 1);
assert.equal(mismatches[0].sku, "SKU-2");
assert.equal(mismatches[0].actual, "Color:Red|Size:Small");
});
test("falls back to index when skus are missing", () => {
const source = [
{ sku: null, options: [{ title: "Size", value: "Small" }] },
{ sku: null, options: [{ title: "Size", value: "Large" }] },
];
const dup = [
{ sku: null, options: [{ title: "Size", value: "Large" }] },
{ sku: null, options: [{ title: "Size", value: "Small" }] },
];
const mismatches = diffVariantOptionSignatures(source, dup);
assert.equal(mismatches.length, 2);
});
Case studies
The seasonal shirt that shipped the wrong size
An apparel brand duplicated last season's t-shirt to start a new colorway, changed the Color values, and published it. A customer ordered a Small in the new color and received a Large. Support assumed it was a fulfillment mistake at the warehouse and manually relabeled a batch of boxes before anyone checked Medusa itself.
Running the diff script against the original shirt and the new colorway found four variants out of twelve with a scrambled Size and Color pairing, none of them the specific SKU that had shipped wrong, meaning more bad orders were only a matter of time. The team fixed all four through the variant update route with dry run off, and added the script as a required check after every product duplication.
The bundle with three option axes
A furniture store sold a chair with Size, Color, and Material as three separate options, nine variants in total. Duplicating it for a new collection produced a copy where the variant count and every SKU suffix looked correct, but three variants had a Material value that did not match what the SKU suggested.
The report flagged one variant whose actual signature did not exist anywhere in the source product's variant list at all, an orphaned pairing rather than a simple swap. The merchandiser used the report to manually verify the correct Material for that one variant, then let the script apply the fix for the two variants that were a clean swap with each other.
After you run this on every duplicate, a variant count and a SKU list that look right are no longer enough to trust a copy. The report tells you exactly which variants are scrambled, what they should be, and what they actually are, so a human can confirm the fix before anything ships wrong. Keep the repair step behind dry run, since the module tables that actually own variant options are never a safe place to write to directly.
FAQ
Why do a duplicated Medusa product's variants have the wrong Size or Color?
Medusa links a variant to its options by matching each option's title to a value, not by a stable positional index. When you duplicate a product, its ProductOption and ProductOptionValue rows are recreated with brand new ids, and the duplication logic re-attaches each new variant to that new set. If the re-attachment happens by creation order instead of by matching each source variant's actual title and value pairing, a variant in the duplicate can end up wired to a value it never had on the source product.
Is it safe to auto-fix a scrambled product duplicate?
Not by writing directly to the module tables. Variant options are managed by the Product Module's own linking logic, not a public set-variant-options endpoint, so the safe pattern is to treat this as a diagnostic first: report every mismatched variant with its expected and actual option signature. A correction is available, but only behind an explicit dry run flag, using the existing variant update route with a title to value options map for each mismatched variant.
How do I detect a scrambled duplicate in Medusa?
Fetch both the source and the duplicate product with fields=id,title,*options,*options.values,*variants,*variants.sku,*variants.options,*variants.options.option. For every variant, build a signature from the sorted set of option title and value pairs, ignoring the regenerated ids. Match source to duplicate variants by SKU, then compare the two signature multisets. A mismatch, an orphaned value, or two duplicate variants sharing one signature all point to a scramble.
Related field notes
Citations
On the problem:
- GitHub Issue: Duplicate product scrambles the variant option values (medusajs/medusa #4360). github.com/medusajs/medusa/issues/4360
- GitHub Issue: Corrupted Options in Edit Options and Edit Variants and Add Variants for existing product (medusajs/medusa #4738). github.com/medusajs/medusa/issues/4738
- GitHub Issue: Error when duplicating product with variant EAN due to unique index constraint violation (medusajs/medusa #5541). github.com/medusajs/medusa/issues/5541
On the solution:
- Medusa Core Workflows Reference: createProductVariantsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createProductVariantsWorkflow
- Medusa Core Workflows Reference: updateProductVariantsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/updateProductVariantsWorkflow
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this untangle your catalog?
If this saved you from a shipment going out with the wrong size or color, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi