Diagnostic Catalog Import
Duplicating a product fails on a unique variant barcode constraint
You click Duplicate on a product in the Medusa admin because it is the fastest way to spin up a new variant of something you already sell. The clone starts, and then it fails, or worse, it succeeds and you only find out later that two products now claim the same barcode. Every variant field got copied over exactly as it was, including the one field that Postgres will not let two live variants share. Here is why Medusa never clears it and a script that finds every barcode, ean, and upc collision in your catalog before it causes a real problem.
Medusa's admin Duplicate action clones a product by re-submitting its variants through the same createProductsWorkflow and createProductVariantsWorkflow used for POST /admin/products, and it copies every variant field verbatim, including sku, ean, upc, and barcode. The product_variant table has unique partial indexes on those identifier columns scoped to deleted_at IS NULL, so the instant the duplicate's variant carries the same barcode as the source, the insert throws a Postgres unique constraint violation, tracked in medusajs/medusa issue #5541. Nothing about duplication auto-clears these fields, so the failure reproduces every time for any product whose variants have a barcode-family value set. Run a small Python or Node.js script that lists every variant's identifier fields, groups them by value, and reports any barcode, ean, or upc shared by more than one product. Never overwrite a real barcode automatically. Full code, tests, and a dry run guarded clear-to-null repair are below.
The problem in plain words
Duplicate looks like a shortcut, and most of the time it is. It saves you from retyping a title, a description, images, and a whole set of options and variants for a product that is nearly identical to one you already have. Under the hood, Medusa treats that shortcut the same way it treats any other product create. It builds a payload from the source product and sends it through createProductsWorkflow, the exact same workflow that handles POST /admin/products.
That workflow has no idea the payload came from a duplication rather than a brand new product a merchant typed in by hand. It takes every field on every variant at face value, including sku, ean, upc, and barcode, and tries to insert them as given. The product_variant table enforces uniqueness on those identifier columns with partial indexes scoped to non-deleted rows. The source product's variant already occupies that barcode. The new variant tries to claim the same one. Postgres refuses the second insert, and the duplication fails with a unique constraint violation.
Why it happens
This is deterministic behavior, not a race condition, because it comes from how duplication is implemented, not from timing:
- The admin Duplicate action builds its create payload directly from the source product's current data, including every variant's
sku,ean,upc, andbarcode, with no field stripped or blanked out before the payload is sent. - That payload goes through
createProductsWorkflowandcreateProductVariantsWorkflow, the same workflows used forPOST /admin/products, so the duplication path has no special-cased logic that treats a cloned variant any differently from a brand new one. - The
product_varianttable enforces uniqueness onsku,ean,upc, andbarcodewith partial unique indexes scoped todeleted_at IS NULL, so any live variant that already holds a given barcode blocks a second live variant from claiming it. - This exact failure is tracked in medusajs/medusa issue #5541, "Error when duplicating product with variant EAN due to unique index constraint violation," which reproduces every time a duplicated product's variant carries a non-empty EAN.
This is a common source of confusion because the failure feels random the first time you see it. Duplicating one product works fine, then duplicating another throws a 500 with a Postgres constraint name in it. The difference is never the workflow, it is whether that particular product's variants happen to have a barcode, ean, or upc set. See the citations at the end for the exact issue and docs.
A barcode is a real world identifier, a physical GTIN printed on a box or a label, so a script should never invent one or silently overwrite it. Whether the original product or the new duplicate should keep the real barcode is a business decision, not something detection logic can answer on its own. So the safe pattern here is not "clear the barcode automatically the moment a collision shows up." It is "report every collision with enough context to tell a real duplication artifact from an intentional shared GTIN, such as a bundle or multipack, and only clear the field to null once a human confirms which record is the unwanted copy."
The fix, as a flow
We do not touch any product by default. We add a script that lists every variant's identifier fields across the whole catalog, groups them by each non-null value of barcode, ean, and upc independently, and reports any value shared by more than one product as a conflict candidate. Only when you explicitly turn off dry run for a confirmed duplicate does it clear that one field to null, and it never invents a replacement value.
Build it step by step
Get an admin token
Exchange an admin email and password for a JWT at POST /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, and default to DRY_RUN=true so the script only reports, it never clears a barcode on its own.
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 clear a confirmed conflict
// 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 clear a confirmed conflict
Authenticate against the Admin API
Both languages exchange the admin email and password for a token the same way, then reuse that token as a Bearer header on every following call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
List every variant's identifier fields, paging through all of them
Ask for id, title, and each variant's sku, ean, upc, and barcode, and page with limit and offset until you have fetched the full count. A single page will miss conflicts once a catalog has more than a couple hundred products.
def list_all_variants(token):
entries, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={
"fields": "id,title,variants.id,variants.sku,variants.ean,variants.upc,variants.barcode",
"limit": limit,
"offset": offset,
},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
for variant in product.get("variants") or []:
entries.append({
"productId": product["id"],
"variantId": variant["id"],
"barcode": variant.get("barcode"),
"ean": variant.get("ean"),
"upc": variant.get("upc"),
})
offset += limit
if offset >= body["count"]:
return entries
async function listAllVariants(token) {
const entries = [];
let offset = 0;
const limit = 200;
while (true) {
const url = new URL(`${BASE_URL}/admin/products`);
url.searchParams.set("fields", "id,title,variants.id,variants.sku,variants.ean,variants.upc,variants.barcode");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa products ${res.status}`);
const body = await res.json();
for (const product of body.products) {
for (const variant of product.variants || []) {
entries.push({
productId: product.id,
variantId: variant.id,
barcode: variant.barcode ?? null,
ean: variant.ean ?? null,
upc: variant.upc ?? null,
});
}
}
offset += limit;
if (offset >= body.count) return entries;
}
}
Group and flag, with one pure function
Keep the grouping logic in its own function that takes the plain list of variant entries and returns only the groups that actually span more than one product. Check barcode, ean, and upc independently, since a conflict on one field says nothing about the others. Skip blank values, and skip a value repeated only within the same product, since a color-only variant reusing the parent barcode is normal and not a duplication artifact. This function does no I/O, so it is easy to test with synthetic data.
FIELDS = ("barcode", "ean", "upc")
def find_barcode_conflicts(variants):
groups_by_field = {field: {} for field in FIELDS}
for v in variants:
for field in FIELDS:
value = v.get(field)
if value is None or value == "":
continue
bucket = groups_by_field[field].setdefault(value, [])
bucket.append({"productId": v["productId"], "variantId": v["variantId"]})
conflicts = []
for field in FIELDS:
for value, entries in groups_by_field[field].items():
product_ids = {e["productId"] for e in entries}
if len(product_ids) > 1:
conflicts.append({"field": field, "value": value, "entries": entries})
conflicts.sort(key=lambda c: (c["field"], c["value"]))
return conflicts
const FIELDS = ["barcode", "ean", "upc"];
export function findBarcodeConflicts(variants) {
const groupsByField = { barcode: new Map(), ean: new Map(), upc: new Map() };
for (const v of variants) {
for (const field of FIELDS) {
const value = v[field];
if (value === null || value === undefined || value === "") continue;
const bucket = groupsByField[field];
if (!bucket.has(value)) bucket.set(value, []);
bucket.get(value).push({ productId: v.productId, variantId: v.variantId });
}
}
const conflicts = [];
for (const field of FIELDS) {
for (const [value, entries] of groupsByField[field]) {
const productIds = new Set(entries.map((e) => e.productId));
if (productIds.size > 1) conflicts.push({ field, value, entries });
}
}
conflicts.sort((a, b) => (a.field === b.field ? (a.value < b.value ? -1 : a.value > b.value ? 1 : 0) : a.field.localeCompare(b.field)));
return conflicts;
}
Clear the field, only on a confirmed duplicate, never invent a value
If you decide a flagged record is truly an unwanted duplicate, the only safe write is PATCH /admin/products/{product_id}/variants/{variant_id} with the offending field set to null. Never write a made-up replacement barcode, since inventing one risks creating an invalid or colliding GTIN. Let the merchant re-enter the correct value afterward through the admin UI or a follow-up PATCH.
def clear_identifier_field(token, product_id, variant_id, field):
r = requests.post(
f"{BASE_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={field: None},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
async function clearIdentifierField(token, productId, variantId, field) {
const res = await fetch(`${BASE_URL}/admin/products/${productId}/variants/${variantId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ [field]: null }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.variant;
}
Wire it together with a dry run guard
The run loop lists every variant, finds the conflicts with the pure function, and logs a full report: field, value, and every product id and variant id that shares it. On the first run, and every run by default, DRY_RUN stays on and nothing is written. This script never decides for you which record to clear. Set CONFIRMED_VARIANT_ID to the one variant id you have manually confirmed is the unwanted duplicate, and only then, with DRY_RUN=false, does it clear that one field on that one variant.
Always read the full conflict report before touching anything. A shared barcode is sometimes correct, such as a bundle or multipack variant that legitimately shares a GTIN with its parent product. Only clear a field once you have confirmed, outside the script, which specific variant id holds the unwanted duplicate value, and never let the script pick a side automatically.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs a full report of every barcode, ean, and upc conflict it finds, respects the dry run flag, and only clears a field on the one variant id you explicitly confirm.
"""Find Medusa variants that collide on barcode, ean, or upc after a product
duplication, safely.
The admin Duplicate action clones a product by re-submitting its variants
through createProductsWorkflow and createProductVariantsWorkflow, the same
workflows used for a normal POST /admin/products, and it copies every
variant field verbatim, including sku, ean, upc, and barcode. The
product_variant table has unique partial indexes on those identifier
columns, scoped to deleted_at IS NULL, so a duplicated variant that carries
the same barcode as its source hits a Postgres unique constraint violation.
Medusa never auto-clears or regenerates these fields, so the failure is
deterministic, not a race condition, for any product whose variants have a
barcode-family value set.
This lists every product's variants, groups their identifier fields with a
pure decision function, and reports every value shared by more than one
product. It never overwrites a barcode automatically. The only write this
script can make is clearing one confirmed field to null on one confirmed
variant id, and only when DRY_RUN is explicitly set to false. It never
invents a replacement value. Run once, or on a schedule. Safe to run again
and again, since a resolved conflict simply stops appearing.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_barcode_conflicts")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CONFIRMED_VARIANT_ID = os.environ.get("CONFIRMED_VARIANT_ID", "")
CONFIRMED_FIELD = os.environ.get("CONFIRMED_FIELD", "")
FIELDS = ("barcode", "ean", "upc")
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_all_variants(token):
entries, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE_URL}/admin/products",
headers={"Authorization": f"Bearer {token}"},
params={
"fields": "id,title,variants.id,variants.sku,variants.ean,variants.upc,variants.barcode",
"limit": limit,
"offset": offset,
},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
for variant in product.get("variants") or []:
entries.append({
"productId": product["id"],
"variantId": variant["id"],
"barcode": variant.get("barcode"),
"ean": variant.get("ean"),
"upc": variant.get("upc"),
})
offset += limit
if offset >= body["count"]:
return entries
def find_barcode_conflicts(variants):
"""Pure function. No I/O.
variants: [{"productId": str, "variantId": str, "barcode": str|None,
"ean": str|None, "upc": str|None}, ...]
Groups variants by each non-null, non-empty value per identifier field
(barcode, ean, upc independently). Returns only groups where entries span
more than one distinct productId. Same-product multi-variant repeats,
such as a color-only variant reusing the parent barcode, are not flagged.
Sorted by field then value for deterministic output.
"""
groups_by_field = {field: {} for field in FIELDS}
for v in variants:
for field in FIELDS:
value = v.get(field)
if value is None or value == "":
continue
bucket = groups_by_field[field].setdefault(value, [])
bucket.append({"productId": v["productId"], "variantId": v["variantId"]})
conflicts = []
for field in FIELDS:
for value, entries in groups_by_field[field].items():
product_ids = {e["productId"] for e in entries}
if len(product_ids) > 1:
conflicts.append({"field": field, "value": value, "entries": entries})
conflicts.sort(key=lambda c: (c["field"], c["value"]))
return conflicts
def clear_identifier_field(token, product_id, variant_id, field):
r = requests.post(
f"{BASE_URL}/admin/products/{product_id}/variants/{variant_id}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={field: None},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
def run():
token = get_token()
variants = list_all_variants(token)
conflicts = find_barcode_conflicts(variants)
if not conflicts:
log.info("Done. No barcode, ean, or upc conflicts found across %d variant(s).", len(variants))
return
log.info("Found %d conflict(s).", len(conflicts))
for c in conflicts:
log.info("Field %s value %r shared by:", c["field"], c["value"])
for entry in c["entries"]:
log.info(" product_id=%s variant_id=%s", entry["productId"], entry["variantId"])
if CONFIRMED_VARIANT_ID and CONFIRMED_FIELD:
target = next(
(c for c in conflicts if c["field"] == CONFIRMED_FIELD
and any(e["variantId"] == CONFIRMED_VARIANT_ID for e in c["entries"])),
None,
)
if target is None:
log.warning(
"CONFIRMED_VARIANT_ID %s with field %s was not found among the reported conflicts. Nothing cleared.",
CONFIRMED_VARIANT_ID, CONFIRMED_FIELD,
)
else:
product_id = next(e["productId"] for e in target["entries"] if e["variantId"] == CONFIRMED_VARIANT_ID)
log.info(
"%s field %s on variant %s (product %s)",
"Would clear" if DRY_RUN else "Clearing", CONFIRMED_FIELD, CONFIRMED_VARIANT_ID, product_id,
)
if not DRY_RUN:
clear_identifier_field(token, product_id, CONFIRMED_VARIANT_ID, CONFIRMED_FIELD)
log.info("Done. %d conflict(s) reported.", len(conflicts))
if __name__ == "__main__":
run()
/**
* Find Medusa variants that collide on barcode, ean, or upc after a product
* duplication, safely.
*
* The admin Duplicate action clones a product by re-submitting its variants
* through createProductsWorkflow and createProductVariantsWorkflow, the same
* workflows used for a normal POST /admin/products, and it copies every
* variant field verbatim, including sku, ean, upc, and barcode. The
* product_variant table has unique partial indexes on those identifier
* columns, scoped to deleted_at IS NULL, so a duplicated variant that carries
* the same barcode as its source hits a Postgres unique constraint
* violation. Medusa never auto-clears or regenerates these fields, so the
* failure is deterministic, not a race condition, for any product whose
* variants have a barcode-family value set.
*
* This lists every product's variants, groups their identifier fields with a
* pure decision function, and reports every value shared by more than one
* product. It never overwrites a barcode automatically. The only write this
* script can make is clearing one confirmed field to null on one confirmed
* variant id, and only when DRY_RUN is explicitly set to false. It never
* invents a replacement value. Run once, or on a schedule.
*
* Guide: https://www.allanninal.dev/medusa/duplicate-product-barcode-conflict/
*/
import { pathToFileURL } from "node:url";
const BASE_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 CONFIRMED_VARIANT_ID = process.env.CONFIRMED_VARIANT_ID || "";
const CONFIRMED_FIELD = process.env.CONFIRMED_FIELD || "";
const FIELDS = ["barcode", "ean", "upc"];
export function findBarcodeConflicts(variants) {
const groupsByField = { barcode: new Map(), ean: new Map(), upc: new Map() };
for (const v of variants) {
for (const field of FIELDS) {
const value = v[field];
if (value === null || value === undefined || value === "") continue;
const bucket = groupsByField[field];
if (!bucket.has(value)) bucket.set(value, []);
bucket.get(value).push({ productId: v.productId, variantId: v.variantId });
}
}
const conflicts = [];
for (const field of FIELDS) {
for (const [value, entries] of groupsByField[field]) {
const productIds = new Set(entries.map((e) => e.productId));
if (productIds.size > 1) conflicts.push({ field, value, entries });
}
}
conflicts.sort((a, b) => (a.field === b.field ? (a.value < b.value ? -1 : a.value > b.value ? 1 : 0) : a.field.localeCompare(b.field)));
return conflicts;
}
async function getToken() {
const res = await fetch(`${BASE_URL}/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 auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function listAllVariants(token) {
const entries = [];
let offset = 0;
const limit = 200;
while (true) {
const url = new URL(`${BASE_URL}/admin/products`);
url.searchParams.set("fields", "id,title,variants.id,variants.sku,variants.ean,variants.upc,variants.barcode");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa products ${res.status}`);
const body = await res.json();
for (const product of body.products) {
for (const variant of product.variants || []) {
entries.push({
productId: product.id,
variantId: variant.id,
barcode: variant.barcode ?? null,
ean: variant.ean ?? null,
upc: variant.upc ?? null,
});
}
}
offset += limit;
if (offset >= body.count) return entries;
}
}
async function clearIdentifierField(token, productId, variantId, field) {
const res = await fetch(`${BASE_URL}/admin/products/${productId}/variants/${variantId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ [field]: null }),
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.variant;
}
export async function run() {
const token = await getToken();
const variants = await listAllVariants(token);
const conflicts = findBarcodeConflicts(variants);
if (conflicts.length === 0) {
console.log(`Done. No barcode, ean, or upc conflicts found across ${variants.length} variant(s).`);
return;
}
console.log(`Found ${conflicts.length} conflict(s).`);
for (const c of conflicts) {
console.log(`Field ${c.field} value "${c.value}" shared by:`);
for (const entry of c.entries) {
console.log(` product_id=${entry.productId} variant_id=${entry.variantId}`);
}
}
if (CONFIRMED_VARIANT_ID && CONFIRMED_FIELD) {
const target = conflicts.find(
(c) => c.field === CONFIRMED_FIELD && c.entries.some((e) => e.variantId === CONFIRMED_VARIANT_ID)
);
if (!target) {
console.warn(
`CONFIRMED_VARIANT_ID ${CONFIRMED_VARIANT_ID} with field ${CONFIRMED_FIELD} was not found among the reported conflicts. Nothing cleared.`
);
} else {
const productId = target.entries.find((e) => e.variantId === CONFIRMED_VARIANT_ID).productId;
console.log(
`${DRY_RUN ? "Would clear" : "Clearing"} field ${CONFIRMED_FIELD} on variant ${CONFIRMED_VARIANT_ID} (product ${productId})`
);
if (!DRY_RUN) await clearIdentifierField(token, productId, CONFIRMED_VARIANT_ID, CONFIRMED_FIELD);
}
}
console.log(`Done. ${conflicts.length} conflict(s) reported.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The grouping rule is the part most worth testing, because it decides which barcodes, eans, and upcs get reported as conflicts and, just as importantly, which repeated values it correctly leaves alone. Because we kept find_barcode_conflicts pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.
from find_barcode_conflicts import find_barcode_conflicts
def variant(**over):
base = {"productId": "prod_1", "variantId": "variant_1", "barcode": None, "ean": None, "upc": None}
base.update(over)
return base
def test_no_conflicts_returns_empty_list():
variants = [
variant(productId="prod_1", variantId="variant_1", barcode="1111"),
variant(productId="prod_2", variantId="variant_2", barcode="2222"),
]
assert find_barcode_conflicts(variants) == []
def test_two_products_sharing_a_barcode_is_flagged():
variants = [
variant(productId="prod_1", variantId="variant_1", barcode="1111"),
variant(productId="prod_2", variantId="variant_2", barcode="1111"),
]
conflicts = find_barcode_conflicts(variants)
assert len(conflicts) == 1
assert conflicts[0]["field"] == "barcode"
assert conflicts[0]["value"] == "1111"
assert len(conflicts[0]["entries"]) == 2
def test_same_product_repeat_is_not_flagged():
variants = [
variant(productId="prod_1", variantId="variant_1", barcode="1111"),
variant(productId="prod_1", variantId="variant_2", barcode="1111"),
]
assert find_barcode_conflicts(variants) == []
def test_fields_are_checked_independently():
variants = [
variant(productId="prod_1", variantId="variant_1", ean="9999"),
variant(productId="prod_2", variantId="variant_2", upc="9999"),
]
assert find_barcode_conflicts(variants) == []
def test_blank_and_none_values_are_ignored():
variants = [
variant(productId="prod_1", variantId="variant_1", barcode=""),
variant(productId="prod_2", variantId="variant_2", barcode=None),
]
assert find_barcode_conflicts(variants) == []
def test_conflicts_are_sorted_by_field_then_value():
variants = [
variant(productId="prod_1", variantId="variant_1", upc="500"),
variant(productId="prod_2", variantId="variant_2", upc="500"),
variant(productId="prod_3", variantId="variant_3", barcode="100"),
variant(productId="prod_4", variantId="variant_4", barcode="100"),
]
conflicts = find_barcode_conflicts(variants)
assert [c["field"] for c in conflicts] == ["barcode", "upc"]
def test_three_way_collision_reports_all_three_entries():
variants = [
variant(productId="prod_1", variantId="variant_1", ean="7777"),
variant(productId="prod_2", variantId="variant_2", ean="7777"),
variant(productId="prod_3", variantId="variant_3", ean="7777"),
]
conflicts = find_barcode_conflicts(variants)
assert len(conflicts) == 1
assert len(conflicts[0]["entries"]) == 3
import { test } from "node:test";
import assert from "node:assert/strict";
import { findBarcodeConflicts } from "./find-barcode-conflicts.js";
const variant = (over = {}) => ({
productId: "prod_1",
variantId: "variant_1",
barcode: null,
ean: null,
upc: null,
...over,
});
test("no conflicts returns empty list", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", barcode: "1111" }),
variant({ productId: "prod_2", variantId: "variant_2", barcode: "2222" }),
];
assert.deepEqual(findBarcodeConflicts(variants), []);
});
test("two products sharing a barcode is flagged", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", barcode: "1111" }),
variant({ productId: "prod_2", variantId: "variant_2", barcode: "1111" }),
];
const conflicts = findBarcodeConflicts(variants);
assert.equal(conflicts.length, 1);
assert.equal(conflicts[0].field, "barcode");
assert.equal(conflicts[0].value, "1111");
assert.equal(conflicts[0].entries.length, 2);
});
test("same product repeat is not flagged", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", barcode: "1111" }),
variant({ productId: "prod_1", variantId: "variant_2", barcode: "1111" }),
];
assert.deepEqual(findBarcodeConflicts(variants), []);
});
test("fields are checked independently", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", ean: "9999" }),
variant({ productId: "prod_2", variantId: "variant_2", upc: "9999" }),
];
assert.deepEqual(findBarcodeConflicts(variants), []);
});
test("blank and null values are ignored", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", barcode: "" }),
variant({ productId: "prod_2", variantId: "variant_2", barcode: null }),
];
assert.deepEqual(findBarcodeConflicts(variants), []);
});
test("conflicts are sorted by field then value", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", upc: "500" }),
variant({ productId: "prod_2", variantId: "variant_2", upc: "500" }),
variant({ productId: "prod_3", variantId: "variant_3", barcode: "100" }),
variant({ productId: "prod_4", variantId: "variant_4", barcode: "100" }),
];
const conflicts = findBarcodeConflicts(variants);
assert.deepEqual(conflicts.map((c) => c.field), ["barcode", "upc"]);
});
test("three way collision reports all three entries", () => {
const variants = [
variant({ productId: "prod_1", variantId: "variant_1", ean: "7777" }),
variant({ productId: "prod_2", variantId: "variant_2", ean: "7777" }),
variant({ productId: "prod_3", variantId: "variant_3", ean: "7777" }),
];
const conflicts = findBarcodeConflicts(variants);
assert.equal(conflicts.length, 1);
assert.equal(conflicts[0].entries.length, 3);
});
Case studies
The apparel brand that duplicated a shirt for a new colorway
A clothing store used Duplicate to spin up a new seasonal print of a bestselling shirt, planning to swap the images and options afterward. The duplication threw a 500 with a Postgres constraint name in the response, and the merchandiser had no idea a single field, the EAN carried over from the original shirt, was the entire cause.
Running the conflict script against the catalog showed one EAN shared between the original shirt and a half-created duplicate product stuck in draft. Once confirmed, the team cleared the EAN on the draft duplicate's variant with a single PATCH, then let the merchandiser enter the real new EAN once the print run's actual barcode was assigned by their supplier.
The multipack that was never actually a bug
A supplements brand ran the script across their full catalog and got back a barcode conflict between a single bottle product and a three-pack bundle of the exact same bottle. At first glance it looked like a leftover duplication artifact.
Checking the source product against the flagged one showed the three-pack intentionally reused the single bottle's barcode, since the supplier issued only one GTIN across both listings. The team left both variants untouched and moved on to the next conflict in the report, which is exactly the outcome the diagnostic-first approach is built to produce.
After this runs, every barcode, ean, or upc conflict in your catalog is a known, reviewed fact instead of a mystery 500 error the next time someone clicks Duplicate. Nothing gets cleared automatically, since only a human can tell an accidental duplication artifact from a legitimate shared GTIN like a bundle. When you are ready, the clear-to-null step gives you a safe, reversible way to unblock a confirmed duplicate without ever inventing a barcode that might collide with something real.
FAQ
Why does duplicating a Medusa product fail with a unique constraint error?
The admin Duplicate action clones a product by re-submitting its variants through the same createProductsWorkflow used for a normal create, and it copies every variant field verbatim, including sku, ean, upc, and barcode. The product_variant table has unique partial indexes on those identifier columns, so as soon as the duplicate's variant carries the same barcode as the source, Postgres throws a unique constraint violation. Medusa never auto-clears or regenerates these fields during duplication, so the failure is deterministic for any product whose variants have a barcode-family value set.
Is it safe to automatically clear a duplicate product's barcode field?
Clearing a barcode to null is the one safe automated write, but deciding which of the two products should keep the real barcode is a business decision, not something a script should guess. The safer default is to report every conflict first, then, only when a human confirms the record is an unwanted duplicate, clear that variant's barcode, ean, or upc to null with a PATCH call and let the merchant re-enter the correct value later.
How do I find products with a duplicate barcode, ean, or upc in Medusa?
Call GET /admin/products with fields=id,title,variants.id,variants.sku,variants.ean,variants.upc,variants.barcode and page through every product with limit and offset. Group the variants by each non-null value of barcode, ean, and upc independently in your own code. Any value that maps to more than one distinct product id is a conflict candidate worth reviewing before you decide whether it is an accidental duplicate or a legitimate shared GTIN, such as a bundle.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #5541: Error when duplicating product with variant EAN due to unique index constraint violation. github.com/medusajs/medusa/issues/5541
- medusajs/medusa GitHub issue #15239: Admin API products endpoint cannot search by variant SKU (regression vs v1). github.com/medusajs/medusa/issues/15239
- Medusa Admin User Guide: Manage Product Variants in Medusa Admin. docs.medusajs.com/user-guide/products/variants
On the solution:
- Medusa Core Workflows Reference: createProductsWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createProductsWorkflow
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
- Medusa Documentation: Product Workflows. docs.medusajs.com/resources/commerce-modules/product/workflows
Stuck on a tricky one?
If you have a problem in Medusa catalog data, 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 a stuck duplicate?
If this saved you from a confusing 500 error or a quiet catalog conflict, 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