Repair Stock and inventory
WooCommerce negative stock from an overselling race
A product had two left. During a busy minute, three people checked out at once, and now its stock reads minus one. Real stock cannot go below zero, but WooCommerce shows it because two orders both passed the stock check before either one saved. That negative number then throws off your reports and your reorder points. Here is why the race happens and a small job that finds the oversold products and resets them in a safe way.
When several orders check out at the same moment, they can each pass the stock check before any of them saves the reduced count, so stock is subtracted more than once and drops below zero. Run a small Python or Node.js job that walks managed-stock products and variations, finds the ones below zero, and sets them back to zero over the REST API. It is read only by default. Full code, tests, and a dry run guard are below.
The problem in plain words
Before an order is placed, WooCommerce checks that there is enough stock. If the item is in stock, the order goes through and the stock is reduced. On a quiet store this works fine, one order at a time.
During a rush it can break. Two orders arrive within the same instant. Both read the stock as two, both decide there is enough, and both reduce it. Two sales came out of one unit of real stock, and the count lands at zero or below. The store oversold, and the negative number is the only visible trace.
Why it happens
This is a classic race condition. The gap between reading the stock and saving the new value is tiny, but under load two requests can both fit inside it. A few things make it more likely:
- A flash sale or a viral moment sends many orders for the same product in the same second.
- Express or one-click checkouts that skip steps and complete very fast.
- The hold stock setting is off, so unpaid orders do not reserve stock while they finish.
- A plugin or a custom integration reduces stock a second time on its own.
Turning on the hold stock setting and keeping stock management clean reduces how often it happens, but on a busy store some will still slip through. The reliable cleanup is to reset the negative counts on a schedule. See the citations at the end for the stock management docs.
A negative stock number is not a live fact, it is the scar left by a race. Once the oversold orders are handled, the honest value is zero until you restock. Resetting the below-zero products to zero clears the bad number so your reports and reorder tools stop reading a value that can never be real.
The fix, as a flow
We do not change the checkout. We add a job that walks your products, and for variable products their variations, and looks at any that manage stock. If the stock is below zero, we set it back to zero through the REST API, which keeps it correct whether or not the store uses High Performance Order Storage.
Build it step by step
Get a WooCommerce API key
You need a WooCommerce REST API key pair with read and write access to products. Create it under WooCommerce, Settings, Advanced, REST API. Working through the REST API means the job behaves the same whether or not the store uses High Performance Order Storage. Keep the values 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 DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
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
Decide, with one pure function
Keep the check in its own tiny function that takes a product or a variation and returns whether it is oversold. An item is oversold only when it manages stock and its quantity is below zero. A product that does not track stock, or is at zero, is never touched. Because it is pure, we can test every branch without a network.
def is_oversold(item):
if not item.get("manage_stock"):
return False
q = item.get("stock_quantity")
return q is not None and q < 0
export function isOversold(item) {
if (!item.manage_stock) return false;
const q = item.stock_quantity;
return q !== null && q !== undefined && q < 0;
}
Walk products and their variations
Page through all products. A simple product carries its own stock. A variable product does not, its variations do, so for those we page through the variations and check each one. This way both a plain product and a single size of a variable product get checked.
def get(path, params=None):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3{path}", params=params or {}, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def products():
page = 1
while True:
batch = get("/products", {"per_page": 50, "page": page})
if not batch:
return
for product in batch:
yield product
page += 1
def variations(product_id):
page = 1
while True:
batch = get(f"/products/{product_id}/variations", {"per_page": 50, "page": page})
if not batch:
return
for variation in batch:
yield variation
page += 1
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* products() {
let page = 1;
while (true) {
const batch = await woo(`/products?per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function* variations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=50&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
Reset the oversold items to zero
When an item is oversold, send a small update that sets its stock quantity to zero. For a simple product that is the product endpoint, for a variation it is the variation endpoint. Reset only after the oversold orders have been handled, so you do not hide a real shortage that still needs picking.
def set_stock(path):
requests.put(
f"{WOO_URL}/wp-json/wc/v3{path}",
json={"stock_quantity": 0}, auth=AUTH, timeout=30,
).raise_for_status()
async function setStock(path) {
await woo(path, { method: "PUT", body: JSON.stringify({ stock_quantity: 0 }) });
}
Wire it together with a dry run guard
The loop ties every piece together, checking simple products directly and variable products through their variations. On the first few runs, leave DRY_RUN on so the job only reports what it would reset. Read the output, confirm those items really are oversold, then switch it off.
Start with DRY_RUN=true. Reset stock to zero only after the oversold orders are accounted for, so you do not paper over a genuine shortage that still needs to be picked or refunded. To prevent the race in the first place, turn on the hold stock setting so unpaid orders reserve stock while they finish.
The full code
Here is the complete 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 only ever resets managed-stock items that are below zero.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find and correct WooCommerce products and variations that oversold to negative stock.
Read only by default. Run 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_negative_stock")
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"
def is_oversold(item):
if not item.get("manage_stock"):
return False
q = item.get("stock_quantity")
return q is not None and q < 0
def get(path, params=None):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3{path}", params=params or {}, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def set_stock(path):
requests.put(
f"{WOO_URL}/wp-json/wc/v3{path}",
json={"stock_quantity": 0}, auth=AUTH, timeout=30,
).raise_for_status()
def products():
page = 1
while True:
batch = get("/products", {"per_page": 50, "page": page})
if not batch:
return
for product in batch:
yield product
page += 1
def variations(product_id):
page = 1
while True:
batch = get(f"/products/{product_id}/variations", {"per_page": 50, "page": page})
if not batch:
return
for variation in batch:
yield variation
page += 1
def run():
fixed = 0
for product in products():
targets = [(f"/products/{product['id']}", product)]
if product.get("type") == "variable":
targets = [(f"/products/{product['id']}/variations/{v['id']}", v) for v in variations(product["id"])]
for path, item in targets:
if not is_oversold(item):
continue
log.warning("%s is at %s. %s", path, item["stock_quantity"],
"would set to 0" if DRY_RUN else "setting to 0")
if not DRY_RUN:
set_stock(path)
fixed += 1
log.info("Done. %d oversold item(s) %s.", fixed, "to correct" if DRY_RUN else "corrected")
if __name__ == "__main__":
run()
/**
* Find and correct WooCommerce products and variations that oversold to negative stock.
* Read only by default. Run on a schedule.
*/
import { pathToFileURL } from "node:url";
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");
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function isOversold(item) {
if (!item.manage_stock) return false;
const q = item.stock_quantity;
return q !== null && q !== undefined && q < 0;
}
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* products() {
let page = 1;
while (true) {
const batch = await woo(`/products?per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function* variations(productId) {
let page = 1;
while (true) {
const batch = await woo(`/products/${productId}/variations?per_page=50&page=${page}`);
if (!batch.length) return;
for (const variation of batch) yield variation;
page++;
}
}
async function setStock(path) {
await woo(path, { method: "PUT", body: JSON.stringify({ stock_quantity: 0 }) });
}
async function run() {
let fixed = 0;
for await (const product of products()) {
let targets = [[`/products/${product.id}`, product]];
if (product.type === "variable") {
targets = [];
for await (const v of variations(product.id)) targets.push([`/products/${product.id}/variations/${v.id}`, v]);
}
for (const [path, item] of targets) {
if (!isOversold(item)) continue;
console.warn(`${path} is at ${item.stock_quantity}. ${DRY_RUN ? "would set to 0" : "setting to 0"}`);
if (!DRY_RUN) await setStock(path);
fixed++;
}
}
console.log(`Done. ${fixed} oversold item(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The oversold check is the part most worth testing, because it decides what gets reset. Because it is pure, the test needs no network and no store. It just feeds in plain items and checks the answer.
from fix_negative_stock import is_oversold
def item(**over):
base = {"manage_stock": True, "stock_quantity": -3}
base.update(over)
return base
def test_oversold_when_managed_and_negative():
assert is_oversold(item()) is True
def test_not_oversold_when_zero():
assert is_oversold(item(stock_quantity=0)) is False
def test_not_oversold_when_positive():
assert is_oversold(item(stock_quantity=5)) is False
def test_not_oversold_when_stock_not_managed():
assert is_oversold(item(manage_stock=False)) is False
def test_not_oversold_when_quantity_is_none():
assert is_oversold(item(stock_quantity=None)) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOversold } from "./fix-negative-stock.js";
const item = (over = {}) => ({ manage_stock: true, stock_quantity: -3, ...over });
test("oversold when managed and negative", () => {
assert.equal(isOversold(item()), true);
});
test("not oversold when zero", () => {
assert.equal(isOversold(item({ stock_quantity: 0 })), false);
});
test("not oversold when positive", () => {
assert.equal(isOversold(item({ stock_quantity: 5 })), false);
});
test("not oversold when stock not managed", () => {
assert.equal(isOversold(item({ manage_stock: false })), false);
});
test("not oversold when quantity is null", () => {
assert.equal(isOversold(item({ stock_quantity: null })), false);
});
Case studies
The last two that sold five times
A small brand ran a midnight drop of a limited item with only a few in stock. Dozens of shoppers hit checkout in the same few seconds, several races landed at once, and a couple of variants ended up at minus three. The next morning the reorder report tried to buy against those negatives.
The job on an hourly schedule reset the oversold variants to zero after the team sorted out which orders to fulfill or refund. The reorder math went back to normal and the counts told the truth again.
The plugin that subtracted twice
A store added a fulfillment plugin that reduced stock on its own, on top of WooCommerce doing the same. Fast-moving products drifted below zero even without a real rush, so the shop looked out of stock while boxes sat on the shelf.
The team ran the job in dry run, saw the exact list of items that had gone negative, fixed the double reduction, then let the job reset the counts to zero so a clean recount could begin.
After this runs on a schedule, a race during a rush is a brief blip, not a number that sticks around for weeks. Your reports and reorder tools read honest counts, and with hold stock turned on the races grow rarer too. You decide what to restock from a clean zero instead of a confusing minus.
FAQ
Why is my WooCommerce stock a negative number?
When several orders check out at the same moment, they can each pass the stock check before any of them saves the reduced count, so the stock is reduced more than once and falls below zero. It is a sign the store oversold during a rush. Real stock cannot be negative, so the number should be reset to zero once the orders are handled.
How do I stop WooCommerce from overselling?
Turn on the hold stock setting so unpaid orders reserve stock for a set number of minutes, keep stock management on at the product level, and avoid plugins that reduce stock twice. A scheduled job that resets negative stock cleans up the counts that still slip through during a heavy rush.
Is it safe to reset stock with a script?
Yes, when the script only touches products and variations that manage stock and are below zero, and you reset them after the oversold orders are accounted for. It goes through the REST API, so it works with High Performance Order Storage. Start in dry run mode to review the list first.
Related field notes
Citations
On the problem:
- WooCommerce docs: managing stock at the store and product level. woocommerce.com/document/product-inventory-settings
- WooCommerce docs: the hold stock setting that reserves stock for unpaid orders. woocommerce.com inventory options
- WooCommerce developer resources: stock is reduced when an order is processed, which can race under load. developer.woocommerce.com/docs
On the solution:
- WooCommerce REST API: list products. woocommerce.github.io/woocommerce-rest-api-docs (products)
- WooCommerce REST API: list product variations. woocommerce.github.io/woocommerce-rest-api-docs (variations)
- WooCommerce REST API: update a product or variation, including stock quantity. woocommerce.github.io/woocommerce-rest-api-docs (update product)
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 clean up your stock counts?
If this cleared a pile of negative numbers for you, 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