Repair WooCommerce core: products and catalog

WooCommerce product rating count drifts from the real reviews

A shopper opens a product and sees "4.8 out of 5 (312 reviews)" at the top, then scrolls down and counts a fraction of that many reviews with stars on them. The number at the top is not a live count. It is a cached figure WooCommerce keeps on the product, and every so often that cache falls out of step with what is actually approved. Here is why it drifts and a small script that recomputes it from the real reviews and only touches the products that are actually wrong.

Python and Node.js Runs on a schedule or after an import Safe by default (dry run)
Best rates led signage
Photo by Jon Cellier on Unsplash
The short answer

The star rating and review count shown on a product are cached numbers, stored as _wc_average_rating and _wc_review_count postmeta, not a live count of reviews. A bulk import, a moderation plugin, or a direct database edit can rewrite reviews without rebuilding that cache, so the product keeps showing the old count and average. Run a small Python or Node.js script on a schedule that reads every approved review with a star rating through the WooCommerce REST API, recomputes the true count and average, and rewrites the cache only on products where it disagrees. Full code, tests, and a dry run guard are below.

The problem in plain words

When a review comes in and gets approved, WooCommerce is supposed to walk every approved review on that product, add up the stars, and save two numbers back onto the product: how many reviews carry a rating, and what the average of those stars is. That save step is the part that can be skipped.

A CSV import that inserts reviews straight into the comments table, a moderation plugin that approves a batch of held reviews in one query, or a manual edit in the database can all add or remove reviews without ever calling the WooCommerce function that rebuilds the cache. The reviews are correct. The product's cached rating is not. The number climbs stale.

Reviews imported or bulk approved Comments table updated directly cache rebuild skipped Rating stale _wc_average_rating Wrong count shown to shoppers
The reviews themselves are fine. The cached count and average on the product were never rebuilt to match them.

Why it happens

WooCommerce's own review handling calls a recount function whenever a review changes status through the normal admin screens or REST API. Skip that path and the cache is left behind. A few common ways that happens:

This shows up most after a store moves reviews in bulk, whether that is a fresh import, a big spam cleanup, or a platform migration. The rating shown to shoppers on the storefront and in rich snippets keeps reporting the old numbers until something forces a recount.

The key insight

The approved reviews are the source of truth. If you add up the star ratings on every review with status approved for a product, that total is correct by definition. The rating_count and average_rating the product reports are only a cache of that total, and a cache can always be wrong. A recompute script treats the reviews as ground truth and repairs the cache to match.

The fix, as a flow

We do not touch the reviews themselves. We add a job that walks published products, asks the WooCommerce REST API for every approved review with a star rating on each one, and adds those stars up. If the real count or average disagrees with what the product currently reports, we write the corrected numbers back onto the product using the same fields WooCommerce itself uses to cache the rating.

Scheduled job daily or after import List published products Fetch approved reviews with a rating Cache disagrees? yes no, skip Recompute cache write count + average
The recompute job treats approved reviews as the truth and only writes the products whose cached rating does not match that truth.

Build it step by step

1

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. Create it under WooCommerce, Settings, Advanced, REST API. This fix never touches Stripe or any payment data, it only reads reviews and writes product meta, so keep the key scoped to what it needs.

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

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
2

List products and their approved reviews

Page through published products, then for each one page through its approved reviews using the product and status=approved filters on the reviews endpoint. A review with no star rating, a plain comment, does not count towards the total, the same rule WooCommerce itself applies when it rebuilds the cache.

step2.py
import requests
from requests.auth import HTTPBasicAuth

WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")

