Diagnostic Catalog / Products

Variant price override stops following base product price changes

A merchant raises the product's base price, and every plain variant follows along on the storefront, except the ones that already had a number typed into their own price field. Those stay frozen at whatever they were set to, silently, with no warning from the API. This is not a bug. It is how BigCommerce's price inheritance is designed to work, and it quietly strands a variant's price the moment anyone, human or script, sets it explicitly. Here is why that split happens and a small script that finds every variant stuck like this so a merchant can decide what to do about it.

Python and Node.js BigCommerce V3 Catalog API Report by default (no auto-write)
A grocery store
Photo by Brad on Unsplash
The short answer

In the BigCommerce v3 catalog, a variant's price field is nullable and independent of the parent product's price. When it is null, the storefront falls back to the product's default price. But once a merchant or an API call sets an explicit numeric value on that variant, it decouples permanently, and a later PUT to the product's price never cascades to variants that already carry a non-null price, sale_price, or retail_price. The API returns 200 with no warning that variants were left behind. Run a small Python or Node.js script that walks GET /v3/catalog/products?include=variants, compares each variant's price against its parent product's price with Decimal arithmetic, and reports every divergence for review. Only clear a specific variant back to null, with PUT /v3/catalog/products/{product_id}/variants/{variant_id} and {"price": null}, once a merchant confirms that variant should follow the product price again. Full code, tests, and a dry run guard are below.

The problem in plain words

BigCommerce products and variants each carry their own price field, and the relationship between them is one directional and one time only. A brand new variant usually has price: null, so the storefront quietly uses the product's price for that variant. That is the inheritance everyone expects.

The trouble starts the moment anyone sets a real number on the variant, whether that is a merchant typing a price into a variant row in the admin, a bulk import, or an API call that writes price, sale_price, or retail_price on the variant object. From that point on, the variant has its own opinion about its price and never looks back at the product again. A later PUT /v3/catalog/products/{product_id} that changes the base price updates the product row and returns 200 like everything went fine. It did, for the product. But every variant that already had an explicit price just sits there, unchanged, still selling at the old number while the rest of the catalog moved on.

Variant price: null inherits product price Explicit price set by admin, import, or API decoupled, permanently Product price changes via PUT Variant price frozen, no cascade API returns 200 on the product update either way, no warning about the stranded variant.
Once a variant carries a non-null price, sale_price, or retail_price, it stops listening to the product's price entirely, and nothing in the API response says so.

Why it happens

This split is not an accident of the API. It is the same mechanism that backs legitimate use cases, so BigCommerce cannot simply auto-sync variant prices without breaking those cases. Common ways a store ends up with quietly frozen variant prices:

In every case, the underlying rule is the same: a non-null price on the variant always wins, and the product's price field only matters for variants that are still null. See the citations at the end for the support articles and developer docs describing this behavior.

The key insight

A diverging variant price is not proof of a bug. It might be a real, intended upcharge. So the safe pattern is not "reset every variant that differs from the product price." It is "report every variant that differs, with the exact numbers, and let a human decide." We compare product.price against each variant.price using Decimal, never float, because BigCommerce prices are precise to four decimal places and float comparison can produce false positives or false negatives right at the boundary. Only once a merchant confirms a specific variant should go back to inheriting do we send the corrective PUT with {"price": null}.

The fix, as a flow

We do not touch checkout, pricing rules, or price lists. We add a job that walks the catalog, compares each variant's price against its product's price, and either reports the divergence for review or, only when explicitly told to for that one variant, clears the override.

Page products include=variants Read prices product.price, variant.price Decimal compare beyond epsilon? Diverges from product price? yes no, skip Report row merchant decides confirmed reset only
The script only writes a report by default. A guarded PUT that clears a variant's price to null runs only for variants the merchant explicitly confirms.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Products (modify) scope so it can read variants and, later, clear a confirmed override. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false to write confirmed resets
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to write confirmed resets
2

Talk to the V3 Catalog API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list products with their variants and, later, to clear a confirmed override.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()

def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

Page through products with their variants included

