Reconciler Catalog / Products
Duplicating a product creates variants with colliding SKUs
You click Duplicate on a product with a dozen variants, expecting a clean copy to edit. Instead every cloned variant is quietly stamped with the same SKU as the original, or no SKU at all, and nothing warns you. BigCommerce only checks SKU uniqueness the moment something writes to it, not the moment a product is cloned, so the collision sits there until an inventory sync, a bulk edit, or a later save trips a 409 Conflict. Here is why the copy skips new SKUs and a small script that finds every collision and safely renames only the duplicates.
When BigCommerce duplicates a product, in the admin or through a script cloning it with the Catalog API, it copies the entire variant option matrix but does not mint new SKU values for the cloned variants. It either repeats the source product's SKU verbatim across every variant row or leaves them blank. BigCommerce enforces SKU uniqueness only as a write-time constraint, a 409 Conflict on POST or PUT, rather than generating a unique SKU at duplication time, so the duplicate persists with colliding SKUs until something else tries to write or match on one. Paginate GET /v3/catalog/products?include=variants&limit=250 (or per product, GET /v3/catalog/products/{product_id}/variants), group each product's variant SKUs case-normalized and trimmed, and flag any non-empty SKU shared by two or more variants. Report by default. Only rewrite the duplicates with a deterministically suffixed SKU when you explicitly enable it. Full code, tests, and a dry run guard are below.
The problem in plain words
Duplicating a product is meant to save time: same options, same modifiers, same images, ready to tweak. BigCommerce does copy the full variant option matrix faithfully, every Color/Size combination, every price and weight override. What it does not do is generate a fresh, unique SKU for each of those copied variant rows.
Instead, the clone typically ends up with the source product's SKU repeated across every variant, or blank SKUs where the platform did not bother to carry one over. Because BigCommerce validates SKU uniqueness only at write time, the save that creates the duplicate goes through without complaint. Nothing in the duplication flow itself checks the resulting catalog for conflicts. The collision sits there, invisible, until something later actually tries to write to one of those variants: an inventory sync tool pushing stock levels, a bulk price edit, or simply a merchant opening a variant and clicking save. That is the moment BigCommerce finally enforces uniqueness and returns "Some of your variants' SKUs already exist," often on a screen that gives no hint the real cause was a duplication done days or weeks earlier.
Why it happens
A few concrete paths lead to the same colliding-SKU state:
- Clicking Duplicate on a product in the BigCommerce admin. The new product gets its own product_id, but its variant rows inherit the original SKU value verbatim, or arrive blank, depending on how that particular variant was templated.
- A script or migration tool that clones a product through the Catalog API, POSTing a new product with the same variants payload as the source, without generating fresh SKU values for each variant before the request.
- BigCommerce enforcing SKU uniqueness only as a write-time constraint, a 409 Conflict on
POSTorPUT, rather than as a rule checked and auto-resolved the moment a duplicate is created. - The conflict staying invisible until a later, unrelated write finally touches one of the colliding variants, at which point the error message ("Some of your variants' SKUs already exist") gives no indication the root cause was a duplication done earlier.
This is a well documented pain point on BigCommerce's own support forum, where merchants report having to manually edit dozens of variant SKUs per duplicated product after hitting this error. See the citations at the end for the exact threads.
BigCommerce will never tell you about this at duplication time, because nothing checks uniqueness then. The only reliable way to find these collisions is to walk the catalog yourself and compare variant SKUs directly, rather than waiting for a 409 Conflict to surface on some unrelated write. And because a SKU often carries meaning for an external inventory or ERP system, the safe default is to report every collision with enough detail to fix it by hand, not to silently rewrite SKUs a merchant may be relying on elsewhere.
The fix, as a flow
We do not touch the Duplicate action or the Catalog API's write path. We add a job that walks every product's variants, groups them by SKU, and reports every collision it finds, with an explicit, separate step to rename the duplicates only when someone turns that on.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Products (modify) scope so it can read variants and, when enabled, write renamed SKUs. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" # start safe, change to false to write renames
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to write renames
Talk to the V3 Catalog API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response, and follows the V3 meta.pagination.links.next cursor so we never miss a page of products.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
Walk every product's variants
Call GET /v3/catalog/products?include=variants&limit=250, following meta.pagination.links.next until it is empty, to pull every product with its variants embedded in one pass. Flatten that into a plain list of variant records, each carrying product_id, variant_id, sku, and option_values, which is exactly what the pure decision function needs.
def all_products_with_variants():
path = "/catalog/products"
params = {"include": "variants", "limit": 250}
while path:
payload = bc_get(path, params) if params else bc_get(path)
for product in payload["data"]:
yield product
next_url = payload.get("meta", {}).get("pagination", {}).get("links", {}).get("next")
path, params = (next_url, None) if next_url else (None, None)
def flatten_variants(products):
for product in products:
for variant in product.get("variants") or []:
yield {
"product_id": product["id"],
"variant_id": variant["id"],
"sku": variant.get("sku") or "",
"option_values": variant.get("option_values") or [],
}
async function* allProductsWithVariants() {
let path = "/catalog/products";
let params = { include: "variants", limit: 250 };
while (path) {
const payload = params ? await bcGet(path, params) : await bcGet(path);
for (const product of payload.data) yield product;
const nextUrl = payload.meta && payload.meta.pagination && payload.meta.pagination.links
? payload.meta.pagination.links.next
: null;
path = nextUrl || null;
params = null;
}
}
function* flattenVariants(products) {
for (const product of products) {
for (const variant of product.variants || []) {
yield {
product_id: product.id,
variant_id: variant.id,
sku: variant.sku || "",
option_values: variant.option_values || [],
};
}
}
}
Decide, with one pure function
Keep the collision logic in its own function that takes only the flat variant list and returns the collision groups. It normalizes each SKU with strip().lower(), groups variants by (product_id, normalized_sku), drops blank SKUs since an empty value is not a collision, and keeps only groups with more than one variant.
def find_sku_collisions(variants):
groups = {}
for v in variants:
normalized_sku = (v.get("sku") or "").strip().lower()
if normalized_sku == "":
continue
key = f'{v["product_id"]}:{normalized_sku}'
groups.setdefault(key, []).append(v)
return {key: rows for key, rows in groups.items() if len(rows) > 1}
export function findSkuCollisions(variants) {
const groups = new Map();
for (const v of variants) {
const normalizedSku = (v.sku || "").trim().toLowerCase();
if (normalizedSku === "") continue;
const key = `${v.product_id}:${normalizedSku}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(v);
}
const collisions = {};
for (const [key, rows] of groups) {
if (rows.length > 1) collisions[key] = rows;
}
return collisions;
}
Report every collision, then rename only when asked
By default the job only reports: for each collision group it logs the product_id, every variant_id, the shared sku, and each variant's option_values, so a human can see exactly which option combination, for example Color:Red/Size:M, maps to the ambiguous SKU. Renaming is a separate, explicit step. When enabled, the first variant in each group keeps its original SKU untouched, and every remaining duplicate gets a deterministic suffix built from its own variant_id, so re-running the job is safe and idempotent.
def rename_duplicate(product_id, variant_id, original_sku):
new_sku = f"{original_sku}-{variant_id}"
return bc_put(f"/catalog/products/{product_id}/variants/{variant_id}", {"sku": new_sku})
async function renameDuplicate(productId, variantId, originalSku) {
const newSku = `${originalSku}-${variantId}`;
return bcPut(`/catalog/products/${productId}/variants/${variantId}`, { sku: newSku });
}
Wire it together with an explicit apply gate
The loop ties every piece together. Notice there are two independent gates, not one: DRY_RUN and an explicit apply flag. By default the job walks the catalog, finds every collision, and writes a report (CSV or JSON) of the affected products, variants, and option values. It only renames anything when you pass --apply AND DRY_RUN is set to false. Read the report first, confirm which variant should really keep the original SKU, then opt in to the rename.
Always start with DRY_RUN=true and without --apply, so the job only reports. A renamed SKU can break external inventory or ERP matching that keyed off the old value, so never let a scheduled run rename automatically. Renaming should always be a deliberate, reviewed, opt-in step.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, reports every collision by default, and only renames the duplicates within a collision group when both DRY_RUN=false and --apply are set, always leaving the first variant in each group untouched.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and optionally repair BigCommerce variants left with colliding SKUs
after a product duplication.
When BigCommerce duplicates a product, in the admin or through a script
cloning it via the Catalog API, it copies the full variant option matrix but
does not mint new SKU values for the cloned variants. It either repeats the
source product's SKU verbatim across every variant row or leaves them blank.
BigCommerce only enforces SKU uniqueness as a write-time constraint, a 409
Conflict on save, rather than auto-generating a unique SKU at duplication
time, so the copy silently persists with colliding SKUs until something else
tries to write or match on one. This job walks the catalog, groups each
product's variant SKUs, and reports every collision. Renaming is gated
behind an explicit --apply flag and DRY_RUN guard, because a SKU can be
keyed against an external inventory or ERP system.
Guide: https://www.allanninal.dev/bigcommerce/duplicate-product-creates-colliding-skus/
"""
import os
import sys
import csv
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_colliding_variant_skus")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
APPLY = "--apply" in sys.argv
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def find_sku_collisions(variants):
"""Pure decision. No network, no side effects.
Takes a flat list of variant records, each with product_id, variant_id,
sku, and option_values. Normalizes sku via sku.strip().lower(), groups
variants by (product_id, normalized_sku), drops blank SKUs (not a
collision), and returns only groups with more than one variant, keyed by
"{product_id}:{sku}".
"""
groups = {}
for v in variants:
normalized_sku = (v.get("sku") or "").strip().lower()
if normalized_sku == "":
continue
key = f'{v["product_id"]}:{normalized_sku}'
groups.setdefault(key, []).append(v)
return {key: rows for key, rows in groups.items() if len(rows) > 1}
def all_products_with_variants():
"""Page through every product with its variants embedded."""
path = "/catalog/products"
params = {"include": "variants", "limit": 250}
while path:
payload = bc_get(path, params) if params else bc_get(path)
for product in payload["data"]:
yield product
next_url = payload.get("meta", {}).get("pagination", {}).get("links", {}).get("next")
path, params = (next_url, None) if next_url else (None, None)
def flatten_variants(products):
for product in products:
for variant in product.get("variants") or []:
yield {
"product_id": product["id"],
"variant_id": variant["id"],
"sku": variant.get("sku") or "",
"option_values": variant.get("option_values") or [],
}
def rename_duplicate(product_id, variant_id, original_sku):
new_sku = f"{original_sku}-{variant_id}"
return bc_put(f"/catalog/products/{product_id}/variants/{variant_id}", {"sku": new_sku})
def write_report(collisions, path="sku_collisions.csv"):
with open(path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["product_id", "variant_id", "sku", "option_values"])
for rows in collisions.values():
for row in rows:
writer.writerow([
row["product_id"],
row["variant_id"],
row["sku"],
json.dumps(row["option_values"]),
])
return path
def run():
products = list(all_products_with_variants())
variants = list(flatten_variants(products))
collisions = find_sku_collisions(variants)
if not collisions:
log.info("No colliding SKUs found across %d product(s).", len(products))
return
report_path = write_report(collisions)
log.info(
"Found %d colliding SKU group(s) across %d product(s). Report written to %s",
len(collisions), len(products), report_path,
)
for key, rows in collisions.items():
log.warning(
"Collision %s: %s",
key,
[{"variant_id": r["variant_id"], "option_values": r["option_values"]} for r in rows],
)
if not APPLY:
log.info("Report only. Pass --apply and set DRY_RUN=false to rename duplicates.")
return
renamed = 0
for rows in collisions.values():
keep, duplicates = rows[0], rows[1:]
log.info("Keeping original sku=%s on variant_id=%s", keep["sku"], keep["variant_id"])
for dup in duplicates:
if DRY_RUN:
log.info(
"Would rename variant_id=%s sku=%s -> %s-%s",
dup["variant_id"], dup["sku"], dup["sku"], dup["variant_id"],
)
else:
rename_duplicate(dup["product_id"], dup["variant_id"], dup["sku"])
log.info("Renamed variant_id=%s sku=%s -> %s-%s",
dup["variant_id"], dup["sku"], dup["sku"], dup["variant_id"])
renamed += 1
log.info("Done. %d duplicate variant(s) %s.", renamed, "would be renamed" if DRY_RUN else "renamed")
if __name__ == "__main__":
run()
/**
* Find and optionally repair BigCommerce variants left with colliding SKUs
* after a product duplication.
*
* When BigCommerce duplicates a product, in the admin or through a script
* cloning it via the Catalog API, it copies the full variant option matrix
* but does not mint new SKU values for the cloned variants. It either
* repeats the source product's SKU verbatim across every variant row or
* leaves them blank. BigCommerce only enforces SKU uniqueness as a
* write-time constraint, a 409 Conflict on save, rather than
* auto-generating a unique SKU at duplication time, so the copy silently
* persists with colliding SKUs until something else tries to write or
* match on one. This job walks the catalog, groups each product's variant
* SKUs, and reports every collision. Renaming is gated behind an explicit
* --apply flag and DRY_RUN guard, because a SKU can be keyed against an
* external inventory or ERP system.
*
* Guide: https://www.allanninal.dev/bigcommerce/duplicate-product-creates-colliding-skus/
*/
import { pathToFileURL } from "node:url";
import fs from "node:fs";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const APPLY = process.argv.includes("--apply");
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* Takes a flat list of variant records, each with product_id, variant_id,
* sku, and option_values. Normalizes sku via trim().toLowerCase(), groups
* variants by "product_id:normalizedSku", drops blank SKUs (not a
* collision), and returns only groups with more than one variant.
*/
export function findSkuCollisions(variants) {
const groups = new Map();
for (const v of variants) {
const normalizedSku = (v.sku || "").trim().toLowerCase();
if (normalizedSku === "") continue;
const key = `${v.product_id}:${normalizedSku}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(v);
}
const collisions = {};
for (const [key, rows] of groups) {
if (rows.length > 1) collisions[key] = rows;
}
return collisions;
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function* allProductsWithVariants() {
let path = "/catalog/products";
let params = { include: "variants", limit: 250 };
while (path) {
const payload = params ? await bcGet(path, params) : await bcGet(path);
for (const product of payload.data) yield product;
const nextUrl = payload.meta && payload.meta.pagination && payload.meta.pagination.links
? payload.meta.pagination.links.next
: null;
path = nextUrl || null;
params = null;
}
}
function flattenVariants(products) {
const out = [];
for (const product of products) {
for (const variant of product.variants || []) {
out.push({
product_id: product.id,
variant_id: variant.id,
sku: variant.sku || "",
option_values: variant.option_values || [],
});
}
}
return out;
}
async function renameDuplicate(productId, variantId, originalSku) {
const newSku = `${originalSku}-${variantId}`;
return bcPut(`/catalog/products/${productId}/variants/${variantId}`, { sku: newSku });
}
function writeReport(collisions, path = "sku_collisions.csv") {
const lines = ["product_id,variant_id,sku,option_values"];
for (const rows of Object.values(collisions)) {
for (const row of rows) {
const optionValues = JSON.stringify(row.option_values).replace(/"/g, '""');
lines.push(`${row.product_id},${row.variant_id},${row.sku},"${optionValues}"`);
}
}
fs.writeFileSync(path, lines.join("\n"));
return path;
}
export async function run() {
const products = [];
for await (const product of allProductsWithVariants()) products.push(product);
const variants = flattenVariants(products);
const collisions = findSkuCollisions(variants);
if (Object.keys(collisions).length === 0) {
console.log(`No colliding SKUs found across ${products.length} product(s).`);
return;
}
const reportPath = writeReport(collisions);
console.log(
`Found ${Object.keys(collisions).length} colliding SKU group(s) across ${products.length} product(s). Report written to ${reportPath}`
);
for (const [key, rows] of Object.entries(collisions)) {
console.warn(
`Collision ${key}:`,
rows.map((r) => ({ variant_id: r.variant_id, option_values: r.option_values }))
);
}
if (!APPLY) {
console.log("Report only. Pass --apply and set DRY_RUN=false to rename duplicates.");
return;
}
let renamed = 0;
for (const rows of Object.values(collisions)) {
const [keep, ...duplicates] = rows;
console.log(`Keeping original sku=${keep.sku} on variant_id=${keep.variant_id}`);
for (const dup of duplicates) {
if (DRY_RUN) {
console.log(`Would rename variant_id=${dup.variant_id} sku=${dup.sku} -> ${dup.sku}-${dup.variant_id}`);
} else {
await renameDuplicate(dup.product_id, dup.variant_id, dup.sku);
console.log(`Renamed variant_id=${dup.variant_id} sku=${dup.sku} -> ${dup.sku}-${dup.variant_id}`);
}
renamed += 1;
}
}
console.log(`Done. ${renamed} duplicate variant(s) ${DRY_RUN ? "would be renamed" : "renamed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The collision-detection rule is the part most worth testing, because it decides which variants get reported and, later, renamed. Because find_sku_collisions takes only a plain list of variant records and returns a plain dict, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the grouping.
from fix_colliding_variant_skus import find_sku_collisions
def variant(product_id=1, variant_id=1, sku="ABC-1", option_values=None):
return {
"product_id": product_id,
"variant_id": variant_id,
"sku": sku,
"option_values": option_values or [],
}
def test_no_collisions_when_all_skus_are_unique():
variants = [variant(variant_id=1, sku="ABC-1"), variant(variant_id=2, sku="ABC-2")]
assert find_sku_collisions(variants) == {}
def test_finds_collision_within_same_product():
variants = [
variant(variant_id=1, sku="ABC-1"),
variant(variant_id=2, sku="ABC-1"),
]
collisions = find_sku_collisions(variants)
assert list(collisions.keys()) == ["1:abc-1"]
assert len(collisions["1:abc-1"]) == 2
def test_normalizes_sku_case_and_whitespace():
variants = [
variant(variant_id=1, sku=" ABC-1 "),
variant(variant_id=2, sku="abc-1"),
]
collisions = find_sku_collisions(variants)
assert len(collisions["1:abc-1"]) == 2
def test_blank_skus_are_not_collisions():
variants = [variant(variant_id=1, sku=""), variant(variant_id=2, sku="")]
assert find_sku_collisions(variants) == {}
def test_same_sku_on_different_products_is_not_grouped_together():
variants = [
variant(product_id=1, variant_id=1, sku="ABC-1"),
variant(product_id=2, variant_id=2, sku="ABC-1"),
]
assert find_sku_collisions(variants) == {}
def test_collision_detected_independently_per_product():
variants = [
variant(product_id=1, variant_id=1, sku="ABC-1"),
variant(product_id=1, variant_id=2, sku="ABC-1"),
variant(product_id=2, variant_id=3, sku="ABC-1"),
variant(product_id=2, variant_id=4, sku="ABC-1"),
]
collisions = find_sku_collisions(variants)
assert "1:abc-1" in collisions
assert "2:abc-1" in collisions
assert len(collisions) == 2
def test_option_values_are_preserved_for_reporting():
variants = [
variant(variant_id=1, sku="ABC-1", option_values=[{"option_display_name": "Color", "label": "Red"}]),
variant(variant_id=2, sku="ABC-1", option_values=[{"option_display_name": "Color", "label": "Blue"}]),
]
collisions = find_sku_collisions(variants)
rows = collisions["1:abc-1"]
assert rows[0]["option_values"][0]["label"] == "Red"
assert rows[1]["option_values"][0]["label"] == "Blue"
import { test } from "node:test";
import assert from "node:assert/strict";
import { findSkuCollisions } from "./fix-colliding-variant-skus.js";
const variant = ({ product_id = 1, variant_id = 1, sku = "ABC-1", option_values = [] } = {}) => ({
product_id, variant_id, sku, option_values,
});
test("no collisions when all SKUs are unique", () => {
const variants = [variant({ variant_id: 1, sku: "ABC-1" }), variant({ variant_id: 2, sku: "ABC-2" })];
assert.deepEqual(findSkuCollisions(variants), {});
});
test("finds collision within same product", () => {
const variants = [variant({ variant_id: 1, sku: "ABC-1" }), variant({ variant_id: 2, sku: "ABC-1" })];
const collisions = findSkuCollisions(variants);
assert.deepEqual(Object.keys(collisions), ["1:abc-1"]);
assert.equal(collisions["1:abc-1"].length, 2);
});
test("normalizes SKU case and whitespace", () => {
const variants = [variant({ variant_id: 1, sku: " ABC-1 " }), variant({ variant_id: 2, sku: "abc-1" })];
const collisions = findSkuCollisions(variants);
assert.equal(collisions["1:abc-1"].length, 2);
});
test("blank SKUs are not collisions", () => {
const variants = [variant({ variant_id: 1, sku: "" }), variant({ variant_id: 2, sku: "" })];
assert.deepEqual(findSkuCollisions(variants), {});
});
test("same SKU on different products is not grouped together", () => {
const variants = [
variant({ product_id: 1, variant_id: 1, sku: "ABC-1" }),
variant({ product_id: 2, variant_id: 2, sku: "ABC-1" }),
];
assert.deepEqual(findSkuCollisions(variants), {});
});
test("collision detected independently per product", () => {
const variants = [
variant({ product_id: 1, variant_id: 1, sku: "ABC-1" }),
variant({ product_id: 1, variant_id: 2, sku: "ABC-1" }),
variant({ product_id: 2, variant_id: 3, sku: "ABC-1" }),
variant({ product_id: 2, variant_id: 4, sku: "ABC-1" }),
];
const collisions = findSkuCollisions(variants);
assert.ok("1:abc-1" in collisions);
assert.ok("2:abc-1" in collisions);
assert.equal(Object.keys(collisions).length, 2);
});
test("option values are preserved for reporting", () => {
const variants = [
variant({ variant_id: 1, sku: "ABC-1", option_values: [{ option_display_name: "Color", label: "Red" }] }),
variant({ variant_id: 2, sku: "ABC-1", option_values: [{ option_display_name: "Color", label: "Blue" }] }),
];
const collisions = findSkuCollisions(variants);
const rows = collisions["1:abc-1"];
assert.equal(rows[0].option_values[0].label, "Red");
assert.equal(rows[1].option_values[0].label, "Blue");
});
Case studies
The store that duplicated a bestseller for every new season
A mid-size apparel store duplicated its bestselling t-shirt product every quarter to relaunch it under a new season name, keeping the same eight Color/Size variants. Each duplicate quietly copied the original's SKU across all eight variants. Nobody noticed until an inventory sync tool tried to update stock levels and started throwing 409 errors on products that had shipped months earlier.
Running the reconciler against the full catalog surfaced eleven duplicated products with colliding SKUs in one pass, each report line showing exactly which Color/Size combination needed a real, distinct SKU. The merchandising team fixed the ones that mattered for active sync and used --apply to auto-suffix the rest that were already discontinued.
The migration that cloned two hundred products overnight
An agency wrote a one-off script to clone two hundred products from a staging catalog into production ahead of a rebrand, using the Catalog API's product creation endpoint with the same variants payload as each source product. Because the script never generated new SKUs, every cloned product's variants collided with its own source product's SKUs.
The report-only run flagged all two hundred products before a single write went out, listing product_id and variant_id pairs the agency could cross-check against their migration spreadsheet. They fixed the SKU-generation gap in their own script for future migrations, and used the rename path just once to clean up the batch that had already landed.
After this runs on a schedule, every product duplication that left colliding SKUs behind shows up in a report within one pass of the catalog, long before an inventory sync or a bulk edit trips a 409 Conflict on some unrelated day. Nothing gets renamed automatically. A merchandiser reviews the report, confirms which variant should keep the original SKU, and only then opts in to the deterministic rename for the rest.
FAQ
Why does duplicating a product in BigCommerce leave variants sharing one SKU?
When BigCommerce clones a product, either through the admin's Duplicate action or a script that copies it via the Catalog API, it copies the full variant option matrix but does not mint new SKU values for the cloned variants. It either repeats the source product's SKU verbatim across every variant row or leaves them blank. BigCommerce only enforces SKU uniqueness as a write-time constraint, a 409 Conflict on save, rather than auto-generating a unique SKU at duplication time, so the copy silently persists with colliding SKUs until something tries to write or match on one.
Why do I only see "Some of your variants' SKUs already exist" after the fact?
BigCommerce does not validate SKU uniqueness at the moment a product is duplicated. The duplicate saves cleanly with repeated or blank SKUs across its variants. The conflict only surfaces the next time something writes to one of those variants, such as an inventory sync, a bulk edit, or a later manual save, which is when the 409 Conflict and the on-screen error finally appear, often far removed from the duplication that actually caused it.
Is it safe to automatically rename every colliding SKU?
Not by default. A SKU is often keyed against an external inventory or ERP system, so silently renaming it can break that matching. The safe default is to report every collision, grouped by product and by the option values it maps to, such as Color:Red/Size:M, so a human can confirm which variant should keep the original SKU. Renaming should be an explicit, opt-in step, not something a reconciler does automatically on every run.
Related field notes
Citations
On the problem:
- BigCommerce Support: SKU issues when duplicating a product. support.bigcommerce.com SKU issues when duplicating product
- BigCommerce Support: generating automated variant SKUs across product copies to avoid the "already exist" error. support.bigcommerce.com automated variant SKUs on product copies
- BigCommerce Support: API product update error, the product SKU is a duplicate. support.bigcommerce.com API product update error, duplicate SKU
On the solution:
- BigCommerce Developer Center: Product Variants reference. developer.bigcommerce.com product variants
- BigCommerce API Reference: Catalog, Product Variants. docs.bigcommerce.com catalog product variants
- BigCommerce API Reference: Get Product Variant. docs.bigcommerce.com get product variant
Stuck on a tricky one?
If you have a problem in BigCommerce catalog, products, orders, or inventory 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 variant SKUs?
If this caught colliding SKUs before they broke an inventory sync, 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