def approved_reviews_for(product_id, per_page=100):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products/reviews",
            params={"product": product_id, "status": "approved", "per_page": per_page, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for review in batch:
            yield review
        page += 1
step2.js
async function* approvedReviewsFor(productId, perPage = 100) {
  let page = 1;
  while (true) {
    const batch = await woo(
      `/products/reviews?product=${productId}&status=approved&per_page=${perPage}&page=${page}`
    );
    if (!batch.length) return;
    for (const review of batch) yield review;
    page++;
  }
}
3

Add up the real count and average

Fold the approved reviews into a count and an average, keeping only reviews that carry a star rating. This is a small, pure calculation with no network calls, which makes it trivial to test on its own.

stats.py
def real_rating_stats(reviews):
    rated = [r["rating"] for r in reviews if r.get("rating")]
    count = len(rated)
    if count == 0:
        return 0, 0.0
    average = sum(rated) / count
    return count, round(average, 2)
stats.js
export function realRatingStats(reviews) {
  const rated = reviews.map((r) => r.rating).filter((r) => Boolean(r));
  const count = rated.length;
  if (count === 0) return { count: 0, average: 0 };
  const average = Math.round((rated.reduce((a, b) => a + b, 0) / count) * 100) / 100;
  return { count, average };
}
4

Decide, with one pure function

Keep the decision in its own function that takes the product and the real count and average, then 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 numbers already match the real reviews within a small rounding tolerance, skip it. Otherwise, recompute.

decide.py
RATING_TOLERANCE = 0.05

def decide(product, real_count, real_average):
    stored_count = int(product.get("rating_count") or 0)
    stored_average = float(product.get("average_rating") or 0)

    if stored_count == real_count and abs(stored_average - real_average) <= RATING_TOLERANCE:
        return ("skip", "rating count and average already match approved reviews")

    if stored_count != real_count:
        return (
            "recompute",
            f"rating_count is {stored_count} but {real_count} approved review(s) have a star rating",
        )

    return (
        "recompute",
        f"average_rating is {stored_average} but real average is {real_average}",
    )
decide.js
const RATING_TOLERANCE = 0.05;

export function decide(product, realCount, realAverage) {
  const storedCount = Number(product.rating_count || 0);
  const storedAverage = Number(product.average_rating || 0);

  if (storedCount === realCount && Math.abs(storedAverage - realAverage) <= RATING_TOLERANCE) {
    return ["skip", "rating count and average already match approved reviews"];
  }

  if (storedCount !== realCount) {
    return [
      "recompute",
      `rating_count is ${storedCount} but ${realCount} approved review(s) have a star rating`,
    ];
  }

  return ["recompute", `average_rating is ${storedAverage} but real average is ${realAverage}`];
}
5

Write the corrected cache the way WooCommerce reads it

When the action is recompute, write the corrected numbers back as product meta using the same keys WooCommerce itself uses to cache the rating: _wc_review_count, _wc_rating_count, and _wc_average_rating. Writing through the REST API's meta_data field means the change shows up immediately on the storefront and in structured data without needing a manual recount in the admin.

apply.py
def apply_recount(product_id, real_count, real_average):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={
            "meta_data": [
                {"key": "_wc_review_count", "value": str(real_count)},
                {"key": "_wc_rating_count", "value": str(real_count)},
                {"key": "_wc_average_rating", "value": f"{real_average:.2f}"},
            ]
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function applyRecount(productId, realCount, realAverage) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_wc_review_count", value: String(realCount) },
        { key: "_wc_rating_count", value: String(realCount) },
        { key: "_wc_average_rating", value: realAverage.toFixed(2) },
      ],
    }),
  });
}
6

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 the products it would recompute. Read the output, trust it, then switch it off to let it write. Run it once a day, or right after a bulk import or a moderation cleanup.

Run it safe

Always start with DRY_RUN=true. This script writes to real product data that feeds star rating rich snippets in search results, so you want to see its plan before it acts. Once the report looks right, turn it off.

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 product whose rating already matches its approved reviews.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

recount_ratings.py
"""Recompute a product's rating average and count from real approved reviews.

WooCommerce caches a product's rating in two places: the `average_rating` and
`rating_count` fields the REST API reports, backed by the `_wc_average_rating`
and `_wc_review_count` (also read as `_wc_rating_count`) postmeta. That cache
is meant to be rebuilt every time a review is approved, held, or deleted, but a
bulk import, a moderation plugin, a direct database edit, or a crash mid
request can leave it stale. Support then sees a product showing "4.8 (312)"
while the actual approved reviews with a star rating add up to something else
entirely.

This script walks products, asks the WooCommerce REST API for every approved
review that carries a rating, recomputes the true average and count, and
compares that against what the product currently reports. When it disagrees
it writes the corrected numbers back as product meta, the same fields
WooCommerce itself uses to cache the count. Read only by default. Safe to run
again and again, since a product that is already correct is left untouched.
"""
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("recount_ratings")

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"),
)
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Anything above this is not worth flagging, floating point rounding on the
# stored average is common and is not a real drift.
RATING_TOLERANCE = 0.05