Call GET /v3/catalog/products?include=variants&limit=250&page={n}, and follow meta.pagination.total_pages until every page has been read. Each product in data[] carries its own price plus a nested variants array, so there is no need for a separate call per product just to get the price fields.

step3.py
def all_products_with_variants():
    page = 1
    while True:
        payload = bc_get("/catalog/products", {
            "include": "variants",
            "limit": 250,
            "page": page,
        })
        for product in payload.get("data", []):
            yield product
        total_pages = payload.get("meta", {}).get("pagination", {}).get("total_pages", page)
        if page >= total_pages:
            return
        page += 1
step3.js
async function* allProductsWithVariants() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/catalog/products", {
      include: "variants",
      limit: 250,
      page,
    });
    for (const product of payload.data || []) yield product;
    const totalPages = payload.meta?.pagination?.total_pages ?? page;
    if (page >= totalPages) return;
    page += 1;
  }
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the product's id and price plus its list of variants, and returns every variant whose non-null price diverges from the product price beyond a small epsilon. Everything is Decimal, never float, since BigCommerce money is precise to four decimal places. A null or empty variant price is not a divergence, it is simply still inheriting.

decide.py
from decimal import Decimal

def find_stale_variant_overrides(product, variants, epsilon="0.0001"):
    product_price = Decimal(str(product["price"]))
    eps = Decimal(str(epsilon))
    findings = []

    for variant in variants or []:
        raw_price = variant.get("price")
        if raw_price is None or raw_price == "":
            continue

        variant_price = Decimal(str(raw_price))
        delta = variant_price - product_price
        if abs(delta) <= eps:
            continue

        findings.append({
            "variant_id": variant["id"],
            "sku": variant.get("sku"),
            "product_price": str(product_price),
            "variant_price": str(variant_price),
            "delta": str(delta),
        })

    return findings
decide.js
// Uses decimal-free string math via a tiny fixed-point helper, see the
// full file below for findStaleVariantOverrides with exact Decimal semantics.
export function findStaleVariantOverrides(product, variants, epsilon = "0.0001") {
  const productPrice = toDecimalString(product.price);
  const eps = toDecimalString(epsilon);
  const findings = [];

  for (const variant of variants || []) {
    const raw = variant.price;
    if (raw === null || raw === undefined || raw === "") continue;

    const variantPrice = toDecimalString(raw);
    const delta = subtractDecimalStrings(variantPrice, productPrice);
    if (absDecimalString(delta) <= eps) continue;

    findings.push({
      variant_id: variant.id,
      sku: variant.sku,
      product_price: productPrice,
      variant_price: variantPrice,
      delta,
    });
  }

  return findings;
}
5

Emit the report, never write by default

Run the pure function over every product and variant pair, and write the findings out as JSON or CSV, one row per stale variant: product_id, product_name, product_price, variant_id, variant_sku, variant_price, delta. That is the deliverable for most runs. Nothing gets written back to BigCommerce unless a merchant explicitly opts a variant in.

report.py
def build_report():
    rows = []
    for product in all_products_with_variants():
        variants = product.get("variants", [])
        for finding in find_stale_variant_overrides(product, variants):
            rows.append({
                "product_id": product["id"],
                "product_name": product.get("name"),
                **finding,
            })
    return rows
report.js
async function buildReport() {
  const rows = [];
  for await (const product of allProductsWithVariants()) {
    const variants = product.variants || [];
    for (const finding of findStaleVariantOverrides(product, variants)) {
      rows.push({
        product_id: product.id,
        product_name: product.name,
        ...finding,
      });
    }
  }
  return rows;
}
6

Only reset when explicitly confirmed, behind a dry run guard

Resetting a variant's price means clearing it back to null with PUT /v3/catalog/products/{product_id}/variants/{variant_id} and {"price": null}, optionally also clearing sale_price. Never do this in bulk by default. The script accepts an explicit list of confirmed variant ids to reset, and even then respects DRY_RUN, so the first run only logs what it would clear.

Run it safe

Always start with DRY_RUN=true, and never reset a variant's price just because it diverges from the product price. A diverging price is often a real, intended upcharge. Only clear a variant back to null once a merchant has looked at the report and explicitly confirmed that specific variant id.

