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.

Python and Node.js V3 Catalog API Safe by default (dry run)
The short answer

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.

Migration import CSV bulk tool Delete + recreate new SKUs, new variant IDs product_list swap referenced product changes CSV only edits price/weight on existing values cannot re-link values Modifier orphaned still on the product Checkout breaks
The import can update pricing on option_values that already exist, but it cannot create, delete, or re-link them. Whatever the modifier pointed at before the import survives, even after the real record is gone.

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:

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.

The key insight

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.

Scheduled job runs on a timer Page products variants, options, modifiers Cross-check values live SKUs, live product ids Confirmed orphan? yes ambiguous, audit list Delete or strip stale values write mode only
Only a confirmed orphan, one whose referenced product or variant is verifiably gone, is a candidate for a write. Everything else goes to an audit list instead of the catalog.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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
step2.js
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;
}
3

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.

step3.py
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")}
step3.js
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));
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

repair.py
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})
repair.js
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 });
}
6

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.

Run it safe

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_stale_modifiers.py
"""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-stale-modifiers.js
/**
 * 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.

test_stale_modifier_detection.py
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]
find-stale-modifiers.test.js
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

Platform migration

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.

Product_list add-on

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.

What good looks like

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:

  1. BigCommerce Support: Variants and Modifiers. support.bigcommerce.com variants and modifiers
  2. BigCommerce Support: Importing Product Options (v2). support.bigcommerce.com importing product options
  3. 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:

  1. BigCommerce Developer Center: Product Modifiers. developer.bigcommerce.com rest-catalog product-modifiers
  2. BigCommerce Developer Center: Modifier Values. developer.bigcommerce.com rest-catalog product-modifiers values
  3. 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.

Contact me on LinkedIn

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

Back to all BigCommerce field notes