Repair WooCommerce core: products and catalog

Deleting a variation does not resync the parent

Someone removes a discontinued size or color, and the variable product itself does not catch up. The price range on the shop page keeps quoting the deleted variation's price. The "In stock" badge stays green even though the only variations left are out of stock. Here is why WooCommerce leaves the parent stale and a small script that finds every affected product and resyncs it safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
Cardboard boxes on a table
Photo by Harper Sunday on Unsplash
The short answer

WooCommerce caches a variable product's price range and stock status so the shop page does not have to query every variation on every page load. Deleting a variation removes the row, but that cache is not reliably rebuilt, so the parent can keep quoting a deleted variation's price or the wrong stock status. Run a small Python or Node.js script on a schedule that reads a product's live variations, works out what the price range and stock status should be, and repairs any parent whose cached values disagree. Full code, tests, and a dry run guard are below.

The problem in plain words

A variable product does not store one price. It stores a range, a low number and a high number, built from whatever variations exist under it. WooCommerce also stores one stock status for the whole product, built from whichever variations are still purchasable. Both of those are cached on the parent so the store does not have to add up every variation just to show a price on the shop grid.

When a shop manager deletes a variation, perhaps a size that is being discontinued or a color that never sold, WooCommerce removes that variation's row from the database. What it is supposed to do next is recompute the parent's cached price range and stock status from whatever variations are left. In practice this recompute does not always happen, especially when the delete comes from a bulk action, a REST API call, or a plugin that removes variations directly. The parent is left holding numbers that describe a variation that no longer exists.

Manager deletes cheapest variation Row removed from the database resync skipped Parent unchanged old cached range Wrong price Wrong stock
The variation row is gone, but nothing forces the parent's cached price range and stock status to catch up.

Why it happens

WooCommerce's own product data store is supposed to run a sync step after variation changes, rebuilding the parent's _price, _min_variation_price, _max_variation_price, and _stock_status meta from the variations that remain. A few common reasons that sync does not run, or runs against stale data:

This is a long-running complaint against WooCommerce core. Store owners report variable products still quoting a deleted variation's price, or showing "In stock" after every remaining variation is out of stock, and the usual workaround is to open the product in the editor and click save, which forces the same resync that a delete should have triggered on its own.

The key insight

The variations are the source of truth. The parent's price range and stock status are only a cache built from them. If the live, purchasable variations disagree with what the parent currently shows, the parent is wrong, not the variations. A resync job is a safety net that reads the truth from the variations and rebuilds the parent's cache to match.

The fix, as a flow

We do not touch the variations themselves. We add a job that walks every variable product, reads its live variations through the REST API, works out what the price range and stock status should be from only the variations that are published and priced, and compares that to what the parent currently shows. If they disagree, we repair the parent. If they already match, we leave it alone.

Scheduled job once a day List variable products, read variations Compute expected range and stock status Parent already matches? yes, skip no Repair parent resync price + stock
The job reads the truth from the live variations and only repairs a parent that has actually drifted. Everything already correct is left alone.

Build it step by step

1

Get access to the store

You need a WooCommerce REST API key pair, a consumer key and a consumer secret, with read and write access to products. Create it under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export DRY_RUN="true"   // start safe, change to false to write
2

List variable products and their live variations

Ask the WooCommerce REST API for products of type variable, then for each one, page through its variations endpoint. This is the same data the storefront would see if it looked at each variation directly, which is exactly why it is trustworthy.

step2.py
import requests
from requests.auth import HTTPBasicAuth

AUTH = HTTPBasicAuth(WOO_CONSUMER_KEY, WOO_CONSUMER_SECRET)

def get_variable_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"type": "variable", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield product
        page += 1
step2.js
async function* getVariableProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?type=variable&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}
3

Work out what the parent should show

Only published variations with a price count. Among those, the low and high price form the expected range, and the stock status follows the same rule WooCommerce itself uses: in stock if any purchasable variation is in stock or on backorder, out of stock only when every one of them is out of stock.

expected.py
def price_minor(value):
    if value in (None, ""):
        return None
    return round(float(value) * 100)

def expected_state(variations):
    purchasable = [
        v for v in variations
        if v.get("status") == "publish" and price_minor(v.get("price")) is not None
    ]
    if not purchasable:
        return {"min_price": None, "max_price": None, "stock_status": "outofstock"}

    prices = [price_minor(v["price"]) for v in purchasable]
    statuses = {v.get("stock_status") for v in purchasable}
    if statuses & {"instock", "onbackorder"}:
        stock_status = "instock" if "instock" in statuses else "onbackorder"
    else:
        stock_status = "outofstock"

    return {"min_price": min(prices), "max_price": max(prices), "stock_status": stock_status}