The full code

Here is the complete script in one file for each language. It pages the full catalog, compares every variant price against its product price with Decimal, writes a report by default, and only sends a corrective write for variant ids explicitly passed in as confirmed, gated behind the dry run flag.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

find_stale_variant_prices.py
"""Report BigCommerce variants whose price no longer follows the product price.

A variant's price field is nullable and independent of the parent product's
price. If it is null, the storefront falls back to the product's default
price, but once a merchant or an API call sets an explicit numeric value on
that variant, it decouples permanently. A later PUT that updates the
product's price never cascades to variants that already carry a non-null
price, sale_price, or retail_price, and the API returns 200 with no warning
that variants were left behind. This job pages the full catalog with
variants included, compares each variant's price against its product's
price using Decimal arithmetic, and writes a report of every divergence.
A diverging variant price can be intentional (a size or material upcharge),
so nothing is reset automatically. Only variant ids the merchant explicitly
confirms are passed to reset_variant_price, and even then DRY_RUN gates the
real write. Safe to run again and again.

Guide: https://www.allanninal.dev/bigcommerce/variant-price-override-breaks-inheritance/
"""
import csv
import json
import logging
import os
import sys
from decimal import Decimal, InvalidOperation

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_stale_variant_prices")

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}

DEFAULT_EPSILON = "0.0001"


def bc_get(path, params=None):
    r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_put(path, body):
    r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def find_stale_variant_overrides(product, variants, epsilon=DEFAULT_EPSILON):
    """Pure decision. No network, no side effects.

    product: {"id": int, "price": str}
    variants: list of {"id": int, "sku": str, "price": str | None}

    Returns one entry per variant whose non-null price differs from
    product["price"] by more than epsilon, using Decimal arithmetic only
    on inputs already fetched. A null or empty variant price means the
    variant is still inheriting and is never a finding.
    """
    try:
        product_price = Decimal(str(product["price"]))
    except (InvalidOperation, KeyError, TypeError):
        return []

    try:
        eps = Decimal(str(epsilon))
    except InvalidOperation:
        eps = Decimal(DEFAULT_EPSILON)

    findings = []
    for variant in variants or []:
        raw_price = variant.get("price")
        if raw_price is None or raw_price == "":
            continue

        try:
            variant_price = Decimal(str(raw_price))
        except InvalidOperation:
            continue

        delta = variant_price - product_price
        if abs(delta) <= eps:
            continue

        findings.append({
            "variant_id": variant.get("id"),
            "sku": variant.get("sku"),
            "product_price": str(product_price),
            "variant_price": str(variant_price),
            "delta": str(delta),
        })

    return findings


def all_products_with_variants():
    page = 1
    while True:
        payload = bc_get("/catalog/products", {
            "include": "variants",
            "limit": 250,
            "page": page,
        })
        for product in payload.get("data", []):
            yield product
        pagination = payload.get("meta", {}).get("pagination", {})
        total_pages = pagination.get("total_pages", page)
        if page >= total_pages:
            return
        page += 1


def build_report():
    rows = []
    for product in all_products_with_variants():
        variants = product.get("variants", [])
        for finding in find_stale_variant_overrides(product, variants):
            rows.append({
                "product_id": product.get("id"),
                "product_name": product.get("name"),
                "product_price": finding["product_price"],
                "variant_id": finding["variant_id"],
                "variant_sku": finding["sku"],
                "variant_price": finding["variant_price"],
                "delta": finding["delta"],
            })
    return rows


def write_report_json(rows, path="stale_variant_overrides.json"):
    with open(path, "w") as f:
        json.dump(rows, f, indent=2)
    log.info("Wrote %d row(s) to %s", len(rows), path)


def write_report_csv(rows, path="stale_variant_overrides.csv"):
    fieldnames = ["product_id", "product_name", "product_price", "variant_id", "variant_sku", "variant_price", "delta"]
    with open(path, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)
    log.info("Wrote %d row(s) to %s", len(rows), path)


def reset_variant_price(product_id, variant_id, also_clear_sale_price=False):
    """Clear a single, merchant-confirmed variant back to inheriting the
    product's price. Never call this in bulk. DRY_RUN gates the real write."""
    body = {"price": None}
    if also_clear_sale_price:
        body["sale_price"] = None

    if DRY_RUN:
        log.info(
            "DRY_RUN: would PUT /catalog/products/%s/variants/%s with %s",
            product_id, variant_id, body,
        )
        return None

    log.info("Resetting variant %s on product %s: %s", variant_id, product_id, body)
    return bc_put(f"/catalog/products/{product_id}/variants/{variant_id}", body)


def run(confirmed_variant_ids=None):
    """confirmed_variant_ids: an explicit set of variant ids a merchant has
    reviewed in the report and approved for reset. Defaults to none, which
    means this run only produces the report and writes nothing."""
    confirmed_variant_ids = set(confirmed_variant_ids or [])

    rows = build_report()
    write_report_json(rows)
    write_report_csv(rows)

    reset_count = 0
    for row in rows:
        variant_id = row["variant_id"]
        if variant_id not in confirmed_variant_ids:
            continue
        reset_variant_price(row["product_id"], variant_id)
        reset_count += 1

    log.info(
        "Done. %d divergent variant(s) reported, %d variant(s) %s.",
        len(rows), reset_count, "would be reset" if DRY_RUN else "reset",
    )


if __name__ == "__main__":
    confirmed = [int(v) for v in sys.argv[1:] if v.strip().isdigit()]
    run(confirmed_variant_ids=confirmed)
find-stale-variant-prices.js
/**
 * Report BigCommerce variants whose price no longer follows the product price.
 *
 * A variant's price field is nullable and independent of the parent product's
 * price. If it is null, the storefront falls back to the product's default
 * price, but once a merchant or an API call sets an explicit numeric value on
 * that variant, it decouples permanently. A later PUT that updates the
 * product's price never cascades to variants that already carry a non-null
 * price, sale_price, or retail_price, and the API returns 200 with no warning
 * that variants were left behind. This job pages the full catalog with
 * variants included, compares each variant's price against its product's
 * price using precise decimal-string arithmetic, and writes a report of every
 * divergence. A diverging variant price can be intentional (a size or
 * material upcharge), so nothing is reset automatically. Only variant ids the
 * merchant explicitly confirms are passed to resetVariantPrice, and even then
 * DRY_RUN gates the real write. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/variant-price-override-breaks-inheritance/
 */
import { pathToFileURL } from "node:url";
import { writeFile } from "node:fs/promises";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DEFAULT_EPSILON = "0.0001";

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

// --- Precise decimal-string arithmetic, no floating point ---
// Scales both operands to the same number of decimal digits and works in
// integers, so 4-decimal-place BigCommerce money never hits float error.

function decimalParts(value) {
  const str = String(value).trim();
  const negative = str.startsWith("-");
  const unsigned = negative ? str.slice(1) : str;
  const [whole, frac = ""] = unsigned.split(".");
  return { negative, whole: whole || "0", frac };
}

function toScaledInt(value, scale) {
  const { negative, whole, frac } = decimalParts(value);
  const paddedFrac = (frac + "0".repeat(scale)).slice(0, scale);
  const digits = `${whole}${paddedFrac}`.replace(/^0+(?=\d)/, "");
  const n = BigInt(digits || "0");
  return negative ? -n : n;
}

function scaleOf(value) {
  const { frac } = decimalParts(value);
  return frac.length;
}

function formatScaledInt(n, scale) {
  const negative = n < 0n;
  let digits = (negative ? -n : n).toString().padStart(scale + 1, "0");
  const whole = scale === 0 ? digits : digits.slice(0, -scale);
  const frac = scale === 0 ? "" : digits.slice(-scale);
  const body = scale === 0 ? whole : `${whole}.${frac}`;
  return negative && n !== 0n ? `-${body}` : body;
}

function subtractDecimalStrings(a, b) {
  const scale = Math.max(scaleOf(a), scaleOf(b));
  const diff = toScaledInt(a, scale) - toScaledInt(b, scale);
  return formatScaledInt(diff, scale);
}