def real_rating_stats(reviews):
    """Fold a list of approved review objects into (count, average).

    Only reviews that carry a star rating of 1 to 5 count towards the total.
    A review left with no rating (a plain comment) does not move the average,
    the same rule WooCommerce itself applies when it rebuilds the cache.
    """
    rated = [r["rating"] for r in reviews if r.get("rating")]
    count = len(rated)
    if count == 0:
        return 0, 0.0
    average = sum(rated) / count
    return count, round(average, 2)


def decide(product, real_count, real_average):
    """Pure decision: does this product's cached rating need a rewrite?

    Returns a tuple of (action, reason). action is one of:
      "skip"      - the cached numbers already match the real reviews
      "recompute" - the cache is stale and should be corrected
    No I/O happens here, so this is fully unit testable with plain dicts.
    """
    stored_count = int(product.get("rating_count") or 0)
    stored_average = float(product.get("average_rating") or 0)

    if stored_count == real_count and abs(stored_average - real_average) <= RATING_TOLERANCE:
        return ("skip", "rating count and average already match approved reviews")

    if stored_count != real_count:
        return (
            "recompute",
            f"rating_count is {stored_count} but {real_count} approved review(s) have a star rating",
        )

    return (
        "recompute",
        f"average_rating is {stored_average} but real average is {real_average}",
    )


def list_products(per_page=50):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products",
            params={"per_page": per_page, "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 approved_reviews_for(product_id, per_page=100):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/products/reviews",
            params={"product": product_id, "status": "approved", "per_page": per_page, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for review in batch:
            yield review
        page += 1


def apply_recount(product_id, real_count, real_average):
    """Write the corrected numbers using the same postmeta WooCommerce reads."""
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={
            "meta_data": [
                {"key": "_wc_review_count", "value": str(real_count)},
                {"key": "_wc_rating_count", "value": str(real_count)},
                {"key": "_wc_average_rating", "value": f"{real_average:.2f}"},
            ]
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for product in list_products():
        reviews = list(approved_reviews_for(product["id"]))
        real_count, real_average = real_rating_stats(reviews)
        action, reason = decide(product, real_count, real_average)
        if action == "skip":
            continue
        log.info(
            "Product %s (%s): %s. %s",
            product["id"], product.get("name", ""), reason, "would recompute" if DRY_RUN else "recomputing",
        )
        if not DRY_RUN:
            apply_recount(product["id"], real_count, real_average)
        fixed += 1
    log.info("Done. %d product(s) %s.", fixed, "to recompute" if DRY_RUN else "recomputed")


if __name__ == "__main__":
    run()
recount-ratings.js
/**
 * Recompute a product's rating average and count from real approved reviews.
 *
 * WooCommerce caches a product's rating in two places: the `average_rating`
 * and `rating_count` fields the REST API reports, backed by the
 * `_wc_average_rating` and `_wc_review_count` (also read as
 * `_wc_rating_count`) postmeta. That cache is meant to be rebuilt every time
 * a review is approved, held, or deleted, but a bulk import, a moderation
 * plugin, a direct database edit, or a crash mid request can leave it stale.
 * Support then sees a product showing "4.8 (312)" while the actual approved
 * reviews with a star rating add up to something else entirely.
 *
 * This script walks products, asks the WooCommerce REST API for every
 * approved review that carries a rating, recomputes the true average and
 * count, and compares that against what the product currently reports. When
 * it disagrees it writes the corrected numbers back as product meta, the
 * same fields WooCommerce itself uses to cache the count. Read only by
 * default. Safe to run again and again, since a product that is already
 * correct is left untouched.
 *
 * Guide: https://www.allanninal.dev/woocommerce/ratings-count-drifts-from-real-reviews/
 */
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Anything above this is not worth flagging, floating point rounding on the
// stored average is common and is not a real drift.
const RATING_TOLERANCE = 0.05;

/**
 * Fold a list of approved review objects into { count, average }.
 *
 * Only reviews that carry a star rating of 1 to 5 count towards the total.
 * A review left with no rating (a plain comment) does not move the average,
 * the same rule WooCommerce itself applies when it rebuilds the cache.
 */
export function realRatingStats(reviews) {
  const rated = reviews.map((r) => r.rating).filter((r) => Boolean(r));
  const count = rated.length;
  if (count === 0) return { count: 0, average: 0 };
  const average = Math.round((rated.reduce((a, b) => a + b, 0) / count) * 100) / 100;
  return { count, average };
}

/**
 * Pure decision: does this product's cached rating need a rewrite?
 *
 * Returns ["skip" | "recompute", reason]. No I/O happens here, so this is
 * fully unit testable with plain objects.
 */
export function decide(product, realCount, realAverage) {
  const storedCount = Number(product.rating_count || 0);
  const storedAverage = Number(product.average_rating || 0);

  if (storedCount === realCount && Math.abs(storedAverage - realAverage) <= RATING_TOLERANCE) {
    return ["skip", "rating count and average already match approved reviews"];
  }

  if (storedCount !== realCount) {
    return [
      "recompute",
      `rating_count is ${storedCount} but ${realCount} approved review(s) have a star rating`,
    ];
  }

  return ["recompute", `average_rating is ${storedAverage} but real average is ${realAverage}`];
}

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* listProducts(perPage = 50) {
  let page = 1;
  while (true) {
    const batch = await woo(`/products?per_page=${perPage}&page=${page}&status=publish`);
    if (!batch.length) return;
    for (const product of batch) yield product;
    page++;
  }
}

async function* approvedReviewsFor(productId, perPage = 100) {
  let page = 1;
  while (true) {
    const batch = await woo(
      `/products/reviews?product=${productId}&status=approved&per_page=${perPage}&page=${page}`
    );
    if (!batch.length) return;
    for (const review of batch) yield review;
    page++;
  }
}

async function applyRecount(productId, realCount, realAverage) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_wc_review_count", value: String(realCount) },
        { key: "_wc_rating_count", value: String(realCount) },
        { key: "_wc_average_rating", value: realAverage.toFixed(2) },
      ],
    }),
  });
}

export async function run() {
  let fixed = 0;
  for await (const product of listProducts()) {
    const reviews = [];
    for await (const review of approvedReviewsFor(product.id)) reviews.push(review);
    const { count: realCount, average: realAverage } = realRatingStats(reviews);
    const [action, reason] = decide(product, realCount, realAverage);
    if (action === "skip") continue;
    console.log(
      `Product ${product.id} (${product.name || ""}): ${reason}. ${DRY_RUN ? "would recompute" : "recomputing"}`
    );
    if (!DRY_RUN) await applyRecount(product.id, realCount, realAverage);
    fixed++;
  }
  console.log(`Done. ${fixed} product(s) ${DRY_RUN ? "to recompute" : "recomputed"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which products get rewritten and which are left alone. Because we kept decide and real_rating_stats pure, the tests need no network and no live store. They just feed in plain objects and check the outcome.

test_ratings_decide.py
from recount_ratings import decide, real_rating_stats


def review(rating):
    return {"rating": rating}


def test_skip_when_count_and_average_match():
    product = {"rating_count": 3, "average_rating": "4.33"}
    reviews = [review(4), review(4), review(5)]
    count, average = real_rating_stats(reviews)
    assert decide(product, count, average)[0] == "skip"


def test_recompute_when_count_is_stale():
    product = {"rating_count": 312, "average_rating": "4.8"}
    reviews = [review(5), review(4)]
    count, average = real_rating_stats(reviews)
    action, reason = decide(product, count, average)
    assert action == "recompute"
    assert "rating_count" in reason


def test_recompute_when_average_is_stale_but_count_matches():
    product = {"rating_count": 2, "average_rating": "5.0"}
    reviews = [review(1), review(1)]
    count, average = real_rating_stats(reviews)
    action, reason = decide(product, count, average)
    assert action == "recompute"
    assert "average_rating" in reason


def test_skip_within_rounding_tolerance():
    product = {"rating_count": 3, "average_rating": "4.3"}
    reviews = [review(4), review(4), review(5)]
    count, average = real_rating_stats(reviews)
    assert decide(product, count, average)[0] == "skip"
recount-ratings.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, realRatingStats } from "./recount-ratings.js";

const review = (rating) => ({ rating });

test("skip when count and average match", () => {
  const product = { rating_count: 3, average_rating: "4.33" };
  const { count, average } = realRatingStats([review(4), review(4), review(5)]);
  assert.equal(decide(product, count, average)[0], "skip");
});

test("recompute when count is stale", () => {
  const product = { rating_count: 312, average_rating: "4.8" };
  const { count, average } = realRatingStats([review(5), review(4)]);
  const [action, reason] = decide(product, count, average);
  assert.equal(action, "recompute");
  assert.match(reason, /rating_count/);
});

test("recompute when average is stale but count matches", () => {
  const product = { rating_count: 2, average_rating: "5.0" };
  const { count, average } = realRatingStats([review(1), review(1)]);
  const [action, reason] = decide(product, count, average);
  assert.equal(action, "recompute");
  assert.match(reason, /average_rating/);
});

test("skip within rounding tolerance", () => {
  const product = { rating_count: 3, average_rating: "4.3" };
  const { count, average } = realRatingStats([review(4), review(4), review(5)]);
  assert.equal(decide(product, count, average)[0], "skip");
});

Case studies

Bulk import

The migration that brought reviews but not the cache

A store moved from another platform and imported five years of reviews through a CSV tool that wrote straight into the comments table. Every review displayed correctly on each product page, but the star rating and count in the shop grid and in search results kept showing zero, because the import never rebuilt the rating cache.

The recompute script ran once across the whole catalog in dry run, listed a few thousand products that needed a rewrite, then ran for real overnight. By morning every product's rating matched its reviews.

Spam cleanup

The moderation sweep that under counted everything

A store used a spam filter plugin that bulk deleted hundreds of fake reviews directly from the database during a cleanup. Real reviews were untouched, but several genuine products lost a few of their legitimate reviews in the same sweep and their rating count no longer matched what was left.

Running the script on a daily schedule caught the drift the next morning, recomputed the handful of affected products, and kept catching similar drops after every future cleanup without anyone needing to remember to run it by hand.

What good looks like

After this runs on a schedule, an import or a moderation sweep can no longer leave a product's rating quietly wrong for months. The worst case becomes a short delay of a day before the recompute job catches the drift and fixes it. Keep it running even after a one time cleanup, since a fresh import or a new plugin can reintroduce the same drift later.

FAQ

Why does my product show a rating count that does not match the reviews I can see?

WooCommerce stores the star rating and count as cached numbers on the product, not as a live count of reviews. That cache is supposed to rebuild every time a review is approved, held, or deleted, but a bulk import, a moderation plugin, or a direct database edit can leave it stale. A script that recomputes the real count and average from approved reviews and rewrites the cache fixes it.

Is it safe to rewrite a product's rating with a script?

Yes, when the script only recomputes from reviews that are actually approved and only writes a product whose cached numbers disagree with that real count and average. Start in dry run mode to see the list of affected products before anything is written.

How often should the ratings recompute run?

Once a day is enough for most stores, or right after a bulk import or a review moderation cleanup. It only rewrites products whose numbers are actually wrong, so running it more often causes no harm.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: how product reviews and ratings work, including how the average rating is calculated. woocommerce.com/document/product-reviews
  2. WooCommerce core reference: the review count and rating meta keys read by the storefront and the REST API. woocommerce.github.io/code-reference
  3. WordPress support: reports of star ratings and review counts not updating after a bulk import or migration. wordpress.org/support/plugin/woocommerce

On the solution:

  1. WooCommerce REST API: list product reviews with a status filter. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: update a product, including its meta_data fields. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce docs: the REST API authentication needed to read and write products and reviews. 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.

Contact me on LinkedIn

Did this fix your product ratings?

If this saved you from a catalog full of wrong star ratings, 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 field notes