Repair WooCommerce core: stock and inventory
Variations stuck On Backorder at zero
A shopper opens a product page and one variation says "On backorder" even though you turned backorders off weeks ago and the stock has been at zero the whole time. Nothing in the admin looks wrong at a glance, the quantity field really does say zero, yet the storefront keeps offering a status that should not exist anymore. Here is why the stored stock_status drifts away from the real numbers, and a small script that puts every variation back in sync.
WooCommerce stores stock_status as its own field on the variation, separate from stock_quantity and the backorders setting, and it only recalculates that field when the quantity changes through WooCommerce's normal save path. An import, a direct database edit, or a backorders setting changed after the fact can leave a variation showing On Backorder when the real numbers say it should be Out of stock, or the reverse. Run a small Python or Node.js script that reads every variation's quantity and backorders setting, works out the stock_status it should have, and corrects any variation that disagrees. Full code, tests, and a dry run guard are below.
The problem in plain words
Every variation that manages its own stock carries three separate pieces of information: how many units are left, whether backorders are allowed, and a label called stock_status that the storefront actually reads to decide what to show a shopper. That label should always agree with the other two, but WooCommerce does not check that agreement on every page load. It only recomputes the label at the moment the quantity is saved through the usual product edit screen or through a stock reduction on an order.
That leaves a gap. If the quantity or the backorders setting changes any other way, the label stays exactly where it was. A variation that was allowed to go on backorder a year ago can still say "On backorder" today even though someone switched backorders off in the meantime and the stock has sat at zero ever since. The count is correct. The setting is correct. The label is just old.
Why it happens
The WooCommerce core code recalculates a variation's stock_status inside its own stock management routines, which run when an order reduces stock or when the product is saved through the edit screen. Anything that changes the numbers without going through those routines leaves the label untouched. A few common causes:
- A CSV or XML import tool writes
_stockand_backordersdirectly as post meta but never calls the function that recomputes_stock_status, so the old label survives the import. - A store manager turns backorders off for a variation weeks after it already sat at zero stock with backorders on, so the label that made sense back then is now wrong.
- A migration or staging to production copy moves the database without re running WooCommerce's stock sync, so every variation keeps whatever label it had at export time.
- A custom plugin or a direct SQL update changes
stock_quantityfor bulk restocking but does not touchstock_status, which is a separate field.
This is a well documented gap. WooCommerce's own developer docs describe stock_status as a field that must be kept in sync by whatever code changes the quantity, and the community has filed repeated reports of variations showing an outdated backorder or stock label after bulk edits. See the citations at the end for the exact references.
stock_quantity and backorders are the source of truth. stock_status is just a cached label computed from those two values. If the label disagrees with what the quantity and the backorders setting say it should be, the label is wrong, not the numbers. A repair script recomputes the label the same way WooCommerce would and writes back only the ones that drifted.
The fix, as a flow
We do not touch stock levels at all. We add a script that reads every variation of a product, or of every variable product in the store, and works out what stock_status should be from stock_quantity and backorders. When the stored label disagrees with that answer, we send one small update that changes only the label, the same value WooCommerce's own save routine would have written.
Build it step by step
Get a WooCommerce REST API key
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. There is no Stripe or payment API involved here, since this is purely a product data repair. Keep the keys in environment variables, never in the file.
pip install requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export PRODUCT_IDS="" # blank scans every variable product
export DRY_RUN="true" # start safe, change to false to write
npm install
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export PRODUCT_IDS="" // blank scans every variable product
export DRY_RUN="true" // start safe, change to false to write
List the variations to check
If you already know which product is affected, pass its ID. Otherwise the script pages through every variable product in the store and then pages through each product's variations. This uses the standard WooCommerce REST API, so it works the same whether High Performance Order Storage is on or off, since this endpoint is about products, not orders.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def list_variations(product_id):
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:
return
for variation in batch:
yield variation
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* listVariations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=100&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
Work out the stock_status a variation should have
This is a small, pure calculation. If the variation does not manage its own stock, there is nothing to repair, WooCommerce handles it at the parent level. Otherwise a positive quantity means in stock. A quantity at or below zero means out of stock, unless backorders are allowed, in which case it means on backorder.
def expected_stock_status(variation):
if not variation.get("manage_stock"):
return None
qty = variation.get("stock_quantity")
if qty is None:
return None
backorders = variation.get("backorders", "no")
if qty > 0:
return "instock"
if backorders in ("yes", "notify"):
return "onbackorder"
return "outofstock"
export function expectedStockStatus(variation) {
if (!variation.manage_stock) return null;
const qty = variation.stock_quantity;
if (qty === null || qty === undefined) return null;
const backorders = variation.backorders || "no";
if (qty > 0) return "instock";
if (backorders === "yes" || backorders === "notify") return "onbackorder";
return "outofstock";
}
Decide, with one pure function
Keep the decision in its own function that takes a variation 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 the variation does not manage its own stock, skip it. If the stored label already matches what quantity and backorders say it should be, skip it. Otherwise, fix it.
VALID_STATUSES = {"instock", "outofstock", "onbackorder"}
def decide(variation):
expected = expected_stock_status(variation)
if expected is None:
return ("skip", "variation does not manage its own stock")
current = variation.get("stock_status")
if current not in VALID_STATUSES:
return ("fix", f"stock_status {current!r} is not a recognized value")
if current == expected:
return ("skip", "stock_status already matches quantity and backorders")
return ("fix", f"stock_status is {current!r} but should be {expected!r}")
const VALID_STATUSES = new Set(["instock", "outofstock", "onbackorder"]);
export function decide(variation) {
const expected = expectedStockStatus(variation);
if (expected === null) return ["skip", "variation does not manage its own stock"];
const current = variation.stock_status;
if (!VALID_STATUSES.has(current)) {
return ["fix", `stock_status ${JSON.stringify(current)} is not a recognized value`];
}
if (current === expected) return ["skip", "stock_status already matches quantity and backorders"];
return ["fix", `stock_status is ${JSON.stringify(current)} but should be ${JSON.stringify(expected)}`];
}
Write back only the label, never the quantity
When the action is fix, send a single PUT that sets only stock_status to the expected value. Do not include stock_quantity or backorders in that update. This keeps the change small and reviewable, and means a bug in this script can never accidentally change how many units you have.
def apply_fix(product_id, variation_id, expected_status):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/{variation_id}",
json={"stock_status": expected_status},
auth=AUTH, timeout=30,
).raise_for_status()
async function applyFix(productId, variationId, expectedStatus) {
await woo(`/products/${productId}/variations/${variationId}`, {
method: "PUT",
body: JSON.stringify({ stock_status: expectedStatus }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would change. Read the output, trust it, then switch it off to let it write. This is a repair job, not a webhook, so it is fine to run it once after a suspicious import, or on a weekly schedule as a safety net.
Always start with DRY_RUN=true. The script writes to real product data, so you want to see its plan before it acts. Once the report looks right, turn it off and let it write.
The full code
Here is the complete script 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 variation whose stock_status already matches its quantity and backorders setting.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Repair WooCommerce variations whose stock_status disagrees with their stock_quantity.
A variation can end up showing "On backorder" in the shop while its stock is at or
below zero and backorders are turned off. WooCommerce only recalculates
stock_status when the quantity changes through its own save path. A CSV import, a
direct database edit, or flipping the backorders setting after the quantity was
already low can leave the stored stock_status stale. This walks the variations of a
product (or every variable product), works out what stock_status should be from the
quantity and the backorders setting, and corrects any variation that disagrees.
Read only by default. Run it by hand or on a schedule.
"""
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("fix_variation_stock_status")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
PRODUCT_IDS = [p.strip() for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
VALID_STATUSES = {"instock", "outofstock", "onbackorder"}
def expected_stock_status(variation):
"""Work out the stock_status a variation should have.
Only variations with manage_stock on carry their own quantity, so anything
else is left to WooCommerce and skipped. Backorders "yes" or "notify" both
mean the shop should keep selling once stock runs out.
"""
if not variation.get("manage_stock"):
return None
qty = variation.get("stock_quantity")
if qty is None:
return None
backorders = variation.get("backorders", "no")
if qty > 0:
return "instock"
if backorders in ("yes", "notify"):
return "onbackorder"
return "outofstock"
def decide(variation):
"""Pure decision: does this variation's stock_status need to change.
Returns a tuple of (action, reason). action is one of:
"skip" - not stock managed, or already correct
"fix" - stock_status disagrees with quantity and backorders, repair it
No I/O happens in here, so it is safe and cheap to unit test.
"""
expected = expected_stock_status(variation)
if expected is None:
return ("skip", "variation does not manage its own stock")
current = variation.get("stock_status")
if current not in VALID_STATUSES:
return ("fix", f"stock_status {current!r} is not a recognized value")
if current == expected:
return ("skip", "stock_status already matches quantity and backorders")
return ("fix", f"stock_status is {current!r} but should be {expected!r}")
def list_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, "status": "publish"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
def list_variations(product_id):
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:
return
for variation in batch:
yield variation
page += 1
def apply_fix(product_id, variation_id, expected_status):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}/variations/{variation_id}",
json={"stock_status": expected_status},
auth=AUTH, timeout=30,
).raise_for_status()
def target_product_ids():
if PRODUCT_IDS:
return PRODUCT_IDS
return [p["id"] for p in list_variable_products()]
def run():
fixed = 0
for product_id in target_product_ids():
for variation in list_variations(product_id):
action, reason = decide(variation)
if action == "skip":
continue
expected = expected_stock_status(variation)
log.info(
"Variation %s (product %s): %s. %s",
variation["id"], product_id, reason, "would fix" if DRY_RUN else "fixing",
)
if not DRY_RUN:
apply_fix(product_id, variation["id"], expected)
fixed += 1
log.info("Done. %d variation(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Repair WooCommerce variations whose stock_status disagrees with their stock_quantity.
*
* A variation can end up showing "On backorder" in the shop while its stock is at or
* below zero and backorders are turned off. WooCommerce only recalculates
* stock_status when the quantity changes through its own save path. A CSV import, a
* direct database edit, or flipping the backorders setting after the quantity was
* already low can leave the stored stock_status stale. This walks the variations of
* a product (or every variable product), works out what stock_status should be from
* the quantity and the backorders setting, and corrects any variation that
* disagrees. Read only by default. Run it by hand or on a schedule.
*/
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 PRODUCT_IDS = (process.env.PRODUCT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const VALID_STATUSES = new Set(["instock", "outofstock", "onbackorder"]);
export function expectedStockStatus(variation) {
if (!variation.manage_stock) return null;
const qty = variation.stock_quantity;
if (qty === null || qty === undefined) return null;
const backorders = variation.backorders || "no";
if (qty > 0) return "instock";
if (backorders === "yes" || backorders === "notify") return "onbackorder";
return "outofstock";
}
export function decide(variation) {
const expected = expectedStockStatus(variation);
if (expected === null) return ["skip", "variation does not manage its own stock"];
const current = variation.stock_status;
if (!VALID_STATUSES.has(current)) {
return ["fix", `stock_status ${JSON.stringify(current)} is not a recognized value`];
}
if (current === expected) return ["skip", "stock_status already matches quantity and backorders"];
return ["fix", `stock_status is ${JSON.stringify(current)} but should be ${JSON.stringify(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* listVariableProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?type=variable&per_page=50&page=${page}&status=publish`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function* listVariations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=100&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
async function applyFix(productId, variationId, expectedStatus) {
await woo(`/products/${productId}/variations/${variationId}`, {
method: "PUT",
body: JSON.stringify({ stock_status: expectedStatus }),
});
}
async function targetProductIds() {
if (PRODUCT_IDS.length) return PRODUCT_IDS;
const ids = [];
for await (const product of listVariableProducts()) ids.push(product.id);
return ids;
}
export async function run() {
let fixed = 0;
for (const productId of await targetProductIds()) {
for await (const variation of listVariations(productId)) {
const [action, reason] = decide(variation);
if (action === "skip") continue;
const expected = expectedStockStatus(variation);
console.log(
`Variation ${variation.id} (product ${productId}): ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`
);
if (!DRY_RUN) await applyFix(productId, variation.id, expected);
fixed++;
}
}
console.log(`Done. ${fixed} variation(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 variations get rewritten. Because we kept decide and expected_stock_status pure, the tests need no network and no live store. They just feed in plain objects and check the answer.
from fix_variation_stock_status import decide, expected_stock_status
def variation(**over):
base = {
"id": 501,
"manage_stock": True,
"stock_quantity": 0,
"backorders": "no",
"stock_status": "onbackorder",
}
base.update(over)
return base
def test_fix_when_onbackorder_at_zero_with_backorders_off():
v = variation()
action, reason = decide(v)
assert action == "fix"
assert expected_stock_status(v) == "outofstock"
def test_skip_when_status_already_outofstock():
v = variation(stock_status="outofstock")
assert decide(v)[0] == "skip"
def test_skip_when_backorders_allowed_and_status_matches():
v = variation(backorders="yes", stock_status="onbackorder")
assert decide(v)[0] == "skip"
def test_fix_when_in_stock_quantity_but_marked_outofstock():
v = variation(stock_quantity=5, stock_status="outofstock")
action, reason = decide(v)
assert action == "fix"
assert expected_stock_status(v) == "instock"
def test_skip_when_variation_does_not_manage_stock():
v = variation(manage_stock=False)
assert decide(v)[0] == "skip"
assert expected_stock_status(v) is None
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, expectedStockStatus } from "./fix-variation-stock-status.js";
const variation = (over = {}) => ({
id: 501,
manage_stock: true,
stock_quantity: 0,
backorders: "no",
stock_status: "onbackorder",
...over,
});
test("fix when onbackorder at zero with backorders off", () => {
const v = variation();
const [action] = decide(v);
assert.equal(action, "fix");
assert.equal(expectedStockStatus(v), "outofstock");
});
test("skip when status already outofstock", () => {
assert.equal(decide(variation({ stock_status: "outofstock" }))[0], "skip");
});
test("skip when backorders allowed and status matches", () => {
assert.equal(decide(variation({ backorders: "yes", stock_status: "onbackorder" }))[0], "skip");
});
test("fix when in stock quantity but marked outofstock", () => {
const v = variation({ stock_quantity: 5, stock_status: "outofstock" });
const [action] = decide(v);
assert.equal(action, "fix");
assert.equal(expectedStockStatus(v), "instock");
});
test("skip when variation does not manage stock", () => {
const v = variation({ manage_stock: false });
assert.equal(decide(v)[0], "skip");
assert.equal(expectedStockStatus(v), null);
});
Case studies
The restock that did not restock the label
A store ran a supplier feed import that updated quantities for four hundred variations overnight. The import wrote the new stock numbers straight to the database and skipped WooCommerce's own save routine entirely, so around sixty variations that had run out with backorders on kept showing "On backorder" long after backorders had been switched off during a catalog cleanup the month before.
The repair script found all sixty in dry run, matched the count the store manager expected from a manual spot check, then ran for real and corrected every one to Out of stock in under a minute.
The staging copy that came in stale
A developer copied the staging database to production after a redesign. Every variation's stock_status came along exactly as it was on staging, months out of date, while the live quantities had moved on. Several best sellers showed as available when they were actually at zero with backorders off.
Running the script against every variable product on a clean production pass surfaced the mismatches immediately, and the store switched DRY_RUN off once the list matched what the warehouse reported.
After this runs once, every variation's stock_status matches its own quantity and backorders setting. Running it again on a schedule, weekly is plenty for most stores, catches any future import or migration that skips WooCommerce's normal save path before a shopper ever notices the wrong label.
FAQ
Why does a variation still say On Backorder when its stock is at zero and backorders are off?
stock_status is a stored value, not something WooCommerce checks live on every page view. It is only recalculated when the quantity changes through WooCommerce's own save path. An import, a direct database edit, or changing the backorders setting after the quantity was already low can leave the old stock_status in place, so it stops matching the quantity and the backorders setting.
Is it safe to change a variation's stock_status with a script?
Yes, when the script only recomputes stock_status from the variation's own stock_quantity and backorders setting, and skips any variation that does not manage its own stock. Start in dry run mode to see the exact list before it writes anything.
Will this script change how many units I have in stock?
No. It never edits stock_quantity. It only corrects the stock_status label so it matches the quantity and backorders setting that are already saved on the variation.
Related field notes
Citations
On the problem:
- WooCommerce developer docs: managing product and variation stock, including how stock_status is derived from quantity and backorders. woocommerce.com/document/managing-products
- WooCommerce code reference: wc_update_product_stock and the conditions under which stock_status is recalculated. woocommerce.github.io/code-reference
- WooCommerce community report: variations showing an outdated backorder status after a bulk import or CSV update. wordpress.org/support
On the solution:
- WooCommerce REST API: retrieve and update a product variation, including stock_status, stock_quantity, and backorders fields. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: list product variations for a given product id with pagination. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce developer docs: backorder settings and the meaning of no, notify, and yes. woocommerce.com/document/managing-products
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.
Did this fix your stuck variations?
If this saved you a pile of "why is this out of stock" messages, 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