Diagnostic Inventory and catalog
Duplicate or missing SKUs in BigCommerce
Two products quietly share the same SKU, or a product has no SKU at all, and nobody notices until an order export breaks or a POS system maps the wrong item. BigCommerce only checks a SKU is unique the moment you write it. It never scans the catalog afterward, so conflicts from CSV imports, sync tools, and product duplication pile up unseen. Here is why it happens and a small script that finds every conflict and flags it for a human to fix.
BigCommerce validates SKU uniqueness only on write, when a POST or PUT to /v3/catalog/products or its /variants sub-resource returns a 422 on collision. It never retroactively scans existing data, so duplicates and blanks that entered through CSV bulk imports, multi-channel or POS or ERP sync, or the Admin's Duplicate product action persist without anyone knowing. Run a small Python or Node.js script that pages through GET /v3/catalog/products?include=variants&limit=250, normalizes and groups every SKU, and reports duplicates and missing SKUs. It never rewrites a SKU. It only flags conflicts with a custom_fields marker in write mode so a merchandiser can hand-correct the real value. Full code, tests, and a dry run guard are below.
The problem in plain words
A SKU is supposed to be the one code that points at exactly one item in your catalog. BigCommerce enforces that promise in exactly one place: the instant something tries to save a SKU. A POST or PUT to /v3/catalog/products, or to a product's /variants sub-resource, gets rejected with a 422 if the value you are trying to save already belongs to something else.
That check only fires on write. BigCommerce does not run a background job that walks the whole catalog looking for SKUs that collide. So the moment a duplicate or a blank gets in through a path that never triggers that single check, or that writes each row independently without seeing the whole picture, it stays in your catalog forever, invisible until it breaks something downstream like an order export, a POS lookup, or an ERP sync.
Why it happens
The uniqueness check is real, but it is scoped to a single write. A few common ways stores end up with conflicts anyway:
- A CSV bulk import loads thousands of rows, and two rows in the same file (or across two imports done weeks apart) carry the same SKU by copy-paste error.
- A multi-channel or POS/ERP sync tool pushes products from a system that does not enforce SKU uniqueness the same way, or that retries a partial sync and creates a second product instead of updating the first.
- Someone clicks Duplicate product in the Admin to save time building a variant. BigCommerce's duplicate can carry over the exact same SKU as the source product, or blank it out, depending on the field.
- The
skufield is optional on product and variant creation. Integrations and manual entry alike can save a product with no SKU, and BigCommerce assigns no default in its place.
This is a common source of confusion because nothing in the Admin surfaces existing conflicts. Merchants find out only when an order export cannot tell two line items apart, or a warehouse scan matches the wrong product. See the citations at the end for the exact threads and docs.
A SKU conflict is not something a script should silently repair. The SKU on each side of a duplicate, or the blank on a missing one, usually encodes a vendor part number or a mapping an outside POS or ERP system depends on. Rewriting one side, or inventing a new value for a blank, can break that external sync in a way nobody notices until the next order. So the safe pattern is detect, then flag, then let a merchandiser make the actual correction in Storefront or Admin.
The fix, as a flow
We do not touch the SKU values themselves. We add a job that pages through the full catalog and every product's variants, normalizes each SKU, and groups them to find collisions and blanks. In write mode it appends a small, machine-readable marker to custom_fields so the conflict is easy to find later. In dry run, which is the default, it only reports the conflict table.
Build it step by step
Get a store hash and an access token
Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts. Give it read access to Products, and write access if you plan to run with DRY_RUN=false. Keep the store hash and access token in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="your_store_hash"
export BIGCOMMERCE_ACCESS_TOKEN="your_access_token"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 catalog API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/ with your token in the X-Auth-Token header. A small helper sends the request, raises on a bad status, and unwraps the data envelope that every V3 response wraps its payload in.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
Page through every product and its variants
Ask for GET /v3/catalog/products?include=variants&limit=250 and follow meta.pagination.links.next, or just keep asking for the next page until a page comes back shorter than the limit. Every product's own sku field is one record, and every entry in its variants array is another, each carrying its own id, the parent product_id, and its sku.
def all_products():
"""Yield every product with its variants, paginated."""
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=variants&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
def sku_records(product):
"""Flatten a product into SKU records: the product's own SKU plus each variant's."""
records = [{"id": product["id"], "parentProductId": None, "sku": product.get("sku")}]
for variant in product.get("variants") or []:
records.append({"id": variant["id"], "parentProductId": product["id"], "sku": variant.get("sku")})
return records
async function* allProducts() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=variants&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
function skuRecords(product) {
const records = [{ id: product.id, parentProductId: null, sku: product.sku }];
for (const variant of product.variants || []) {
records.push({ id: variant.id, parentProductId: product.id, sku: variant.sku });
}
return records;
}
Classify with one pure function
Keep the decision in its own function that takes the flattened records and returns the duplicate groups and the missing ones. A pure function like this is easy to read and easy to test, which we do later. It normalizes each SKU with trim() and lower-casing, since BigCommerce matches SKUs case-sensitively at the API level but merchants usually mean "ABC-1" and "abc-1" as the same code, and it treats a null, undefined, or whitespace-only SKU as missing rather than a joinable key.
def classify_sku_conflicts(records):
"""records: [{"id", "parentProductId", "sku"}] -> {"duplicates", "missing"}. Pure, no I/O."""
groups = {}
missing = []
for record in records:
sku = record.get("sku")
normalized = sku.strip().lower() if isinstance(sku, str) else ""
if not normalized:
missing.append({"id": record["id"], "parentProductId": record.get("parentProductId")})
continue
groups.setdefault(normalized, []).append(record["id"])
duplicates = [
{"normalizedSku": sku, "recordIds": ids}
for sku, ids in groups.items()
if len(ids) > 1
]
duplicates.sort(key=lambda d: d["normalizedSku"])
missing.sort(key=lambda m: m["id"])
return {"duplicates": duplicates, "missing": missing}
export function classifySkuConflicts(records) {
const groups = new Map();
const missing = [];
for (const record of records) {
const sku = record.sku;
const normalized = typeof sku === "string" ? sku.trim().toLowerCase() : "";
if (!normalized) {
missing.push({ id: record.id, parentProductId: record.parentProductId ?? null });
continue;
}
if (!groups.has(normalized)) groups.set(normalized, []);
groups.get(normalized).push(record.id);
}
const duplicates = [...groups.entries()]
.filter(([, ids]) => ids.length > 1)
.map(([normalizedSku, recordIds]) => ({ normalizedSku, recordIds }))
.sort((a, b) => (a.normalizedSku < b.normalizedSku ? -1 : a.normalizedSku > b.normalizedSku ? 1 : 0));
missing.sort((a, b) => a.id - b.id);
return { duplicates, missing };
}
Flag, never rewrite
When write mode is on, append one custom_fields entry to each conflicting product (or variant) with a machine-readable marker, for example {"name":"sku_conflict","value":"duplicate:abc-1|ids:1023,1877"} or {"name":"sku_conflict","value":"missing_sku"}. That is the only write the script ever makes. It never touches the sku field itself, since inventing or moving a SKU can break an external POS or ERP mapping the merchant depends on.
def flag_product(product_id, marker_value):
return bc("PUT", f"/v3/catalog/products/{product_id}",
json={"custom_fields": [{"name": "sku_conflict", "value": marker_value}]})
def flag_variant(product_id, variant_id, marker_value):
return bc("PUT", f"/v3/catalog/products/{product_id}/variants/{variant_id}",
json={"custom_fields": [{"name": "sku_conflict", "value": marker_value}]})
async function flagProduct(productId, markerValue) {
return bc("PUT", `/v3/catalog/products/${productId}`, {
custom_fields: [{ name: "sku_conflict", value: markerValue }],
});
}
async function flagVariant(productId, variantId, markerValue) {
return bc("PUT", `/v3/catalog/products/${productId}/variants/${variantId}`, {
custom_fields: [{ name: "sku_conflict", value: markerValue }],
});
}
Wire it together with a dry run guard
The loop pages the catalog, flattens every product and variant into SKU records, classifies them, and only writes when DRY_RUN is false. On the first few runs, leave DRY_RUN on so it only prints the conflict table: product id, variant id, SKU, and conflict type. Read the output, confirm it against the Admin, then switch it off. Run it on a schedule that matches how often your catalog changes, for example nightly.
Always start with DRY_RUN=true. Cross-check a suspected duplicate against GET /v3/catalog/products?sku=ABC-1,ABC-2 or GET /v3/catalog/products/{id}/variants?sku=ABC-1 before you trust the report, since those exact-match filters confirm which product or variant ids truly collide.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and is safe to run again and again because it never writes anything except the review marker, and only when a conflict is real.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find duplicate and missing SKUs across a BigCommerce catalog, and flag them.
BigCommerce only validates SKU uniqueness at write time, on a POST or PUT to
/v3/catalog/products or its /variants sub-resource, which returns a 422 if the
value collides with an existing one. It never retroactively scans the catalog,
so duplicates and blanks that entered through CSV bulk imports, multi-channel or
POS/ERP sync tools, or the Admin's Duplicate product action persist undetected.
Blank SKUs are common because the sku field is optional on creation.
This pages through GET /v3/catalog/products?include=variants&limit=250 across
the full catalog, flattens each product and its variants into SKU records,
classifies them with a pure function, and in write mode appends a custom_fields
marker to each conflicting product or variant so a merchandiser can hand-correct
the real value. It never rewrites a SKU itself. Guarded by DRY_RUN. Safe to run
again and again.
Guide: https://www.allanninal.dev/bigcommerce/duplicate-or-missing-skus/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_sku_conflicts")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def bc(method, path, **kwargs):
r = requests.request(
method, BASE + path.lstrip("/"),
headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
timeout=30, **kwargs,
)
r.raise_for_status()
if not r.content:
return None
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
def classify_sku_conflicts(records):
"""records: [{"id", "parentProductId", "sku"}] -> {"duplicates", "missing"}. Pure, no I/O.
Normalizes each sku with trim() and lower-casing, treating null, undefined, or
whitespace-only as missing rather than a joinable key. Groups the rest by the
normalized value and reports every group with more than one record id as a
duplicate. Output is sorted for deterministic, testable ordering.
"""
groups = {}
missing = []
for record in records:
sku = record.get("sku")
normalized = sku.strip().lower() if isinstance(sku, str) else ""
if not normalized:
missing.append({"id": record["id"], "parentProductId": record.get("parentProductId")})
continue
groups.setdefault(normalized, []).append(record["id"])
duplicates = [
{"normalizedSku": sku, "recordIds": ids}
for sku, ids in groups.items()
if len(ids) > 1
]
duplicates.sort(key=lambda d: d["normalizedSku"])
missing.sort(key=lambda m: m["id"])
return {"duplicates": duplicates, "missing": missing}
def all_products():
"""Yield every product with its variants, paginated."""
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=variants&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
def sku_records(product):
"""Flatten a product into SKU records: the product's own SKU plus each variant's."""
records = [{"id": product["id"], "parentProductId": None, "sku": product.get("sku")}]
for variant in product.get("variants") or []:
records.append({"id": variant["id"], "parentProductId": product["id"], "sku": variant.get("sku")})
return records
def flag_product(product_id, marker_value):
return bc("PUT", f"/v3/catalog/products/{product_id}",
json={"custom_fields": [{"name": "sku_conflict", "value": marker_value}]})
def flag_variant(product_id, variant_id, marker_value):
return bc("PUT", f"/v3/catalog/products/{product_id}/variants/{variant_id}",
json={"custom_fields": [{"name": "sku_conflict", "value": marker_value}]})
def run():
all_records = []
for product in all_products():
all_records.extend(sku_records(product))
result = classify_sku_conflicts(all_records)
duplicates = result["duplicates"]
missing = result["missing"]
for dup in duplicates:
ids = ",".join(str(i) for i in dup["recordIds"])
marker = f"duplicate:{dup['normalizedSku']}|ids:{ids}"
log.warning("Duplicate SKU %r across ids %s. %s",
dup["normalizedSku"], ids, "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
for record_id in dup["recordIds"]:
flag_product(record_id, marker)
for miss in missing:
log.warning("Missing SKU on id %s (parentProductId=%s). %s",
miss["id"], miss["parentProductId"], "would flag" if DRY_RUN else "flagging")
if not DRY_RUN:
if miss["parentProductId"] is None:
flag_product(miss["id"], "missing_sku")
else:
flag_variant(miss["parentProductId"], miss["id"], "missing_sku")
log.info(
"Done. %d duplicate group(s) and %d missing SKU(s) %s.",
len(duplicates), len(missing), "to flag" if DRY_RUN else "flagged",
)
if __name__ == "__main__":
run()
/**
* Find duplicate and missing SKUs across a BigCommerce catalog, and flag them.
*
* BigCommerce only validates SKU uniqueness at write time, on a POST or PUT to
* /v3/catalog/products or its /variants sub-resource, which returns a 422 if the
* value collides with an existing one. It never retroactively scans the catalog,
* so duplicates and blanks that entered through CSV bulk imports, multi-channel or
* POS/ERP sync tools, or the Admin's Duplicate product action persist undetected.
*
* This pages through GET /v3/catalog/products?include=variants&limit=250 across
* the full catalog, flattens each product and its variants into SKU records,
* classifies them with a pure function, and in write mode appends a custom_fields
* marker to each conflicting product or variant. It never rewrites a SKU itself.
* Guarded by DRY_RUN. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/duplicate-or-missing-skus/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "dummy_store_hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function classifySkuConflicts(records) {
const groups = new Map();
const missing = [];
for (const record of records) {
const sku = record.sku;
const normalized = typeof sku === "string" ? sku.trim().toLowerCase() : "";
if (!normalized) {
missing.push({ id: record.id, parentProductId: record.parentProductId ?? null });
continue;
}
if (!groups.has(normalized)) groups.set(normalized, []);
groups.get(normalized).push(record.id);
}
const duplicates = [...groups.entries()]
.filter(([, ids]) => ids.length > 1)
.map(([normalizedSku, recordIds]) => ({ normalizedSku, recordIds }))
.sort((a, b) => (a.normalizedSku < b.normalizedSku ? -1 : a.normalizedSku > b.normalizedSku ? 1 : 0));
missing.sort((a, b) => a.id - b.id);
return { duplicates, missing };
}
async function bc(method, path, body) {
const res = await fetch(BASE + path.replace(/^\//, ""), {
method,
headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return null;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
async function* allProducts() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=variants&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
function skuRecords(product) {
const records = [{ id: product.id, parentProductId: null, sku: product.sku }];
for (const variant of product.variants || []) {
records.push({ id: variant.id, parentProductId: product.id, sku: variant.sku });
}
return records;
}
async function flagProduct(productId, markerValue) {
return bc("PUT", `/v3/catalog/products/${productId}`, {
custom_fields: [{ name: "sku_conflict", value: markerValue }],
});
}
async function flagVariant(productId, variantId, markerValue) {
return bc("PUT", `/v3/catalog/products/${productId}/variants/${variantId}`, {
custom_fields: [{ name: "sku_conflict", value: markerValue }],
});
}
export async function run() {
const allRecords = [];
for await (const product of allProducts()) {
allRecords.push(...skuRecords(product));
}
const { duplicates, missing } = classifySkuConflicts(allRecords);
for (const dup of duplicates) {
const ids = dup.recordIds.join(",");
const marker = `duplicate:${dup.normalizedSku}|ids:${ids}`;
console.warn(`Duplicate SKU "${dup.normalizedSku}" across ids ${ids}. ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) {
for (const recordId of dup.recordIds) await flagProduct(recordId, marker);
}
}
for (const miss of missing) {
console.warn(`Missing SKU on id ${miss.id} (parentProductId=${miss.parentProductId}). ${DRY_RUN ? "would flag" : "flagging"}`);
if (!DRY_RUN) {
if (miss.parentProductId === null) await flagProduct(miss.id, "missing_sku");
else await flagVariant(miss.parentProductId, miss.id, "missing_sku");
}
}
console.log(`Done. ${duplicates.length} duplicate group(s) and ${missing.length} missing SKU(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification rule is the part most worth testing, because it decides what gets reported and, eventually, what gets flagged. Because we kept classify_sku_conflicts pure, the test needs no network and no BigCommerce store. It just feeds in plain records and checks the answer.
from find_sku_conflicts import classify_sku_conflicts
def record(id, sku, parent=None):
return {"id": id, "parentProductId": parent, "sku": sku}
def test_no_conflicts_when_all_unique():
records = [record(1, "ABC-1"), record(2, "ABC-2"), record(3, "ABC-3")]
result = classify_sku_conflicts(records)
assert result["duplicates"] == []
assert result["missing"] == []
def test_detects_duplicate_case_and_whitespace_insensitive():
records = [record(1, "ABC-1"), record(2, " abc-1 "), record(3, "ABC-2")]
result = classify_sku_conflicts(records)
assert result["duplicates"] == [{"normalizedSku": "abc-1", "recordIds": [1, 2]}]
def test_detects_missing_sku_for_null_blank_and_whitespace():
records = [
record(1, None),
record(2, ""),
record(3, " "),
record(4, "ABC-9"),
]
result = classify_sku_conflicts(records)
assert result["duplicates"] == []
assert result["missing"] == [
{"id": 1, "parentProductId": None},
{"id": 2, "parentProductId": None},
{"id": 3, "parentProductId": None},
]
def test_missing_keeps_parent_product_id_for_variants():
records = [record(55, None, parent=10)]
result = classify_sku_conflicts(records)
assert result["missing"] == [{"id": 55, "parentProductId": 10}]
def test_duplicates_sorted_by_normalized_sku_and_missing_by_id():
records = [
record(3, None),
record(1, None),
record(9, "zzz-1"),
record(8, "zzz-1"),
record(6, "aaa-1"),
record(5, "aaa-1"),
]
result = classify_sku_conflicts(records)
assert [d["normalizedSku"] for d in result["duplicates"]] == ["aaa-1", "zzz-1"]
assert [m["id"] for m in result["missing"]] == [1, 3]
def test_three_way_duplicate_groups_all_ids():
records = [record(1, "DUP-1"), record(2, "DUP-1"), record(3, "DUP-1")]
result = classify_sku_conflicts(records)
assert result["duplicates"] == [{"normalizedSku": "dup-1", "recordIds": [1, 2, 3]}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifySkuConflicts } from "./find-sku-conflicts.js";
const record = (id, sku, parentProductId = null) => ({ id, sku, parentProductId });
test("no conflicts when all unique", () => {
const records = [record(1, "ABC-1"), record(2, "ABC-2"), record(3, "ABC-3")];
const result = classifySkuConflicts(records);
assert.deepEqual(result.duplicates, []);
assert.deepEqual(result.missing, []);
});
test("detects duplicate case and whitespace insensitive", () => {
const records = [record(1, "ABC-1"), record(2, " abc-1 "), record(3, "ABC-2")];
const result = classifySkuConflicts(records);
assert.deepEqual(result.duplicates, [{ normalizedSku: "abc-1", recordIds: [1, 2] }]);
});
test("detects missing sku for null, blank, and whitespace", () => {
const records = [record(1, null), record(2, ""), record(3, " "), record(4, "ABC-9")];
const result = classifySkuConflicts(records);
assert.deepEqual(result.duplicates, []);
assert.deepEqual(result.missing, [
{ id: 1, parentProductId: null },
{ id: 2, parentProductId: null },
{ id: 3, parentProductId: null },
]);
});
test("missing keeps parentProductId for variants", () => {
const records = [record(55, null, 10)];
const result = classifySkuConflicts(records);
assert.deepEqual(result.missing, [{ id: 55, parentProductId: 10 }]);
});
test("duplicates sorted by normalizedSku and missing by id", () => {
const records = [
record(3, null),
record(1, null),
record(9, "zzz-1"),
record(8, "zzz-1"),
record(6, "aaa-1"),
record(5, "aaa-1"),
];
const result = classifySkuConflicts(records);
assert.deepEqual(result.duplicates.map((d) => d.normalizedSku), ["aaa-1", "zzz-1"]);
assert.deepEqual(result.missing.map((m) => m.id), [1, 3]);
});
test("three way duplicate groups all ids", () => {
const records = [record(1, "DUP-1"), record(2, "DUP-1"), record(3, "DUP-1")];
const result = classifySkuConflicts(records);
assert.deepEqual(result.duplicates, [{ normalizedSku: "dup-1", recordIds: [1, 2, 3] }]);
});
Case studies
The migration that copied a SKU twice
A housewares store migrated from another platform with a CSV import split across three batches. One vendor part number got pasted onto two different color variants by mistake, and BigCommerce happily accepted both since each import ran as its own set of writes and never saw the other batch.
Nobody noticed until a fulfillment scan pulled up the wrong variant for a pick. Running the script in dry run first surfaced the exact duplicate group by product id, the team fixed the real vendor code for the correct variant in Admin, and the fake match never happened again.
The register that kept creating blanks
A retailer syncing products from an in-store POS system had a handful of quick, in-person product entries that staff created without a barcode on hand. Those synced to BigCommerce with a completely empty SKU, and grew every week the POS pushed new items.
The nightly job flagged every blank with a sku_conflict custom field, giving the merchandising team a standing list to clear. Within a month the backlog was down to zero, and new blanks get caught the same night they appear instead of piling up for a quarter.
After this runs on a schedule, duplicate and missing SKUs never sit unnoticed for months. Every conflict shows up in the log the same run it happens, and a merchandiser can search Admin for the sku_conflict field to jump straight to it. The script never guesses at what a SKU should be, so the outside systems that depend on those codes stay exactly as reliable as they were before.
FAQ
Why does BigCommerce let two products share the same SKU?
BigCommerce only checks that a SKU is unique at the moment you write it, on a POST or PUT to the catalog API or in the Admin. It never goes back and scans the whole catalog afterward. So if a duplicate enters through a CSV import, a sync tool, or the Duplicate product button before that check applies, it sits in your catalog undetected.
Why do some BigCommerce products have no SKU at all?
The sku field is optional when a product or variant is created. If a CSV import, an integration, or a staff member saves a product without typing one in, BigCommerce accepts it with a blank SKU and never assigns a default value in its place.
Can a script safely auto-fix duplicate or missing SKUs?
No, not by rewriting the SKU itself. A SKU usually encodes a vendor part number or a mapping used by an outside POS or ERP system, so guessing a new value risks breaking that sync. The safe move is to detect every conflict and flag it with a custom field so a merchandiser can correct the real value by hand.
Related field notes
Citations
On the problem:
- BigCommerce Support: API product update error, the product SKU is a duplicate. support.bigcommerce.com api product update error duplicate sku
- BigCommerce Support: why is the sku value null when all products have been set up with sku values? support.bigcommerce.com sku value null
- BigCommerce Support: handling SKUs for base products and variants. support.bigcommerce.com handling skus for base products and variants
On the solution:
- BigCommerce Developer Center: Get Products (List Products), the V3 catalog products endpoint. developer.bigcommerce.com rest-catalog products
- BigCommerce Developer Center: Product Variants. developer.bigcommerce.com rest-catalog product-variants
- BigCommerce Docs: List Variants, the variants batch endpoint. docs.bigcommerce.com product-variants variants-batch get-variants
Stuck on a tricky one?
If you have a problem in BigCommerce orders, catalog, inventory, or fulfillment 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 clean up your catalog?
If this saved you a pile of manual clicks or a broken export, 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