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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A close up of four different colored papers
Photo by The 77 Human Needs System on Unsplash
The short answer

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.

Bulk import or REST API edit Product's terms or stock changes recount hook skipped Stale count cached on term Wrong filter counts shown
The count is cached at import or edit time. When the recount hook is skipped, the stale number keeps showing on every filter until something recalculates it.

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:

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 key insight

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.

Scheduled job once a day List attributes and every term Recount real published, in stock Counts disagree? yes no, skip Write true count back to the term
The recounter recomputes the true number from real products and only writes back the terms whose stored count is wrong. Terms already correct are left untouched.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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
step2.js
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++;
  }
}
3

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.

step3.py
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"))
step3.js
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);
}
4

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.

decide.py
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}")
decide.js
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}`];
}
5

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.

apply.py
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()
apply.js
async function writeCount(attributeId, termId, real) {
  await woo(`/products/attributes/${attributeId}/terms/${termId}`, {
    method: "PUT",
    body: JSON.stringify({ count: real }),
  });
}
6

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.

Run it safe

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_terms.py
"""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-terms.js
/**
 * 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.

test_decide.py
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
decide.test.js
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

CSV import

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.

Out of stock

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.

What good looks like

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:

  1. 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
  2. WooCommerce docs: how product attributes and their terms drive layered navigation and filter widgets. woocommerce.com/document/managing-product-taxonomies
  3. 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:

  1. WooCommerce REST API: list and update product attribute terms, including the count field. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: filter products by attribute and attribute term. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes