Repair WooCommerce core: products and catalog
WooCommerce attribute and term counts drift from the real products
A shopper filters by "Color: Blue" and the widget says 42 products, but only 31 actually show up. Nobody touched the theme. Nobody touched the filter plugin. The number stored on the attribute term simply stopped matching the catalog. Here is why that count drifts and a small script that recounts every term and repairs the ones that are wrong.
Term counts are cached numbers, not live queries. WordPress only refreshes a taxonomy term's count when its own recount hooks fire, and a lot of normal WooCommerce activity, bulk imports, REST API edits, and stock or status changes, never fires them. Run a small Python or Node.js script on a schedule that reads every attribute term through the WooCommerce REST API, recomputes how many published, in-stock products actually carry that term, and writes back only the terms whose stored count disagrees. Full code, tests, and a dry run guard are below.
The problem in plain words
Every product attribute term, like "Blue" under the Color attribute, carries a small cached number called count. That number is what layered navigation, filter widgets, and most theme attribute swatches show next to the term. It is meant to answer one question: how many products in the shop currently carry this term and should show up if a shopper filters by it.
WordPress does not calculate that number every time a page loads, because counting every product for every term on every request would be slow. Instead it caches the number on the term row and only recalculates it when specific WordPress functions run, mainly around saving a post through the normal editor screen. Anything that changes a product's attributes, stock, or visibility without going through that exact path leaves the cached number untouched, even though the real answer has changed.
Why it happens
The WordPress and WooCommerce docs describe term counts as something that gets refreshed by specific recount functions tied to the normal post save flow. A few common reasons that flow never runs:
- A CSV or XML import tool writes attribute terms and product meta directly through the importer's own code path, which does not always trigger
wp_update_term_count_now()for every affected term. - An edit made through the WooCommerce REST API changes a product's attributes without the site also running the taxonomy count refresh that the classic admin screen triggers on save.
- A product goes out of stock, is trashed, or is switched to draft, and the term count was never told that one of "its" products should no longer be counted.
- A staging to production migration copies the product tables but the term taxonomy relationships get rebuilt in a different order, leaving counts from the old environment attached to the new one.
This is a long-running WordPress core behavior, not a WooCommerce bug on its own, but it shows up constantly on WooCommerce stores because attribute filtering leans on it so heavily. See the citations at the end for the exact references.
The real count is never actually lost, it can always be recomputed by looking at which published, in-stock products currently carry each term. A recounter is a safety net that runs on a schedule, recalculates the true number for every term, and only writes back the ones that disagree with what is stored.
The fix, as a flow
We do not touch product data, price, or stock. We add a job that runs on a schedule, walks every attribute and every term under it, and counts how many published, in-stock products actually carry that term right now. If the real count disagrees with the number WooCommerce has stored, we write the correct number back to the term. We also check recent Stripe PaymentIntents against the affected products, so a term that is actively driving sales and showing a wrong count gets flagged as more urgent in the log.
Build it step by step
Get access to both systems
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to products and product attributes, plus a Stripe secret key for the sales cross-check. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SALES_LOOKBACK_HOURS="24"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export SALES_LOOKBACK_HOURS="24"
export DRY_RUN="true" // start safe, change to false to write
List every attribute and its terms
Ask the WooCommerce REST API for the global attributes, then for each attribute, page through its terms. Each term already carries the count WooCommerce currently has stored, which is exactly the number we are going to check.
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 all_attributes():
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/attributes", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def attribute_terms(attribute_id):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products/attributes/{attribute_id}/terms",
params={"per_page": 100, "page": page}, auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for term in batch:
yield term
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, 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 allAttributes() {
return woo("/products/attributes");
}
async function* attributeTerms(attributeId) {
let page = 1;
while (true) {
const batch = await woo(`/products/attributes/${attributeId}/terms?per_page=100&page=${page}`);
if (!batch.length) return;
for (const term of batch) yield term;
page++;
}
}
Recompute the real count for a term
For each term, ask the products endpoint how many published, in-stock products actually carry it, filtered by the term's own attribute slug. The total WooCommerce reports back for that filtered query is the true count, no matter what the term's cached number says.
def real_count(attribute_slug, term_slug):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={
"attribute": attribute_slug,
"attribute_term": term_slug,
"status": "publish",
"stock_status": "instock",
"per_page": 1,
},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return int(r.headers.get("X-WP-Total", "0"))
async function realCount(attributeSlug, termSlug) {
const url = `${WOO_URL}/wp-json/wc/v3/products?attribute=${attributeSlug}` +
`&attribute_term=${termSlug}&status=publish&stock_status=instock&per_page=1`;
const res = await fetch(url, { headers: { Authorization: AUTH } });
if (!res.ok) throw new Error(`Woo products lookup returned ${res.status}`);
await res.json();
return Number(res.headers.get("X-WP-Total") || 0);
}
Decide, with one pure function
Keep the decision in its own function that takes a term and its freshly computed real count 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 stored count already matches, skip it. Otherwise, repair it with the true number.
def decide(term, real):
stored = term.get("count", 0)
if stored == real:
return ("skip", "count already correct")
if real < 0:
return ("skip", "real count invalid, will not write a negative number")
return ("repair", f"stored {stored}, real {real}")
export function decide(term, real) {
const stored = term.count || 0;
if (stored === real) return ["skip", "count already correct"];
if (real < 0) return ["skip", "real count invalid, will not write a negative number"];
return ["repair", `stored ${stored}, real ${real}`];
}
Write the true count back to the term
When the action is repair, update the term through the same attribute terms endpoint, sending only the corrected count. Nothing about the product itself, its price, or its stock is touched, only the cached number on the taxonomy term.
def write_count(attribute_id, term_id, real):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/attributes/{attribute_id}/terms/{term_id}",
json={"count": real}, auth=AUTH, timeout=30,
).raise_for_status()
async function writeCount(attributeId, termId, real) {
await woo(`/products/attributes/${attributeId}/terms/${termId}`, {
method: "PUT",
body: JSON.stringify({ count: real }),
});
}
Flag urgency with a Stripe sales cross-check, then wire it together
A wrong count on a slow term is a cosmetic bug. A wrong count on a term that is actively selling is a shopper being told "we have none of that" when the store does. We ask Stripe for PaymentIntents that succeeded in the lookback window, read the WooCommerce order id from metadata.order_id, load each order's line items, and mark any drifted term whose products appear in a real, succeeded sale as higher priority in the log. The loop then ties every piece together behind the dry run guard.
Always start with DRY_RUN=true. The script only ever writes a count number, never product data, but you still 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 recounter 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 a term whose count is already correct is simply skipped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Recount WooCommerce attribute terms whose cached count drifted from the real
catalog. Cross-checks recent Stripe sales to flag drifted terms that are still
actively selling as higher priority. Run on a schedule. Safe to run again and again.
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recount_terms")
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
SALES_LOOKBACK_HOURS = int(os.environ.get("SALES_LOOKBACK_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def all_attributes():
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/attributes", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def attribute_terms(attribute_id):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products/attributes/{attribute_id}/terms",
params={"per_page": 100, "page": page}, auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for term in batch:
yield term
page += 1
def real_count(attribute_slug, term_slug):
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={
"attribute": attribute_slug,
"attribute_term": term_slug,
"status": "publish",
"stock_status": "instock",
"per_page": 1,
},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return int(r.headers.get("X-WP-Total", "0"))
def decide(term, real):
stored = term.get("count", 0)
if stored == real:
return ("skip", "count already correct")
if real < 0:
return ("skip", "real count invalid, will not write a negative number")
return ("repair", f"stored {stored}, real {real}")
def write_count(attribute_id, term_id, real):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/attributes/{attribute_id}/terms/{term_id}",
json={"count": real}, auth=AUTH, timeout=30,
).raise_for_status()
def recently_sold_product_ids(lookback_hours):
since = int(time.time()) - lookback_hours * 3600
ids = set()
for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
order_id = intent.metadata.get("order_id")
if intent.status != "succeeded" or not order_id:
continue
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
if r.status_code != 200:
continue
for line in r.json().get("line_items", []):
ids.add(line.get("product_id"))
return ids
def run():
repaired = 0
sold_ids = recently_sold_product_ids(SALES_LOOKBACK_HOURS)
for attribute in all_attributes():
for term in attribute_terms(attribute["id"]):
real = real_count(attribute["slug"], term["slug"])
action, reason = decide(term, real)
if action == "skip":
continue
urgent = bool(sold_ids) and real > 0
log.info(
"Term %s (%s): %s. %s%s",
term["name"], attribute["name"], reason,
"would repair" if DRY_RUN else "repairing",
" [urgent: recent sales use this term]" if urgent else "",
)
if not DRY_RUN:
write_count(attribute["id"], term["id"], real)
repaired += 1
log.info("Done. %d term(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")
if __name__ == "__main__":
run()
/**
* Recount WooCommerce attribute terms whose cached count drifted from the real
* catalog. Cross-checks recent Stripe sales to flag drifted terms that are still
* actively selling as higher priority. Run on a schedule. Safe to run again and again.
*/
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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 SALES_LOOKBACK_HOURS = Number(process.env.SALES_LOOKBACK_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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 allAttributes() {
return woo("/products/attributes");
}
async function* attributeTerms(attributeId) {
let page = 1;
while (true) {
const batch = await woo(`/products/attributes/${attributeId}/terms?per_page=100&page=${page}`);
if (!batch.length) return;
for (const term of batch) yield term;
page++;
}
}
async function realCount(attributeSlug, termSlug) {
const url = `${WOO_URL}/wp-json/wc/v3/products?attribute=${attributeSlug}` +
`&attribute_term=${termSlug}&status=publish&stock_status=instock&per_page=1`;
const res = await fetch(url, { headers: { Authorization: AUTH } });
if (!res.ok) throw new Error(`Woo products lookup returned ${res.status}`);
await res.json();
return Number(res.headers.get("X-WP-Total") || 0);
}
export function decide(term, real) {
const stored = term.count || 0;
if (stored === real) return ["skip", "count already correct"];
if (real < 0) return ["skip", "real count invalid, will not write a negative number"];
return ["repair", `stored ${stored}, real ${real}`];
}
async function writeCount(attributeId, termId, real) {
await woo(`/products/attributes/${attributeId}/terms/${termId}`, {
method: "PUT",
body: JSON.stringify({ count: real }),
});
}
async function recentlySoldProductIds(lookbackHours) {
const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
const ids = new Set();
for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
const orderId = intent.metadata.order_id;
if (intent.status !== "succeeded" || !orderId) continue;
const order = await woo(`/orders/${orderId}`).catch(() => null);
if (!order) continue;
for (const line of order.line_items || []) ids.add(line.product_id);
}
return ids;
}
export async function run() {
let repaired = 0;
const soldIds = await recentlySoldProductIds(SALES_LOOKBACK_HOURS);
for (const attribute of await allAttributes()) {
for await (const term of attributeTerms(attribute.id)) {
const real = await realCount(attribute.slug, term.slug);
const [action, reason] = decide(term, real);
if (action === "skip") continue;
const urgent = soldIds.size > 0 && real > 0;
console.log(
`Term ${term.name} (${attribute.name}): ${reason}. ` +
`${DRY_RUN ? "would repair" : "repairing"}${urgent ? " [urgent: recent sales use this term]" : ""}`
);
if (!DRY_RUN) await writeCount(attribute.id, term.id, real);
repaired++;
}
}
console.log(`Done. ${repaired} term(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}
run().catch((err) => { console.error(err); process.exit(1); });
Add a test
The decision rule is the part most worth testing, because it decides whether a term's stored count gets overwritten. Because we kept decide pure, the test needs no network, no Stripe account, and no live store. It just feeds in plain objects and checks the action.
from recount_terms import decide
def test_skip_when_count_already_correct():
assert decide({"count": 42}, 42)[0] == "skip"
def test_repair_when_count_too_high():
assert decide({"count": 42}, 31)[0] == "repair"
def test_repair_when_count_too_low():
assert decide({"count": 5}, 12)[0] == "repair"
def test_skip_when_real_is_negative():
assert decide({"count": 5}, -1)[0] == "skip"
def test_defaults_stored_count_to_zero():
action, reason = decide({}, 3)
assert action == "repair"
assert "stored 0" in reason
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./recount-terms.js";
test("skip when count already correct", () => {
assert.equal(decide({ count: 42 }, 42)[0], "skip");
});
test("repair when count too high", () => {
assert.equal(decide({ count: 42 }, 31)[0], "repair");
});
test("repair when count too low", () => {
assert.equal(decide({ count: 5 }, 12)[0], "repair");
});
test("skip when real is negative", () => {
assert.equal(decide({ count: 5 }, -1)[0], "skip");
});
test("defaults stored count to zero", () => {
const [action, reason] = decide({}, 3);
assert.equal(action, "repair");
assert.match(reason, /stored 0/);
});
Case studies
The catalog refresh that froze every filter count
A store re-imported its whole catalog through a CSV tool to fix pricing ahead of a sale. The import updated attributes on thousands of products directly through the importer's own code, never touching the classic product editor, so none of the term counts moved. The color and size filters kept showing last month's numbers for two weeks.
The recounter on a daily schedule found 96 terms whose stored count no longer matched, repaired every one on its first real run, and confirmed the filter widget matched the catalog again within one day.
The size that kept selling after it should have disappeared
A bestselling shirt sold out in one size through a fast checkout flow that updated stock directly, without the recount hooks that the admin screen usually fires. "Size: Large" kept showing a count of eight, so shoppers kept filtering to a size that had nothing left, then bouncing when every result said out of stock.
The Stripe cross-check flagged that term as urgent, since recent PaymentIntents pointed straight at that shirt, so it got fixed first instead of waiting behind slower, cosmetic drift on rarely viewed terms.
After this runs on a schedule, filter counts stop being a source of quiet distrust. A shopper who filters by a term sees a number that matches what is actually there. Keep it running even after a big import is long finished, since normal REST API activity and stock changes will keep causing small drift on their own.
FAQ
Why do WooCommerce attribute term counts stop matching the real products?
The count stored on a taxonomy term is only updated when WordPress runs its own recount hooks. A bulk import, a REST API edit, a direct database change, or a product moving out of stock or out of the published state can change which products should count without ever firing those hooks, so the stored number quietly drifts from reality.
Is it safe to let a script rewrite term counts?
Yes, when the script only recomputes the count from the actual set of published, in-stock products carrying that term and writes back nothing else. It never touches product data, price, or stock. Start in dry run mode to see every term that would change before it writes.
How often should I recount attribute terms?
Once a day is enough for most stores, and right after any bulk import, bulk edit, or catalog migration. It only recomputes numbers, so running it often carries no real risk.
Related field notes
Citations
On the problem:
- WordPress developer reference: term counts are refreshed by
wp_update_term_count_now(), not on every request. developer.wordpress.org/reference/functions/wp_update_term_count_now - WooCommerce docs: how product attributes and their terms drive layered navigation and filter widgets. woocommerce.com/document/managing-product-taxonomies
- WooCommerce community report: attribute and category counts not updating after bulk edits and imports. wordpress.org/support/topic/product-count-not-updating-2
On the solution:
- WooCommerce REST API: list and update product attribute terms, including the
countfield. woocommerce.github.io/woocommerce-rest-api-docs - WooCommerce REST API: filter products by attribute and attribute term. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
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 filter counts?
If this saved you a pile of "why does the filter say we have this in stock" tickets, 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