Diagnostic Migration and housekeeping
Stale product modifiers after import in BigCommerce
A migration tool rewrites your variants, and the modifier picker in the storefront still lists an option that points at nothing. BigCommerce's CSV import and export can only edit the price or weight adjuster on a modifier option_value that already exists. It cannot create, delete, or re-link one. So when a bulk import deletes and recreates variants, or swaps the product behind a product_list modifier, the old modifier survives on the parent product as an orphan that still renders in the admin and at checkout. Here is why it happens and a small script that finds every stale modifier and reports it for a human to fix.
BigCommerce's CSV product import and export tools can only edit the price and weight adjusters on modifier option_values that already exist. They cannot create, delete, or fully re-link them. So when a migration or bulk-import tool deletes and recreates variants with new SKUs and variant IDs, or replaces the product a product_list or product_list_with_images modifier points at, the old modifier and its option_values are left behind on the parent product. They still return from the API and render in the admin, but they reference variant SKUs or a value_data.product_id that no longer exists, so they cannot resolve at checkout. Run a small Python or Node.js script that pages through GET /v3/catalog/products?include=variants,options,modifiers&limit=250, cross-checks each modifier's option_values against the product's current variants and the live catalog, and reports the stale ones. It never deletes a customer-facing modifier on its own. Full code, tests, and a dry run guard are below.
The problem in plain words
A product modifier is a customer-facing choice, like a monogram text field, an engraving option, or a linked accessory pulled from another product in the catalog. Behind that choice sits a list of option_values, and for price or weight adjusting rules, those values can be tied to specific variant SKUs or, for a product_list type modifier, to a value_data.product_id that points at a real product elsewhere in the store.
The CSV import and export tools that most migrations and bulk tools rely on were built to update pricing and weight rules on modifiers that already exist, not to manage the modifier's shape. They cannot create a new option_value, cannot delete one, and cannot re-point one at a different variant or product. So when the import deletes a product's old variants and creates brand new ones with new SKUs and variant IDs, which is common in a full re-import, or when it replaces the product a product_list modifier referenced, the modifier itself is untouched. It survives with the same option_values it always had, quietly pointing at records that the import just erased.
Why it happens
The gap is real and it is a limitation of the CSV import format itself, not a bug in a specific migration tool. A few common ways stores end up with orphaned modifiers:
- A full product re-import deletes all of a product's variants and recreates them with new SKUs and new variant IDs, since the import treats the row as a fresh product rather than an update. Any modifier that had rules tied to the old variants keeps referencing SKUs that are simply gone.
- A
product_listorproduct_list_with_imagesmodifier lets a customer add a related product as an add-on. When a migration or catalog cleanup deletes that referenced product, or a re-import creates a new product id in its place, the modifier'svalue_data.product_idnever gets updated to match. - An import tool writes a modifier mid-migration and the process fails partway, leaving an
option_valuesentry whoseidno longer appears on a fresh read of the same modifier, a sign of a partial write. - A migration script builds modifiers from a source platform's option data but never carries over the logic to delete or update modifiers whose backing variant or product it also deleted in the same run.
This is a common source of confusion because nothing fails loudly. The modifier still returns from GET /v3/catalog/products/{product_id}/modifiers and still renders as a normal dropdown or add-on in the storefront and the admin. The break only shows up when a customer actually tries to check out with that choice selected. See the citations at the end for the exact docs and threads.
A stale modifier is not something a script should silently delete. If it is is_required=true, removing it wrong can let a broken or mispriced order through checkout in the other direction, by letting the customer skip a choice they were supposed to make. So the safe pattern is detect, then act only on confirmed orphans, then flag anything ambiguous for a merchant to review rather than writing to the catalog at all.
The fix, as a flow
We do not touch a modifier the moment we are unsure about it. We add a job that pulls each product's modifiers alongside its current variants and cross-checks every option_value against what actually still exists in the catalog. Confirmed orphans, like a dead value_data.product_id, are safe to act on. Anything else goes to an audit list for a merchant to review. In dry run, which is the default, it only reports.
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,
)
if r.status_code == 404:
return None
r.raise_for_status()
if not r.content:
return True
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.status === 404) return null;
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return true;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
Pull products, variants, and modifiers in one pass
Ask for GET /v3/catalog/products?include=variants,options,modifiers&limit=250 and page with meta.pagination. Each product comes back with its current variant SKU set alongside the modifiers that are attached to it, so you can compare them without a second round trip for the common case.
def all_products_with_modifiers():
"""Yield every product with its variants and modifiers included, paginated."""
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=variants,options,modifiers&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
def live_variant_skus(product):
"""The set of SKUs currently in this product's variants array."""
return {v["sku"] for v in product.get("variants") or [] if v.get("sku")}
async function* allProductsWithModifiers() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=variants,options,modifiers&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
function liveVariantSkus(product) {
return new Set((product.variants || []).map((v) => v.sku).filter(Boolean));
}
Decide, with one pure function
Keep the decision in its own function that takes a product's modifiers, the set of SKUs its variants matrix actually has right now, and the set of product ids known to still exist in the catalog, and returns the stale ones. A pure function like this is easy to read and easy to test, which we do later. A modifier is stale if it is a product_list or product_list_with_images type and any value_data.product_id is missing from the live product ids, if any option_value's value_data.sku is missing from the live variant SKUs, or if it is is_required with zero option_values.
PRODUCT_LIST_TYPES = {"product_list", "product_list_with_images"}
def find_stale_modifiers(modifiers, live_variant_skus, live_product_ids):
"""Pure decision logic (no I/O). See docstring in the full script below."""
stale = []
for modifier in modifiers:
option_values = modifier.get("option_values") or []
if modifier.get("is_required") and not option_values:
stale.append(modifier)
continue
is_stale = False
for value in option_values:
value_data = value.get("value_data") or {}
if modifier.get("type") in PRODUCT_LIST_TYPES:
product_id = value_data.get("product_id")
if product_id is not None and product_id not in live_product_ids:
is_stale = True
break
sku = value_data.get("sku")
if sku and sku not in live_variant_skus:
is_stale = True
break
if is_stale:
stale.append(modifier)
return stale
const PRODUCT_LIST_TYPES = new Set(["product_list", "product_list_with_images"]);
export function findStaleModifiers(modifiers, liveVariantSkus, liveProductIds) {
const stale = [];
for (const modifier of modifiers) {
const optionValues = modifier.option_values || [];
if (modifier.is_required && optionValues.length === 0) {
stale.push(modifier);
continue;
}
let isStale = false;
for (const value of optionValues) {
const valueData = value.value_data || {};
if (PRODUCT_LIST_TYPES.has(modifier.type)) {
const productId = valueData.product_id;
if (productId != null && !liveProductIds.has(productId)) {
isStale = true;
break;
}
}
const sku = valueData.sku;
if (sku && !liveVariantSkus.has(sku)) {
isStale = true;
break;
}
}
if (isStale) stale.push(modifier);
}
return stale;
}
Confirm before you write anything
A flagged modifier is a candidate, not a verdict. Before acting, confirm the referenced record is really gone: GET /v3/catalog/products/{id} for a value_data.product_id, or GET /v3/catalog/products/{id}/variants for a SKU. Only a confirmed 404, or a SKU still absent on a fresh read, is safe to touch. In write mode, delete the whole modifier only when every option_value is dead; otherwise strip just the dangling entries with a PUT so the still-valid choices remain for customers.
def delete_modifier(product_id, modifier_id):
return bc("DELETE", f"/v3/catalog/products/{product_id}/modifiers/{modifier_id}")
def strip_dangling_option_values(product_id, modifier_id, modifier, live_variant_skus, live_product_ids):
"""PUT the modifier back with only the option_values that still resolve."""
kept = []
for value in modifier.get("option_values") or []:
value_data = value.get("value_data") or {}
product_id_ref = value_data.get("product_id")
sku = value_data.get("sku")
if modifier.get("type") in PRODUCT_LIST_TYPES and product_id_ref is not None:
if product_id_ref not in live_product_ids:
continue
if sku and sku not in live_variant_skus:
continue
kept.append(value)
return bc("PUT", f"/v3/catalog/products/{product_id}/modifiers/{modifier_id}",
json={"option_values": kept})
async function deleteModifier(productId, modifierId) {
return bc("DELETE", `/v3/catalog/products/${productId}/modifiers/${modifierId}`);
}
async function stripDanglingOptionValues(productId, modifierId, modifier, liveVariantSkus, liveProductIds) {
const kept = (modifier.option_values || []).filter((value) => {
const valueData = value.value_data || {};
const productIdRef = valueData.product_id;
const sku = valueData.sku;
if (PRODUCT_LIST_TYPES.has(modifier.type) && productIdRef != null) {
if (!liveProductIds.has(productIdRef)) return false;
}
if (sku && !liveVariantSkus.has(sku)) return false;
return true;
});
return bc("PUT", `/v3/catalog/products/${productId}/modifiers/${modifierId}`, { option_values: kept });
}
Wire it together with a dry run guard
The loop pages the catalog, reads each product's live variant SKUs, calls GET /v3/catalog/products/{product_id}/modifiers, and runs the pure decision function. In write mode it only ever touches modifiers where every option_value is a confirmed dead reference; anything with a mix of live and dead values gets its dangling entries stripped instead of the whole modifier deleted. On the first few runs leave DRY_RUN on so it only logs what it would do. Run it right after a migration, and again on a recurring schedule to catch drift from ongoing sync tools.
Always start with DRY_RUN=true. Never let this script auto-delete a customer-facing modifier that is is_required=true without a human reading the log first, since removing a required modifier wrong can let a broken or mispriced order pass checkout with no choice made at all.
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 only writes to modifiers whose stale option_values are confirmed dead against a fresh read of the catalog.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and safely report stale product modifiers left behind after a BigCommerce import.
The CSV product import and export tools can only edit price and weight adjusters on
modifier option_values that already exist. They cannot create, delete, or fully
re-link one. So when a migration or bulk-import tool deletes and recreates variants
with new SKUs and variant IDs, or replaces the product a product_list or
product_list_with_images modifier points at, the old modifier and its option_values
survive on the parent product. They still return from the API and render in the
admin, but they reference a variant SKU or a value_data.product_id that no longer
exists, so they cannot resolve at checkout.
This pages through GET /v3/catalog/products?include=variants,options,modifiers&limit=250,
reads each product's modifiers, and classifies them with a pure function against the
product's current variant SKUs and the live catalog's product ids. In write mode it
deletes a modifier only when every option_value is a confirmed dead reference, and
strips just the dangling entries when some values are still valid. Anything ambiguous
is recorded in an audit list instead of written. Guarded by DRY_RUN. Safe to run again
and again.
Guide: https://www.allanninal.dev/bigcommerce/stale-product-modifiers-after-import/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_stale_modifiers")
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"
PRODUCT_LIST_TYPES = {"product_list", "product_list_with_images"}
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,
)
if r.status_code == 404:
return None
r.raise_for_status()
if not r.content:
return True
body = r.json()
return body["data"] if isinstance(body, dict) and "data" in body else body
def find_stale_modifiers(modifiers, live_variant_skus, live_product_ids):
"""Pure decision logic (no I/O). Given one product's modifiers (as returned by
GET /v3/catalog/products/{id}/modifiers), the set of SKUs currently in
GET /v3/catalog/products/{id}/variants, and the set of product IDs known to
still exist in the catalog, return the list of modifier dicts judged stale.
A modifier is stale if:
- type in {"product_list", "product_list_with_images"} and any
option_values[].value_data.product_id is not in live_product_ids; or
- any option_values[].value_data.sku (or adjusters-linked variant sku) is
not in live_variant_skus; or
- is_required is True and option_values is empty.
"""
stale = []
for modifier in modifiers:
option_values = modifier.get("option_values") or []
if modifier.get("is_required") and not option_values:
stale.append(modifier)
continue
is_stale = False
for value in option_values:
value_data = value.get("value_data") or {}
if modifier.get("type") in PRODUCT_LIST_TYPES:
product_id = value_data.get("product_id")
if product_id is not None and product_id not in live_product_ids:
is_stale = True
break
sku = value_data.get("sku")
if sku and sku not in live_variant_skus:
is_stale = True
break
if is_stale:
stale.append(modifier)
return stale
def all_products_with_modifiers():
"""Yield every product with its variants and modifiers included, paginated."""
page = 1
limit = 250
while True:
batch = bc("GET", f"/v3/catalog/products?include=variants,options,modifiers&limit={limit}&page={page}")
if not batch:
return
for product in batch:
yield product
if len(batch) < limit:
return
page += 1
def live_variant_skus(product):
return {v["sku"] for v in product.get("variants") or [] if v.get("sku")}
def product_exists(product_id):
return bc("GET", f"/v3/catalog/products/{product_id}") is not None
def all_dead_references(modifier, live_variant_skus_set, live_product_ids):
"""True only if every option_value on this modifier is a confirmed dead reference."""
option_values = modifier.get("option_values") or []
if not option_values:
return True
for value in option_values:
value_data = value.get("value_data") or {}
product_id = value_data.get("product_id")
sku = value_data.get("sku")
product_dead = (
modifier.get("type") in PRODUCT_LIST_TYPES
and product_id is not None
and product_id not in live_product_ids
)
sku_dead = bool(sku) and sku not in live_variant_skus_set
if not (product_dead or sku_dead):
return False
return True
def delete_modifier(product_id, modifier_id):
return bc("DELETE", f"/v3/catalog/products/{product_id}/modifiers/{modifier_id}")
def strip_dangling_option_values(product_id, modifier_id, modifier, live_variant_skus_set, live_product_ids):
kept = []
for value in modifier.get("option_values") or []:
value_data = value.get("value_data") or {}
product_id_ref = value_data.get("product_id")
sku = value_data.get("sku")
if modifier.get("type") in PRODUCT_LIST_TYPES and product_id_ref is not None:
if product_id_ref not in live_product_ids:
continue
if sku and sku not in live_variant_skus_set:
continue
kept.append(value)
return bc("PUT", f"/v3/catalog/products/{product_id}/modifiers/{modifier_id}",
json={"option_values": kept})
def run():
audit = []
acted = 0
for product in all_products_with_modifiers():
modifiers = product.get("modifiers") or []
if not modifiers:
continue
skus = live_variant_skus(product)
product_ids_seen = {
v.get("value_data", {}).get("product_id")
for m in modifiers
for v in (m.get("option_values") or [])
if v.get("value_data", {}).get("product_id") is not None
}
live_product_ids = {pid for pid in product_ids_seen if product_exists(pid)}
stale = find_stale_modifiers(modifiers, skus, live_product_ids)
for modifier in stale:
product_id = product["id"]
modifier_id = modifier["id"]
if all_dead_references(modifier, skus, live_product_ids):
log.warning("Product %s modifier %s fully orphaned. %s",
product_id, modifier_id, "would delete" if DRY_RUN else "deleting")
if not DRY_RUN:
delete_modifier(product_id, modifier_id)
acted += 1
elif modifier.get("is_required") and not (modifier.get("option_values") or []):
log.warning("Product %s modifier %s is required with zero option_values, needs a human. Recording to audit list.",
product_id, modifier_id)
audit.append({"product_id": product_id, "modifier_id": modifier_id, "reason": "required_no_values"})
else:
log.warning("Product %s modifier %s has some dangling option_values. %s",
product_id, modifier_id, "would strip" if DRY_RUN else "stripping")
if not DRY_RUN:
strip_dangling_option_values(product_id, modifier_id, modifier, skus, live_product_ids)
acted += 1
log.info("Done. %d stale modifier(s) %s, %d recorded for review.",
acted, "to act on" if DRY_RUN else "acted on", len(audit))
return audit
if __name__ == "__main__":
run()
/**
* Find and safely report stale product modifiers left behind after a BigCommerce import.
*
* The CSV product import and export tools can only edit price and weight adjusters on
* modifier option_values that already exist. They cannot create, delete, or fully
* re-link one. So when a migration or bulk-import tool deletes and recreates variants
* with new SKUs and variant IDs, or replaces the product a product_list or
* product_list_with_images modifier points at, the old modifier and its option_values
* survive on the parent product, referencing records that no longer exist.
*
* This pages through GET /v3/catalog/products?include=variants,options,modifiers&limit=250,
* reads each product's modifiers, and classifies them with a pure function against the
* product's current variant SKUs and the live catalog's product ids. In write mode it
* deletes a modifier only when every option_value is a confirmed dead reference, and
* strips just the dangling entries when some values are still valid. Anything ambiguous
* is recorded in an audit list instead of written. Guarded by DRY_RUN. Safe to run again
* and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/stale-product-modifiers-after-import/
*/
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";
const PRODUCT_LIST_TYPES = new Set(["product_list", "product_list_with_images"]);
export function findStaleModifiers(modifiers, liveVariantSkus, liveProductIds) {
const stale = [];
for (const modifier of modifiers) {
const optionValues = modifier.option_values || [];
if (modifier.is_required && optionValues.length === 0) {
stale.push(modifier);
continue;
}
let isStale = false;
for (const value of optionValues) {
const valueData = value.value_data || {};
if (PRODUCT_LIST_TYPES.has(modifier.type)) {
const productId = valueData.product_id;
if (productId != null && !liveProductIds.has(productId)) {
isStale = true;
break;
}
}
const sku = valueData.sku;
if (sku && !liveVariantSkus.has(sku)) {
isStale = true;
break;
}
}
if (isStale) stale.push(modifier);
}
return stale;
}
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.status === 404) return null;
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
if (!text) return true;
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
}
async function* allProductsWithModifiers() {
const limit = 250;
let page = 1;
while (true) {
const batch = await bc("GET", `/v3/catalog/products?include=variants,options,modifiers&limit=${limit}&page=${page}`);
if (!batch || !batch.length) return;
for (const product of batch) yield product;
if (batch.length < limit) return;
page += 1;
}
}
function liveVariantSkus(product) {
return new Set((product.variants || []).map((v) => v.sku).filter(Boolean));
}
async function productExists(productId) {
return (await bc("GET", `/v3/catalog/products/${productId}`)) !== null;
}
function allDeadReferences(modifier, liveVariantSkusSet, liveProductIds) {
const optionValues = modifier.option_values || [];
if (optionValues.length === 0) return true;
for (const value of optionValues) {
const valueData = value.value_data || {};
const productId = valueData.product_id;
const sku = valueData.sku;
const productDead =
PRODUCT_LIST_TYPES.has(modifier.type) && productId != null && !liveProductIds.has(productId);
const skuDead = Boolean(sku) && !liveVariantSkusSet.has(sku);
if (!(productDead || skuDead)) return false;
}
return true;
}
async function deleteModifier(productId, modifierId) {
return bc("DELETE", `/v3/catalog/products/${productId}/modifiers/${modifierId}`);
}
async function stripDanglingOptionValues(productId, modifierId, modifier, liveVariantSkusSet, liveProductIds) {
const kept = (modifier.option_values || []).filter((value) => {
const valueData = value.value_data || {};
const productIdRef = valueData.product_id;
const sku = valueData.sku;
if (PRODUCT_LIST_TYPES.has(modifier.type) && productIdRef != null) {
if (!liveProductIds.has(productIdRef)) return false;
}
if (sku && !liveVariantSkusSet.has(sku)) return false;
return true;
});
return bc("PUT", `/v3/catalog/products/${productId}/modifiers/${modifierId}`, { option_values: kept });
}
export async function run() {
const audit = [];
let acted = 0;
for await (const product of allProductsWithModifiers()) {
const modifiers = product.modifiers || [];
if (modifiers.length === 0) continue;
const skus = liveVariantSkus(product);
const productIdsSeen = new Set(
modifiers
.flatMap((m) => m.option_values || [])
.map((v) => v.value_data?.product_id)
.filter((id) => id != null)
);
const liveProductIds = new Set();
for (const pid of productIdsSeen) {
if (await productExists(pid)) liveProductIds.add(pid);
}
const stale = findStaleModifiers(modifiers, skus, liveProductIds);
for (const modifier of stale) {
const productId = product.id;
const modifierId = modifier.id;
if (allDeadReferences(modifier, skus, liveProductIds)) {
console.warn(`Product ${productId} modifier ${modifierId} fully orphaned. ${DRY_RUN ? "would delete" : "deleting"}`);
if (!DRY_RUN) await deleteModifier(productId, modifierId);
acted++;
} else if (modifier.is_required && (modifier.option_values || []).length === 0) {
console.warn(`Product ${productId} modifier ${modifierId} is required with zero option_values, needs a human. Recording to audit list.`);
audit.push({ productId, modifierId, reason: "required_no_values" });
} else {
console.warn(`Product ${productId} modifier ${modifierId} has some dangling option_values. ${DRY_RUN ? "would strip" : "stripping"}`);
if (!DRY_RUN) await stripDanglingOptionValues(productId, modifierId, modifier, skus, liveProductIds);
acted++;
}
}
}
console.log(`Done. ${acted} stale modifier(s) ${DRY_RUN ? "to act on" : "acted on"}, ${audit.length} recorded for review.`);
return audit;
}
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 which modifiers get treated as stale in the first place. Because we kept find_stale_modifiers pure, the test needs no network and no BigCommerce store. It just feeds in plain dicts and checks the answer.
from find_stale_modifiers import find_stale_modifiers
def modifier(id, type="text", is_required=False, option_values=None):
return {"id": id, "type": type, "is_required": is_required, "option_values": option_values or []}
def value(sku=None, product_id=None):
data = {}
if sku is not None:
data["sku"] = sku
if product_id is not None:
data["product_id"] = product_id
return {"value_data": data}
def test_not_stale_when_all_references_are_live():
mods = [modifier(1, option_values=[value(sku="ABC-1")])]
assert find_stale_modifiers(mods, {"ABC-1"}, set()) == []
def test_stale_when_sku_missing_from_live_variants():
mods = [modifier(1, option_values=[value(sku="OLD-SKU")])]
result = find_stale_modifiers(mods, {"ABC-1"}, set())
assert result == mods
def test_stale_when_product_list_references_dead_product_id():
mods = [modifier(2, type="product_list", option_values=[value(product_id=99)])]
result = find_stale_modifiers(mods, set(), {1, 2, 3})
assert result == mods
def test_not_stale_when_product_list_references_live_product_id():
mods = [modifier(2, type="product_list", option_values=[value(product_id=1)])]
assert find_stale_modifiers(mods, set(), {1, 2, 3}) == []
def test_required_with_zero_option_values_is_stale():
mods = [modifier(3, is_required=True, option_values=[])]
assert find_stale_modifiers(mods, set(), set()) == mods
def test_optional_with_zero_option_values_is_not_flagged():
mods = [modifier(4, is_required=False, option_values=[])]
assert find_stale_modifiers(mods, set(), set()) == []
def test_ignores_type_other_than_product_list_for_product_id_check():
mods = [modifier(5, type="text", option_values=[value(product_id=99)])]
assert find_stale_modifiers(mods, set(), {1, 2, 3}) == []
def test_multiple_modifiers_only_flags_the_stale_one():
fine = modifier(6, option_values=[value(sku="ABC-1")])
stale = modifier(7, option_values=[value(sku="GONE")])
assert find_stale_modifiers([fine, stale], {"ABC-1"}, set()) == [stale]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleModifiers } from "./find-stale-modifiers.js";
const modifier = (id, { type = "text", is_required = false, option_values = [] } = {}) => ({
id, type, is_required, option_values,
});
const value = ({ sku, product_id } = {}) => {
const data = {};
if (sku !== undefined) data.sku = sku;
if (product_id !== undefined) data.product_id = product_id;
return { value_data: data };
};
test("not stale when all references are live", () => {
const mods = [modifier(1, { option_values: [value({ sku: "ABC-1" })] })];
assert.deepEqual(findStaleModifiers(mods, new Set(["ABC-1"]), new Set()), []);
});
test("stale when sku missing from live variants", () => {
const mods = [modifier(1, { option_values: [value({ sku: "OLD-SKU" })] })];
assert.deepEqual(findStaleModifiers(mods, new Set(["ABC-1"]), new Set()), mods);
});
test("stale when product_list references dead product id", () => {
const mods = [modifier(2, { type: "product_list", option_values: [value({ product_id: 99 })] })];
assert.deepEqual(findStaleModifiers(mods, new Set(), new Set([1, 2, 3])), mods);
});
test("not stale when product_list references live product id", () => {
const mods = [modifier(2, { type: "product_list", option_values: [value({ product_id: 1 })] })];
assert.deepEqual(findStaleModifiers(mods, new Set(), new Set([1, 2, 3])), []);
});
test("required with zero option_values is stale", () => {
const mods = [modifier(3, { is_required: true, option_values: [] })];
assert.deepEqual(findStaleModifiers(mods, new Set(), new Set()), mods);
});
test("optional with zero option_values is not flagged", () => {
const mods = [modifier(4, { is_required: false, option_values: [] })];
assert.deepEqual(findStaleModifiers(mods, new Set(), new Set()), []);
});
test("ignores type other than product_list for product id check", () => {
const mods = [modifier(5, { type: "text", option_values: [value({ product_id: 99 })] })];
assert.deepEqual(findStaleModifiers(mods, new Set(), new Set([1, 2, 3])), []);
});
test("multiple modifiers only flags the stale one", () => {
const fine = modifier(6, { option_values: [value({ sku: "ABC-1" })] });
const stale = modifier(7, { option_values: [value({ sku: "GONE" })] });
assert.deepEqual(findStaleModifiers([fine, stale], new Set(["ABC-1"]), new Set()), [stale]);
});
Case studies
The apparel store that re-imported every variant
An apparel brand moved from another cart with a CSV migration that deleted and recreated every color and size variant to get clean, sequential SKUs. A monogram modifier on a handful of jacket products had price adjusters tied to specific old variant SKUs, and those SKUs vanished the moment the import ran, even though the modifier itself imported fine.
Customers could still pick the monogram option; it just quietly priced against a phantom variant. Running the script in dry run surfaced the exact product and modifier ids, the merchandiser rebuilt the pricing rule against the new SKUs, and the storefront started charging correctly again the same day.
The gift shop whose bundle add-on pointed at nothing
A gift shop used a product_list modifier to let customers add a gift-wrap product to an order. A catalog cleanup deleted the old gift-wrap product and replaced it with a new one under a different product id, but the CSV export used for the cleanup had no way to update the modifier's value_data.product_id to match.
The add-on option kept showing in the storefront, but selecting it and going to checkout returned an error a support ticket described as random. The nightly job flagged the dead product_id reference as a confirmed orphan, and it was safe to delete outright since the modifier had exactly one option_value and it was fully dead. The team pointed a fresh modifier at the correct gift-wrap product the same day.
After this runs right after a migration and on a recurring schedule, a stale modifier never sits invisible until a customer hits it at checkout. Confirmed orphans are cleared automatically, dangling values are stripped without losing the choices that still work, and anything ambiguous lands on a merchant's desk instead of silently breaking an order. The script never guesses on a required modifier it cannot fully confirm, so checkout stays trustworthy the whole time.
FAQ
Why does BigCommerce leave old modifiers behind after an import?
The CSV product import and export tools can only edit price and weight adjusters on option_values that already exist. They cannot create, delete, or fully re-link a modifier's option_values. So when a migration or bulk-import tool deletes and recreates variants with new SKUs and IDs, or swaps the product a product_list modifier points at, the old modifier and its option_values survive on the parent product with nothing real left to reference.
How do I know if a BigCommerce modifier is actually stale?
Pull each product's modifiers with GET /v3/catalog/products/{product_id}/modifiers and compare it against the product's current variant SKUs from GET /v3/catalog/products/{id}/variants and, for product_list or product_list_with_images types, check whether value_data.product_id still resolves with GET /v3/catalog/products/{id}. A modifier is stale if it references a SKU that is gone, a product_id that 404s, or if it is required but has zero option_values.
Is it safe to auto-delete stale modifiers with a script?
No, not automatically. Deleting a required modifier can let broken or mispriced orders through checkout if the deletion was wrong. The safe pattern is to run in dry run by default, only ever act on confirmed orphans such as a dead value_data.product_id, and record anything ambiguous in an audit list for a merchant to review rather than writing to the catalog.
Related field notes
Citations
On the problem:
- BigCommerce Support: Variants and Modifiers. support.bigcommerce.com variants and modifiers
- BigCommerce Support: Importing Product Options (v2). support.bigcommerce.com importing product options
- BigCommerce Community: how to import product with modifier options and its rules using a CSV file. support.bigcommerce.com import modifier options csv
On the solution:
- BigCommerce Developer Center: Product Modifiers. developer.bigcommerce.com rest-catalog product-modifiers
- BigCommerce Developer Center: Modifier Values. developer.bigcommerce.com rest-catalog product-modifiers values
- BigCommerce Developer Center: V2 to V3 Catalog Operations Comparison. developer.bigcommerce.com catalog migration guide
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 checkout, 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