expected.js
export function priceMinor(value) {
  if (value === null || value === undefined || value === "") return null;
  return Math.round(parseFloat(value) * 100);
}

export function expectedState(variations) {
  const purchasable = variations.filter(
    (v) => v.status === "publish" && priceMinor(v.price) !== null
  );
  if (purchasable.length === 0) {
    return { minPrice: null, maxPrice: null, stockStatus: "outofstock" };
  }

  const prices = purchasable.map((v) => priceMinor(v.price));
  const statuses = new Set(purchasable.map((v) => v.stock_status));
  let stockStatus;
  if (statuses.has("instock") || statuses.has("onbackorder")) {
    stockStatus = statuses.has("instock") ? "instock" : "onbackorder";
  } else {
    stockStatus = "outofstock";
  }

  return { minPrice: Math.min(...prices), maxPrice: Math.max(...prices), stockStatus };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the parent and its variations and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If it is not a variable product, skip it. If every variation is gone, flag it separately since that is a different situation. Otherwise compare the cached price and stock status to what is expected, and fix only when they disagree.

decide.py
def decide(parent, variations):
    if parent.get("type") != "variable":
        return ("skip", "not a variable product", None)

    expected = expected_state(variations)

    if not variations:
        cached_status = parent.get("stock_status")
        if cached_status == "outofstock" and parent.get("price") in (None, ""):
            return ("skip", "already reflects no variations", expected)
        return ("no-variations", "every variation was deleted, parent still shows stale data", expected)

    cached_min = price_minor(parent.get("price"))
    cached_status = parent.get("stock_status")

    mismatched_price = expected["min_price"] is not None and cached_min != expected["min_price"]
    mismatched_stock = cached_status != expected["stock_status"]

    if mismatched_price or mismatched_stock:
        return ("fix", "parent price range or stock status is stale after a variation delete", expected)

    return ("skip", "parent already matches its live variations", expected)
decide.js
export function decide(parent, variations) {
  if (parent.type !== "variable") {
    return ["skip", "not a variable product", null];
  }

  const expected = expectedState(variations);

  if (variations.length === 0) {
    const alreadyCleared = parent.stock_status === "outofstock" && (parent.price === null || parent.price === "");
    if (alreadyCleared) return ["skip", "already reflects no variations", expected];
    return ["no-variations", "every variation was deleted, parent still shows stale data", expected];
  }

  const cachedMin = priceMinor(parent.price);
  const cachedStatus = parent.stock_status;

  const mismatchedPrice = expected.minPrice !== null && cachedMin !== expected.minPrice;
  const mismatchedStock = cachedStatus !== expected.stockStatus;

  if (mismatchedPrice || mismatchedStock) {
    return ["fix", "parent price range or stock status is stale after a variation delete", expected];
  }

  return ["skip", "parent already matches its live variations", expected];
}
5

Repair the parent

When the action is fix, we first send a zero-length variation batch update, which is enough to make WooCommerce's own variable product data store rerun its sync step and rebuild the cache from the variations that remain. We also PUT the expected stock status and low price directly, so the storefront is correct immediately rather than waiting on the next save.

apply.py
def apply_fix(product_id, expected):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/batch",
        json={"update": []},
        auth=AUTH, timeout=30,
    ).raise_for_status()

    payload = {"stock_status": expected["stock_status"]}
    if expected["min_price"] is not None:
        payload["regular_price"] = f"{expected['min_price'] / 100:.2f}"
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json=payload,
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function applyFix(productId, expected) {
  await woo(`/products/${productId}/variations/batch`, {
    method: "POST",
    body: JSON.stringify({ update: [] }),
  });

  const payload = { stock_status: expected.stockStatus };
  if (expected.minPrice !== null) {
    payload.regular_price = (expected.minPrice / 100).toFixed(2);
  }
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify(payload),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it once a day, or hourly if your catalog changes often.

Run it safe

Always start with DRY_RUN=true. This job writes to real products, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete resync job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a parent that already matches its variations.

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

resync_variable_parent.py
"""Fix WooCommerce variable products whose price range and stock status went stale
after a variation was deleted.

Deleting a variation removes that row, but nothing tells the parent product to
recompute its cached `_price`, `_min_variation_price` / `_max_variation_price`,
or `_stock_status`. The parent keeps showing the old range (or "In stock" when
every remaining variation is out of stock) until something forces a resync.

This walks variable products, reads their live variations from the REST API,
computes what the parent's price range and stock status should be, and repairs
any parent whose cached values disagree. Safe to run again and again. Dry run
by default.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth

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

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

IN_STOCK = "instock"
OUT_OF_STOCK = "outofstock"
ON_BACKORDER = "onbackorder"


def price_minor(value):
    """Turn a WooCommerce price string into integer cents. Empty means unset."""
    if value in (None, ""):
        return None
    return round(float(value) * 100)


def expected_state(variations):
    """Work out what the parent's price range and stock status should be,
    given the variations that are left after a delete. Purchasable variations
    (published, with a price) decide the range. Stock status follows the same
    rule WooCommerce uses: in stock if any purchasable variation is in stock or
    on backorder, out of stock only when every one of them is out of stock.
    """
    purchasable = [
        v for v in variations
        if v.get("status") == "publish" and price_minor(v.get("price")) is not None
    ]
    if not purchasable:
        return {"min_price": None, "max_price": None, "stock_status": OUT_OF_STOCK}

    prices = [price_minor(v["price"]) for v in purchasable]
    statuses = {v.get("stock_status") for v in purchasable}
    if statuses & {IN_STOCK, ON_BACKORDER}:
        stock_status = IN_STOCK if IN_STOCK in statuses else ON_BACKORDER
    else:
        stock_status = OUT_OF_STOCK

    return {"min_price": min(prices), "max_price": max(prices), "stock_status": stock_status}


def decide(parent, variations):
    """Pure decision function. No I/O. Returns (action, reason, expected).

    action is one of:
      "skip"   - parent is not a variable product, or nothing is out of sync
      "no-variations" - all variations are gone, parent should show unpurchasable
      "fix"    - the cached parent values disagree with what the live variations say
    """
    if parent.get("type") != "variable":
        return ("skip", "not a variable product", None)

    expected = expected_state(variations)

    if not variations:
        cached_status = parent.get("stock_status")
        if cached_status == OUT_OF_STOCK and parent.get("price") in (None, ""):
            return ("skip", "already reflects no variations", expected)
        return ("no-variations", "every variation was deleted, parent still shows stale data", expected)

    # The REST API exposes the parent's cached low price as "price". A healthy
    # variable product keeps "price" equal to the lowest live variation price.
    cached_min = price_minor(parent.get("price"))
    cached_status = parent.get("stock_status")

    mismatched_price = expected["min_price"] is not None and cached_min != expected["min_price"]
    mismatched_stock = cached_status != expected["stock_status"]

    if mismatched_price or mismatched_stock:
        return ("fix", "parent price range or stock status is stale after a variation delete", expected)

    return ("skip", "parent already matches its live variations", expected)


def get_variable_products():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"type": "variable", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for product in batch:
            yield product
        page += 1


def get_variations(product_id):
    variations = []
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations",
            params={"per_page": 100, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            break
        variations.extend(batch)
        page += 1
    return variations


def apply_fix(product_id, expected):
    """Force WooCommerce to recompute the parent by touching one of its own
    variations (a zero-length variation batch update). WooCommerce's variable
    product data store runs WC_Product_Variable::sync on that call, which
    rebuilds price range and stock status from the variations that still
    exist. We also PUT the expected values directly so the storefront is
    correct even before the next full save.
    """
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/batch",
        json={"update": []},
        auth=AUTH, timeout=30,
    ).raise_for_status()

    payload = {"stock_status": expected["stock_status"]}
    if expected["min_price"] is not None:
        payload["regular_price"] = f"{expected['min_price'] / 100:.2f}"
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json=payload,
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for product in get_variable_products():
        variations = get_variations(product["id"])
        action, reason, expected = decide(product, variations)
        if action == "skip":
            continue
        log.info(
            "Product %s: %s. %s",
            product["id"], reason, "would fix" if DRY_RUN else "fixing",
        )
        if not DRY_RUN:
            apply_fix(product["id"], expected)
        fixed += 1
    log.info("Done. %d product(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
resync-variable-parent.js
/**
 * Fix WooCommerce variable products whose price range and stock status went
 * stale after a variation was deleted.
 *
 * Deleting a variation removes that row, but nothing tells the parent product
 * to recompute its cached "_price", "_min_variation_price" / "_max_variation_price",
 * or "_stock_status". The parent keeps showing the old range (or "In stock"
 * when every remaining variation is out of stock) until something forces a
 * resync. Read only when DRY_RUN is true. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/deleting-a-variation-does-not-resync-the-parent/
 */
import { pathToFileURL } from "node:url";

const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const IN_STOCK = "instock";
const OUT_OF_STOCK = "outofstock";
const ON_BACKORDER = "onbackorder";

export function priceMinor(value) {
  if (value === null || value === undefined || value === "") return null;
  return Math.round(parseFloat(value) * 100);
}

export function expectedState(variations) {
  const purchasable = variations.filter(
    (v) => v.status === "publish" && priceMinor(v.price) !== null
  );
  if (purchasable.length === 0) {
    return { minPrice: null, maxPrice: null, stockStatus: OUT_OF_STOCK };
  }

  const prices = purchasable.map((v) => priceMinor(v.price));
  const statuses = new Set(purchasable.map((v) => v.stock_status));
  let stockStatus;
  if (statuses.has(IN_STOCK) || statuses.has(ON_BACKORDER)) {
    stockStatus = statuses.has(IN_STOCK) ? IN_STOCK : ON_BACKORDER;
  } else {
    stockStatus = OUT_OF_STOCK;
  }

  return { minPrice: Math.min(...prices), maxPrice: Math.max(...prices), stockStatus };
}

/**
 * Pure decision function. No I/O. Returns [action, reason, expected].
 *
 * action is one of:
 *   "skip"          - parent is not a variable product, or nothing is out of sync
 *   "no-variations" - all variations are gone, parent should show unpurchasable
 *   "fix"           - the cached parent values disagree with what the live variations say
 */
export function decide(parent, variations) {
  if (parent.type !== "variable") {
    return ["skip", "not a variable product", null];
  }

  const expected = expectedState(variations);

  if (variations.length === 0) {
    const alreadyCleared = parent.stock_status === OUT_OF_STOCK && (parent.price === null || parent.price === "");
    if (alreadyCleared) return ["skip", "already reflects no variations", expected];
    return ["no-variations", "every variation was deleted, parent still shows stale data", expected];
  }

  // The REST API exposes the parent's cached low price as "price". A healthy
  // variable product keeps "price" equal to the lowest live variation price.
  const cachedMin = priceMinor(parent.price);
  const cachedStatus = parent.stock_status;

  const mismatchedPrice = expected.minPrice !== null && cachedMin !== expected.minPrice;
  const mismatchedStock = cachedStatus !== expected.stockStatus;

  if (mismatchedPrice || mismatchedStock) {
    return ["fix", "parent price range or stock status is stale after a variation delete", expected];
  }

  return ["skip", "parent already matches its live variations", expected];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* getVariableProducts() {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?type=variable&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}

async function getVariations(productId) {
  const variations = [];
  let page = 1;
  while (true) {
    const batch = await woo(`/products/${productId}/variations?per_page=100&page=${page}`);
    if (!batch.length) break;
    variations.push(...batch);
    page++;
  }
  return variations;
}

/**
 * Force WooCommerce to recompute the parent by sending a zero-length variation
 * batch update. WooCommerce's variable product data store runs
 * WC_Product_Variable::sync on that call, which rebuilds price range and
 * stock status from the variations that still exist. We also PUT the
 * expected values directly so the storefront is correct right away.
 */
async function applyFix(productId, expected) {
  await woo(`/products/${productId}/variations/batch`, {
    method: "POST",
    body: JSON.stringify({ update: [] }),
  });

  const payload = { stock_status: expected.stockStatus };
  if (expected.minPrice !== null) {
    payload.regular_price = (expected.minPrice / 100).toFixed(2);
  }
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify(payload),
  });
}

export async function run() {
  let fixed = 0;
  for await (const product of getVariableProducts()) {
    const variations = await getVariations(product.id);
    const [action, reason, expected] = decide(product, variations);
    if (action === "skip") continue;
    console.log(`Product ${product.id}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await applyFix(product.id, expected);
    fixed++;
  }
  console.log(`Done. ${fixed} product(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which products get rewritten. Because we kept decide and expected_state pure, the tests need no network and no live store. They just feed in plain objects and check the action.

test_deleting_variation_decide.py
from resync_variable_parent import decide, expected_state, price_minor


def variation(**over):
    base = {"status": "publish", "price": "20.00", "stock_status": "instock"}
    base.update(over)
    return base


def parent(**over):
    base = {"type": "variable", "price": "20.00", "stock_status": "instock"}
    base.update(over)
    return base


def test_skip_for_simple_product():
    action, _, _ = decide({"type": "simple", "price": "10.00"}, [])
    assert action == "skip"


def test_fix_when_cheapest_variation_was_deleted():
    variations = [variation(price="20.00"), variation(price="35.00")]
    action, reason, expected = decide(parent(price="15.00"), variations)
    assert action == "fix"
    assert expected["min_price"] == 2000


def test_fix_when_last_in_stock_variation_was_deleted():
    variations = [variation(price="20.00", stock_status="outofstock")]
    action, reason, expected = decide(parent(stock_status="instock"), variations)
    assert action == "fix"
    assert expected["stock_status"] == "outofstock"


def test_no_variations_when_every_variation_deleted():
    action, reason, expected = decide(parent(), [])
    assert action == "no-variations"
resync-variable-parent.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./resync-variable-parent.js";

const variation = (over = {}) => ({ status: "publish", price: "20.00", stock_status: "instock", ...over });
const parent = (over = {}) => ({ type: "variable", price: "20.00", stock_status: "instock", ...over });

test("skip for simple product", () => {
  assert.equal(decide({ type: "simple", price: "10.00" }, [])[0], "skip");
});

test("fix when cheapest variation was deleted", () => {
  const variations = [variation({ price: "20.00" }), variation({ price: "35.00" })];
  const [action, , expected] = decide(parent({ price: "15.00" }), variations);
  assert.equal(action, "fix");
  assert.equal(expected.minPrice, 2000);
});

test("fix when last in stock variation was deleted", () => {
  const variations = [variation({ price: "20.00", stock_status: "outofstock" })];
  const [action, , expected] = decide(parent({ stock_status: "instock" }), variations);
  assert.equal(action, "fix");
  assert.equal(expected.stockStatus, "outofstock");
});

test("no-variations when every variation deleted", () => {
  assert.equal(decide(parent(), [])[0], "no-variations");
});

Case studies

Discontinued size

The shoe that kept quoting a price nobody could pay

A store discontinued its smallest size, which happened to be the cheapest variation, and deleted it straight through a REST API cleanup script. The parent kept advertising the old low price on the shop grid for weeks. Shoppers clicked through expecting that price and found every remaining size cost more.

The resync job caught it on its first run, recomputed the range from the sizes that were actually left, and updated the parent so the shop grid matched the product page again.

Bulk cleanup

The color line that stayed "In stock" after it sold out

A store ran a bulk delete removing every variation for a color that was being retired, but one leftover out of stock variation was deleted last while a plugin's stock recalculation had already fired on the earlier ones. The parent froze on "In stock" with no purchasable variation behind it.

Running the job in dry run first showed the exact list of frozen products, the team confirmed it, then ran it for real and every one flipped to the correct out of stock status.

What good looks like

After this runs on a schedule, a deleted variation is no longer a silent pricing bug. The worst case becomes a short delay before the resync job catches the drift and repairs it. Keep it running even after you fix whatever deleted the variation, since this kind of drift can come from more than one direction.

FAQ

Why does my product still show the old price range after I deleted a variation?

WooCommerce caches the parent product's low price, high price, and stock status. Deleting a variation removes that row but does not always trigger the parent to recompute those cached values, so the storefront keeps showing the old range until something forces a resync.

Is it safe to fix this with a script instead of editing every product by hand?

Yes, when the script only touches the cached price and stock status, computes them from the variations that are actually still live, and skips any parent that already matches. Start in dry run mode to review the list before it writes.

How often should the resync job run?

Once a day is enough for most stores, since this only matters right after someone deletes a variation. Stores that delete variations often, such as ones clearing out discontinued sizes or colors every week, can run it hourly with no real cost.

Related field notes

Citations

On the problem:

  1. WooCommerce core reports of variable product price ranges and stock status not updating after variation changes. github.com/woocommerce/woocommerce/issues
  2. WooCommerce docs: how variable products and their price ranges work. woocommerce.com/document/variable-product
  3. WooCommerce docs: managing product variations, including deleting them. woocommerce.com/document/managing-product-variations

On the solution:

  1. WooCommerce REST API: list and update products, including price and stock status fields. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list, batch update, and delete product variations. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce developer docs: High Performance Order Storage and how the REST API keeps product and order code paths consistent. developer.woocommerce.com/docs/hpos-extension-recipe-book

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 fix your catalog?

If this saved you from chasing a pricing bug across your whole shop, 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 WooCommerce field notes