function compareAbsDecimalStrings(value, epsilon) {
  const scale = Math.max(scaleOf(value), scaleOf(epsilon));
  const v = toScaledInt(value, scale);
  const abs = v < 0n ? -v : v;
  const e = toScaledInt(epsilon, scale);
  return abs > e ? 1 : abs < e ? -1 : 0;
}

/**
 * Pure decision. No network, no side effects.
 *
 * product: {id, price}
 * variants: list of {id, sku, price}
 *
 * Returns one entry per variant whose non-null price differs from
 * product.price by more than epsilon. A null, undefined, or empty variant
 * price means the variant is still inheriting and is never a finding.
 */
export function findStaleVariantOverrides(product, variants, epsilon = DEFAULT_EPSILON) {
  if (product == null || product.price === undefined || product.price === null) return [];

  const findings = [];
  for (const variant of variants || []) {
    const raw = variant.price;
    if (raw === null || raw === undefined || raw === "") continue;

    const delta = subtractDecimalStrings(String(raw), String(product.price));
    if (compareAbsDecimalStrings(delta, String(epsilon)) <= 0) continue;

    findings.push({
      variant_id: variant.id,
      sku: variant.sku,
      product_price: String(product.price),
      variant_price: String(raw),
      delta,
    });
  }

  return findings;
}

