Repair WooCommerce core: products and catalog
Popularity sort uses stale total sales
Sort a WooCommerce shop by "Popularity" and it should show what actually sells first. Instead it often shows products that stopped selling months ago, while a genuine bestseller sits on page three. The sort is not broken. The number it sorts by, total_sales, is wrong. Here is why that number drifts and a small script that recounts it from real orders.
The Popularity sort orders products by the total_sales number saved on each product, and WooCommerce only updates that number through its own order status hooks. Imported orders, statuses changed outside the normal checkout flow, and refunds that never decrement the count all leave it stale. Run a small Python or Node.js script on a schedule that sums real quantities from paid orders and refunds through the WooCommerce REST API, compares that to the stored number, and corrects any product that disagrees. Full code, tests, and a dry run guard are below.
The problem in plain words
Every WooCommerce product carries a hidden number called total_sales. It is not shown on the product page. It exists for one job: sorting the catalog by "Popularity" in the shop, in a widget, or in a shortcode. When a shopper picks that sort, WooCommerce simply orders products by this number, largest first.
WooCommerce keeps that number current by listening for an order to reach Processing or Completed, then adding the quantity from each line item. That works fine for orders that go through a normal checkout. It falls apart the moment an order gets into a paid state some other way, or a paid order is later refunded and the count is never walked back down. The catalog then ranks by a number that describes last quarter, or a bulk import, instead of what customers are buying today.
Why it happens
The WooCommerce core code that updates total_sales runs inside the order status transition hooks, specifically when an order moves into a status that counts as a completed sale. That is a clean design for the common case, but several everyday situations skip it entirely:
- Orders brought in through a migration, a CSV import, or a custom script that inserts rows directly, without ever firing the WooCommerce order status hooks.
- A staff member or another plugin changes an order's status directly in the database or through a raw SQL update, instead of through the normal WooCommerce order edit screen or API.
- A refund or a cancellation after the sale was counted. WooCommerce does not always walk
total_salesback down to match, so returned or cancelled units can stay counted as sold forever. - A theme, a report plugin, or a custom checkout writes an order straight to Processing without going through the usual payment complete flow, so the hook that increments the count never runs.
- Store migrations between hosts or between payment gateways sometimes replay orders in a way that double counts or skips the hook, since the underlying WordPress post or the HPOS order table rows change without the matching WooCommerce action firing.
None of this shows up as an error. The store just quietly ranks its catalog by a number that stopped being true a while ago, and nobody notices until a merchant asks why their newest bestseller is not on the first page of the Popularity sort.
The orders themselves are the source of truth for what sold, not the cached total_sales field. If you sum the real quantities from paid orders, then subtract whatever was later refunded, you get the number the Popularity sort should have been using all along. A recount script is a safety net that runs on a schedule, computes that real number, and repairs the products whose cached count drifted.
The fix, as a flow
We do not touch checkout or the order status hooks. We add a job that runs on a schedule, walks paid orders in a lookback window, and adds up the real quantity sold per product, refunds included as negative units. It then compares that real count to what is stored on the product and, only when the two disagree, writes the corrected number back through the WooCommerce REST API.
Build it step by step
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 and orders. Create the key under WooCommerce, Settings, Advanced, REST API. Keep every value 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 LOOKBACK_DAYS="365"
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 LOOKBACK_DAYS="365"
export DRY_RUN="true" // start safe, change to false to write
Walk every paid order and its refunds
Ask the WooCommerce REST API for orders in Processing or Completed within the lookback window, and page through all of them. For each order, also fetch its refunds, since a refund line item carries the quantity that was returned. Going through the REST API means the code works the same whether the store uses High Performance Order Storage (HPOS) or the legacy post based orders, because WooCommerce handles the storage for you.
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 paid_orders(after_iso):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after_iso, "per_page": 100, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
yield from batch
page += 1
def refund_line_items(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds", auth=AUTH, timeout=30)
r.raise_for_status()
for refund in r.json():
yield from refund.get("line_items", [])
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: { Authorization: AUTH } });
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* paidOrders(afterIso) {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=processing,completed&after=${afterIso}&per_page=100&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function refundLineItems(orderId) {
const refunds = await woo(`/orders/${orderId}/refunds`);
return refunds.flatMap((refund) => refund.line_items || []);
}
Sum real quantity per product
Add up the quantity from every order line item, keyed by product_id. Refund line items already carry a negative quantity in WooCommerce, so adding them subtracts the returned units automatically. The result is the true number of units sold for each product, right now, in the window you chose.
from collections import defaultdict
def net_quantity(line_item):
try:
return int(line_item.get("quantity") or 0)
except (TypeError, ValueError):
return 0
def real_sales_by_product(orders_with_refunds):
totals = defaultdict(int)
for order, refund_items in orders_with_refunds:
for item in order.get("line_items", []):
if item.get("product_id"):
totals[item["product_id"]] += net_quantity(item)
for item in refund_items:
if item.get("product_id"):
totals[item["product_id"]] += net_quantity(item)
return totals
export function netQuantity(lineItem) {
const qty = Number(lineItem && lineItem.quantity);
return Number.isFinite(qty) ? Math.trunc(qty) : 0;
}
function addRealSales(totals, order, refundItems) {
for (const item of order.line_items || []) {
if (item.product_id) totals.set(item.product_id, (totals.get(item.product_id) || 0) + netQuantity(item));
}
for (const item of refundItems) {
if (item.product_id) totals.set(item.product_id, (totals.get(item.product_id) || 0) + netQuantity(item));
}
}
Decide, with one pure function
Keep the decision in its own function that takes the stored count and the 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 two numbers already agree, skip it. Otherwise, fix it, and never let the corrected number go negative.
def decide(stored_total_sales, real_total_sales):
try:
stored = int(stored_total_sales)
except (TypeError, ValueError):
stored = 0
real = max(0, int(real_total_sales))
if stored == real:
return ("skip", "total_sales already correct")
return ("fix", f"stored {stored}, real {real}")
export function decide(storedTotalSales, realTotalSales) {
const stored = Number.isFinite(Number(storedTotalSales)) ? Math.trunc(Number(storedTotalSales)) : 0;
const real = Math.max(0, Math.trunc(realTotalSales || 0));
if (stored === real) return ["skip", "total_sales already correct"];
return ["fix", `stored ${stored}, real ${real}`];
}
Write the corrected number back
When the action is fix, update the product's total_sales field through the WooCommerce REST API. This is the same field the Popularity sort reads, so the correction takes effect the moment it is saved, no cache to clear and no reindex to wait for.
def write_total_sales(product_id, real_total_sales):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"total_sales": str(real_total_sales)},
auth=AUTH, timeout=30,
).raise_for_status()
async function writeTotalSales(productId, realTotalSales) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ total_sales: String(realTotalSales) }),
});
}
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 which products it would correct and by how much. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day.
Always start with DRY_RUN=true. A recount rewrites real product data, 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 recount 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 only ever writes a product whose stored count still disagrees with the real one.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Recount WooCommerce's total_sales product meta from real, paid orders.
The "Popularity" catalog sort orders products by the total_sales number
stored on each product. WooCommerce core only bumps that number through its
own order status hooks, so it drifts from reality whenever orders are
imported straight into the database, a status is changed by another plugin
or a direct SQL update, or a refund and cancellation never decrements it
back down. This walks paid orders in a lookback window, sums real
quantities per product (minus refunded quantities), compares that to the
stored total_sales, and corrects any product whose number is wrong. Read
only by default. Run on a schedule.
"""
import os
import logging
import requests
from collections import defaultdict
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recount_total_sales")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "365"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SALE_COUNTED_STATUSES = {"processing", "completed"}
def decide(stored_total_sales, real_total_sales):
try:
stored = int(stored_total_sales)
except (TypeError, ValueError):
stored = 0
real = max(0, int(real_total_sales))
if stored == real:
return ("skip", "total_sales already correct")
return ("fix", f"stored {stored}, real {real}")
def net_quantity(line_item):
try:
return int(line_item.get("quantity") or 0)
except (TypeError, ValueError):
return 0
def paid_orders(after_iso):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "processing,completed", "after": after_iso, "per_page": 100, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
def refund_line_items(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds", auth=AUTH, timeout=30)
r.raise_for_status()
for refund in r.json():
for item in refund.get("line_items", []):
yield item
def real_sales_by_product(lookback_days):
import datetime
after = (datetime.date.today() - datetime.timedelta(days=lookback_days)).isoformat() + "T00:00:00"
totals = defaultdict(int)
for order in paid_orders(after):
for item in order.get("line_items", []):
product_id = item.get("product_id")
if not product_id:
continue
totals[product_id] += net_quantity(item)
for item in refund_line_items(order["id"]):
product_id = item.get("product_id")
if not product_id:
continue
totals[product_id] += net_quantity(item)
return totals
def get_product(product_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def write_total_sales(product_id, real_total_sales):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"total_sales": str(real_total_sales)},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
totals = real_sales_by_product(LOOKBACK_DAYS)
for product_id, real_total_sales in totals.items():
product = get_product(product_id)
if product is None:
log.warning("Product %s has sales but no longer exists, skipping", product_id)
continue
action, reason = decide(product.get("total_sales"), real_total_sales)
if action == "skip":
continue
log.info("Product %s: %s. %s", product_id, reason, "would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
write_total_sales(product_id, real_total_sales)
fixed += 1
log.info("Done. %d product(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Recount WooCommerce's total_sales product meta from real, paid orders.
*
* The "Popularity" catalog sort orders products by the total_sales number
* stored on each product. WooCommerce core only bumps that number through
* its own order status hooks, so it drifts from reality whenever orders are
* imported straight into the database, a status is changed by another
* plugin or a direct SQL update, or a refund and cancellation never
* decrements it back down. This walks paid orders in a lookback window,
* sums real quantities per product (minus refunded quantities), compares
* that to the stored total_sales, and corrects any product whose number is
* wrong. Read only by default. Run 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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 365);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decide(storedTotalSales, realTotalSales) {
const stored = Number.isFinite(Number(storedTotalSales)) ? Math.trunc(Number(storedTotalSales)) : 0;
const real = Math.max(0, Math.trunc(realTotalSales || 0));
if (stored === real) return ["skip", "total_sales already correct"];
return ["fix", `stored ${stored}, real ${real}`];
}
export function netQuantity(lineItem) {
const qty = Number(lineItem && lineItem.quantity);
return Number.isFinite(qty) ? Math.trunc(qty) : 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.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* paidOrders(afterIso) {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=processing,completed&after=${afterIso}&per_page=100&page=${page}`);
if (!batch || !batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function refundLineItems(orderId) {
const refunds = (await woo(`/orders/${orderId}/refunds`)) || [];
const items = [];
for (const refund of refunds) {
for (const item of refund.line_items || []) items.push(item);
}
return items;
}
async function realSalesByProduct(lookbackDays) {
const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
const totals = new Map();
for await (const order of paidOrders(after)) {
for (const item of order.line_items || []) {
if (!item.product_id) continue;
totals.set(item.product_id, (totals.get(item.product_id) || 0) + netQuantity(item));
}
for (const item of await refundLineItems(order.id)) {
if (!item.product_id) continue;
totals.set(item.product_id, (totals.get(item.product_id) || 0) + netQuantity(item));
}
}
return totals;
}
async function writeTotalSales(productId, realTotalSales) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({ total_sales: String(realTotalSales) }),
});
}
export async function run() {
let fixed = 0;
const totals = await realSalesByProduct(LOOKBACK_DAYS);
for (const [productId, realTotalSales] of totals) {
const product = await woo(`/products/${productId}`);
if (!product) {
console.warn(`Product ${productId} has sales but no longer exists, skipping`);
continue;
}
const [action, reason] = decide(product.total_sales, realTotalSales);
if (action === "skip") continue;
console.log(`Product ${productId}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await writeTotalSales(productId, realTotalSales);
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 their sales data rewritten. Because we kept decide pure, the test needs no network and no store credentials. It just feeds in plain numbers and checks the action.
from recount_total_sales import decide, net_quantity
def test_skip_when_totals_match():
assert decide(12, 12)[0] == "skip"
def test_fix_when_stored_is_lower_than_real():
assert decide(3, 40)[0] == "fix"
def test_fix_when_stored_is_higher_than_real():
assert decide(40, 3)[0] == "fix"
def test_fix_when_stored_is_missing():
assert decide(None, 5)[0] == "fix"
def test_skip_when_stored_is_missing_and_real_is_zero():
assert decide(None, 0)[0] == "skip"
def test_negative_real_total_is_floored_at_zero():
action, reason = decide(0, -4)
assert action == "skip"
assert reason == "total_sales already correct"
def test_net_quantity_reads_order_line_item():
assert net_quantity({"quantity": 3}) == 3
def test_net_quantity_reads_negative_refund_line_item():
assert net_quantity({"quantity": -2}) == -2
def test_net_quantity_defaults_to_zero_when_missing():
assert net_quantity({}) == 0
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, netQuantity } from "./recount-total-sales.js";
test("skip when totals match", () => {
assert.equal(decide(12, 12)[0], "skip");
});
test("fix when stored is lower than real", () => {
assert.equal(decide(3, 40)[0], "fix");
});
test("fix when stored is higher than real", () => {
assert.equal(decide(40, 3)[0], "fix");
});
test("fix when stored is missing", () => {
assert.equal(decide(null, 5)[0], "fix");
});
test("skip when stored is missing and real is zero", () => {
assert.equal(decide(null, 0)[0], "skip");
});
test("negative real total is floored at zero", () => {
const [action, reason] = decide(0, -4);
assert.equal(action, "skip");
assert.equal(reason, "total_sales already correct");
});
test("netQuantity reads order line item", () => {
assert.equal(netQuantity({ quantity: 3 }), 3);
});
test("netQuantity reads negative refund line item", () => {
assert.equal(netQuantity({ quantity: -2 }), -2);
});
test("netQuantity defaults to zero when missing", () => {
assert.equal(netQuantity({}), 0);
});
Case studies
The migrated shop where nothing was ever popular
A merchant moved three years of order history into a fresh WooCommerce install using a database import. The orders looked right, the revenue reports matched, but every product showed zero on the Popularity sort, because the import never fired the order status hooks that increment total_sales.
Running the recount script in dry run listed every product with real sales and the exact count it should have. Turning off dry run wrote the correct numbers in one pass, and the Popularity sort matched the revenue report from that day forward.
The seasonal item that never dropped in rank
A holiday product had a high return rate once the season ended, but total_sales kept counting every original sale and never subtracted the refunds. Months later it still ranked near the top of Popularity, pushing out items that were actually still selling.
The recount job, run nightly, picked up the accumulated refunds and adjusted the count down to the real net units sold. The seasonal item settled to its true rank within a day and stayed accurate as new refunds came in.
After this runs on a schedule, an import, a manual status change, or a wave of refunds is no longer a permanent dent in the catalog's ranking. The worst case becomes a short delay of up to a day before the next run corrects it. Keep it running even after you clean up the immediate cause, since imports and refunds will always happen again.
FAQ
Why does the Popularity sort show the wrong products first?
Popularity sorts by the total_sales number stored on each product, and that number only updates through WooCommerce's own order status hooks. Imported orders, statuses changed by another plugin or direct SQL, and refunds that never decrement the count all leave it wrong. A script that recounts total_sales from real paid orders and refunds fixes it.
Is it safe to rewrite total_sales with a script?
Yes, when the script computes the real count from actual order line items and refunds and only changes products whose stored number disagrees with that count. Start in dry run mode to review the list before it writes.
How often should the recount run?
Once a day is enough for most stores, since total_sales drift builds up slowly from imports, manual edits, and refunds rather than from normal checkout traffic. A nightly cron job keeps the Popularity sort trustworthy without extra load.
Related field notes
Citations
On the problem:
- WooCommerce core source: total_sales is updated inside order status change handling, tied to specific order events. github.com/woocommerce/woocommerce
- WooCommerce docs: how the store catalog Popularity ordering works and what it sorts by. woocommerce.com/document
- WooCommerce support forum: total_sales does not match real orders after an import or migration. wordpress.org/support/plugin/woocommerce
On the solution:
- WooCommerce REST API: list orders with status and date filters, and read refunds for an order. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: update a product, including writing the total_sales field. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: refund line items and their quantity and product fields. woocommerce.github.io/woocommerce-rest-api-docs
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 catalog sort?
If this saved you from a Popularity sort that misled shoppers, 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