async function bcGet(path, params = {}) {
  const url = new URL(`${API_BASE}${path}`);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "PUT",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function* allProductsWithVariants() {
  let page = 1;
  while (true) {
    const payload = await bcGet("/catalog/products", {
      include: "variants",
      limit: 250,
      page,
    });
    for (const product of payload.data || []) yield product;
    const totalPages = payload.meta?.pagination?.total_pages ?? page;
    if (page >= totalPages) return;
    page += 1;
  }
}

async function buildReport() {
  const rows = [];
  for await (const product of allProductsWithVariants()) {
    const variants = product.variants || [];
    for (const finding of findStaleVariantOverrides(product, variants)) {
      rows.push({
        product_id: product.id,
        product_name: product.name,
        product_price: finding.product_price,
        variant_id: finding.variant_id,
        variant_sku: finding.sku,
        variant_price: finding.variant_price,
        delta: finding.delta,
      });
    }
  }
  return rows;
}

function toCsv(rows) {
  const fields = ["product_id", "product_name", "product_price", "variant_id", "variant_sku", "variant_price", "delta"];
  const escape = (value) => {
    const s = value === undefined || value === null ? "" : String(value);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const lines = [fields.join(",")];
  for (const row of rows) lines.push(fields.map((f) => escape(row[f])).join(","));
  return lines.join("\n");
}

/**
 * Clear a single, merchant-confirmed variant back to inheriting the
 * product's price. Never call this in bulk. DRY_RUN gates the real write.
 */
async function resetVariantPrice(productId, variantId, alsoClearSalePrice = false) {
  const body = { price: null };
  if (alsoClearSalePrice) body.sale_price = null;

  if (DRY_RUN) {
    console.log(`DRY_RUN: would PUT /catalog/products/${productId}/variants/${variantId} with ${JSON.stringify(body)}`);
    return null;
  }

  console.log(`Resetting variant ${variantId} on product ${productId}: ${JSON.stringify(body)}`);
  return bcPut(`/catalog/products/${productId}/variants/${variantId}`, body);
}

export async function run(confirmedVariantIds = []) {
  const confirmed = new Set(confirmedVariantIds);

  const rows = await buildReport();
  await writeFile("stale_variant_overrides.json", JSON.stringify(rows, null, 2));
  await writeFile("stale_variant_overrides.csv", toCsv(rows));
  console.log(`Wrote ${rows.length} row(s) to stale_variant_overrides.json and .csv`);

  let resetCount = 0;
  for (const row of rows) {
    if (!confirmed.has(row.variant_id)) continue;
    await resetVariantPrice(row.product_id, row.variant_id);
    resetCount += 1;
  }

  console.log(
    `Done. ${rows.length} divergent variant(s) reported, ${resetCount} variant(s) ${DRY_RUN ? "would be reset" : "reset"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const confirmed = process.argv.slice(2).filter((v) => /^\d+$/.test(v)).map(Number);
  run(confirmed).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The comparison rule is the part most worth testing, because it decides which variants get reported and, eventually, which ones a merchant might reset. Because find_stale_variant_overrides takes only plain values and returns plain dictionaries, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_variant_price_overrides.py
from find_stale_variant_prices import find_stale_variant_overrides


def make_product(price="50.0000"):
    return {"id": 100, "price": price}


def make_variant(variant_id=1, sku="SKU-1", price=None):
    return {"id": variant_id, "sku": sku, "price": price}


def test_no_findings_when_variant_price_is_null():
    product = make_product("50.0000")
    variants = [make_variant(price=None)]
    assert find_stale_variant_overrides(product, variants) == []


def test_no_findings_when_variant_price_is_empty_string():
    product = make_product("50.0000")
    variants = [make_variant(price="")]
    assert find_stale_variant_overrides(product, variants) == []


def test_no_findings_when_variant_price_matches_product_price():
    product = make_product("50.0000")
    variants = [make_variant(price="50.0000")]
    assert find_stale_variant_overrides(product, variants) == []


def test_finding_when_variant_price_diverges():
    product = make_product("50.0000")
    variants = [make_variant(variant_id=7, sku="SKU-7", price="45.0000")]
    result = find_stale_variant_overrides(product, variants)
    assert result == [{
        "variant_id": 7,
        "sku": "SKU-7",
        "product_price": "50.0000",
        "variant_price": "45.0000",
        "delta": "-5.0000",
    }]


def test_finding_delta_is_positive_when_variant_price_is_higher():
    product = make_product("50.0000")
    variants = [make_variant(variant_id=8, sku="SKU-8", price="62.5000")]
    result = find_stale_variant_overrides(product, variants)
    assert result[0]["delta"] == "12.5000"


def test_within_epsilon_is_not_a_finding():
    product = make_product("50.0000")
    variants = [make_variant(price="50.00005")]
    assert find_stale_variant_overrides(product, variants, epsilon="0.0001") == []


def test_just_outside_epsilon_is_a_finding():
    product = make_product("50.0000")
    variants = [make_variant(price="50.0002")]
    result = find_stale_variant_overrides(product, variants, epsilon="0.0001")
    assert len(result) == 1


def test_multiple_variants_only_flags_the_diverging_ones():
    product = make_product("50.0000")
    variants = [
        make_variant(variant_id=1, sku="SKU-1", price=None),
        make_variant(variant_id=2, sku="SKU-2", price="50.0000"),
        make_variant(variant_id=3, sku="SKU-3", price="55.0000"),
    ]
    result = find_stale_variant_overrides(product, variants)
    assert [f["variant_id"] for f in result] == [3]
find-stale-variant-prices.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleVariantOverrides } from "./find-stale-variant-prices.js";

const makeProduct = (price = "50.0000") => ({ id: 100, price });
const makeVariant = (id = 1, sku = "SKU-1", price = null) => ({ id, sku, price });

test("no findings when variant price is null", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(1, "SKU-1", null)];
  assert.deepEqual(findStaleVariantOverrides(product, variants), []);
});

test("no findings when variant price is empty string", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(1, "SKU-1", "")];
  assert.deepEqual(findStaleVariantOverrides(product, variants), []);
});

test("no findings when variant price matches product price", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(1, "SKU-1", "50.0000")];
  assert.deepEqual(findStaleVariantOverrides(product, variants), []);
});

test("finding when variant price diverges", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(7, "SKU-7", "45.0000")];
  const result = findStaleVariantOverrides(product, variants);
  assert.deepEqual(result, [{
    variant_id: 7,
    sku: "SKU-7",
    product_price: "50.0000",
    variant_price: "45.0000",
    delta: "-5.0000",
  }]);
});

test("finding delta is positive when variant price is higher", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(8, "SKU-8", "62.5000")];
  const result = findStaleVariantOverrides(product, variants);
  assert.equal(result[0].delta, "12.5000");
});

test("within epsilon is not a finding", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(1, "SKU-1", "50.00005")];
  assert.deepEqual(findStaleVariantOverrides(product, variants, "0.0001"), []);
});

test("just outside epsilon is a finding", () => {
  const product = makeProduct("50.0000");
  const variants = [makeVariant(1, "SKU-1", "50.0002")];
  const result = findStaleVariantOverrides(product, variants, "0.0001");
  assert.equal(result.length, 1);
});

test("multiple variants only flags the diverging ones", () => {
  const product = makeProduct("50.0000");
  const variants = [
    makeVariant(1, "SKU-1", null),
    makeVariant(2, "SKU-2", "50.0000"),
    makeVariant(3, "SKU-3", "55.0000"),
  ];
  const result = findStaleVariantOverrides(product, variants);
  assert.deepEqual(result.map((f) => f.variant_id), [3]);
});

Case studies

Seasonal price bump

The store where a price increase quietly skipped half the variants

A merchant raised a product's base price ahead of a seasonal restock. Most sizes updated on the storefront right away. But a handful of sizes, priced individually months earlier during a one-off promotion, kept selling at the old number, and nobody noticed until a customer asked why two sizes of the same shirt were priced differently after the "price increase."

Running the report caught every one of those frozen variants immediately, with the exact delta between what the product now charged and what each variant was still charging. The merchant confirmed the handful that should reset, and left the ones that were genuine size upcharges alone.

Import re-freezes prices

The catalog sync that kept undoing manual price fixes

A nightly ERP sync wrote the full variant object back to BigCommerce on every run, including a price field it always carried, even for variants that a merchant had manually cleared back to null the day before. Every morning, the same set of variants was frozen again, and the merchant assumed BigCommerce itself was broken.

The report made the actual cause visible: the same variant ids reappeared with a diverging price every single day, right after the sync ran. Once the team fixed the sync to omit price on variants meant to inherit, the report came back empty and stayed that way.

What good looks like

After this runs on a schedule, every variant whose price has quietly drifted from its product shows up in a report with the exact numbers, not a guess. Nothing gets reset without a human looking at it first, so a genuine size or material upcharge never gets erased by mistake, and a stray import or forgotten manual edit never hides again.

FAQ

Why does a BigCommerce variant not update when I change the product's price?

A variant's price field is nullable and independent of the parent product's price. If it is null, the storefront falls back to the product's default price, but once a merchant or an API call sets an explicit numeric value on that variant, the variant decouples permanently. A later PUT to the product's price never cascades to variants that already carry a non-null price, sale_price, or retail_price, and the API returns 200 with no warning.

Is it safe to auto-reset every variant price that diverges from the product price?

No. A diverging variant price can be intentional, such as a size or material upcharge. The safe pattern is to report every divergence for merchant review by default, and only clear a variant's price back to null when the merchant explicitly confirms that specific variant should follow the product price again.

How do I reset a variant back to following the product's price?

Send a PUT to https://api.bigcommerce.com/stores/{store_hash}/v3/catalog/products/{product_id}/variants/{variant_id} with a body of {"price": null}, and optionally "sale_price": null if that was also overridden. That clears the explicit override so the variant falls back to the product's default price on the storefront.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: how product prices and variant prices relate. support.bigcommerce.com product prices
  2. BigCommerce Community: price does not change for different variant prices. support.bigcommerce.com price does not change for different variant prices
  3. BigCommerce Support: Variants and Modifiers. support.bigcommerce.com variants and modifiers

On the solution:

  1. BigCommerce Developer Center: Product Variants. developer.bigcommerce.com product variants
  2. BigCommerce API Reference: Update Product. docs.bigcommerce.com update product
  3. BigCommerce API Reference: Catalog, Product Variants. docs.bigcommerce.com catalog product variants

Stuck on a tricky one?

If you have a problem in BigCommerce catalog, pricing, orders, webhooks, 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 catch a frozen variant price?

If this saved you a confusing pricing complaint or caught overrides you would have otherwise missed, 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