Diagnostic WooCommerce core: tax, totals, and analytics

WooCommerce order stats wrong after an HPOS migration

The orders themselves look fine. Customers were charged, order pages show the right totals, and stock moved the way it should. But WooCommerce Analytics tells a different story: revenue is lower than it should be, order counts are off, and a handful of dates look strangely empty. This started right after the store moved to High-Performance Order Storage. Here is why the stats tables fall out of sync and a small script that finds every order they disagree on and rebuilds them safely.

Python and Node.js Runs once or on a schedule Safe by default (dry run)
Shallow focus photo of computer code
Photo by Shahadat Rahman on Unsplash
The short answer

WooCommerce Analytics does not read orders directly. It reads a separate set of stats tables that are only filled in when an order is saved through the normal WooCommerce hooks. During an HPOS migration, some orders get moved without ever re-triggering that save, so their stats row is missing, stale, or holding the wrong total. Run a small Python or Node.js script on a schedule that compares each real order to its Analytics report row and re-saves any order whose row is missing or wrong, which makes WooCommerce rebuild the stats the same way its own "Regenerate data" tool does. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce keeps two separate pictures of your orders. The first is the order itself: its status, its line items, its total, stored in wp_wc_orders once High-Performance Order Storage is turned on. The second is a set of lookup tables built just for Analytics, mainly wp_wc_order_stats, that Reports and the Analytics dashboard read from so a chart does not have to scan every order row every time it loads.

Those lookup tables are not the source of truth. They are a cache, and a cache only stays correct if something keeps updating it. Normally that something is a WooCommerce hook that fires whenever an order is saved. A migration to HPOS moves a large number of orders in one pass, often through a background sync process, and if that process is interrupted, runs before the analytics hooks are registered, or handles an order in a way that skips the usual save path, the stats row for that order is left behind. The order is fine. The chart is wrong.

HPOS migration orders move tables wp_wc_orders order data: correct stats not rebuilt wc_order_stats stale or missing rows Analytics reports wrong
The order data survives the migration intact. The separate stats cache that Analytics reads from does not always get rebuilt for every order, so the dashboard tells a different story than the real orders.

Why it happens

WooCommerce's own documentation on HPOS is upfront that the order tables and the analytics tables are two different things, kept in sync by hooks rather than by a single source. A few common reasons that sync breaks during or after a migration:

This is a well known side effect of the HPOS rollout. WooCommerce's own migration guide calls out that analytics tables need a separate resync step, and it is easy to skip that step or have it fail silently on a large store. See the citations at the end for the exact references.

The key insight

The order itself is the source of truth. If the real order says a total and a status, and the Analytics stats row disagrees or does not exist, the stats row is wrong, not the order. A resync script is a safety net that checks recent orders against their stats row and repairs only the ones a migration left behind.

The fix, as a flow

We do not touch order data at all, and we do not run a blanket store-wide regenerate. We walk recent orders, look up each one's Analytics report row through the REST API, and compare the two. If the row is missing, has a stale status, or a total that does not match, we re-save the order with its own current status. That one write is enough to make WooCommerce re-fire the hooks that rebuild the stats row, the same mechanism the built-in tool uses, just aimed at one order instead of the whole store.

List recent orders (REST API) Load Analytics report row for order Compare status and total to the order Row missing or stale? yes no, skip Re-save order rebuilds stats row
The script reads the truth from the order itself and only repairs the Analytics row when it is missing or disagrees. It never edits order data, so it cannot make a real order wrong.

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 orders. Create it under WooCommerce, Settings, Advanced, REST API. This script never touches Stripe or any payment processor, since the mismatch lives entirely between WooCommerce's own order data and its own stats tables. Keep every value in environment variables, never in the file.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
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 LOOKBACK_DAYS="90"
export DRY_RUN="true"   // start safe, change to false to write
2

List the orders worth checking

Page through orders created since your lookback window using the standard orders endpoint. There is no need to check every order ever placed, a migration only leaves a mark near the day it ran, so a window covering the last quarter is normally more than enough.

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 list_orders(lookback_days):
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=lookback_days)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"after": after, "per_page": 50, "page": page, "orderby": "date", "order": "asc"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        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* listOrders(lookbackDays) {
  const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}&orderby=date&order=asc`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Load the matching Analytics report row

The Analytics REST namespace, wc-analytics, exposes the same rows that power the Reports screens. Ask it for the row tied to one order ID. A missing row is a strong signal that this order was never picked up when the stats tables were rebuilt.

step3.py
def get_report_row(order_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc-analytics/reports/orders",
        params={"order_id": order_id, "per_page": 1},
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return None
    r.raise_for_status()
    rows = r.json()
    return rows[0] if rows else None
step3.js
async function wooAnalytics(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc-analytics${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo Analytics ${path} returned ${res.status}`);
  return res.json();
}

async function getReportRow(orderId) {
  const rows = await wooAnalytics(`/reports/orders?order_id=${orderId}&per_page=1`);
  return rows && rows.length ? rows[0] : null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and its report row and returns an action. A pure function like this has no network calls, so it is trivial to test with plain objects. The rule is simple. Orders in a status Analytics is not meant to count are skipped. A countable order missing its row is flagged. A row with a stale status or a total more than a cent off from the order is flagged. Otherwise the stats already match and nothing happens.

decide.py
COUNTED_STATUSES = {"processing", "completed", "on-hold", "refunded"}

def order_amount_minor(order):
    # Works for two decimal currencies.
    return round(float(order["total"]) * 100)

def report_amount_minor(report_row):
    return round(float(report_row.get("total_sales", 0)) * 100)

def decide(order, report_row):
    status = order["status"]
    if status not in COUNTED_STATUSES:
        return ("skip", "order status is not counted in Analytics")
    if report_row is None:
        return ("missing", "no Analytics stats row for a countable order")
    if report_row.get("status") != status:
        return ("resync", "stats row has a stale status")
    if abs(order_amount_minor(order) - report_amount_minor(report_row)) > 1:
        return ("resync", "stats row total does not match the order total")
    return ("ok", "stats row matches the order")
decide.js
const COUNTED_STATUSES = new Set(["processing", "completed", "on-hold", "refunded"]);

export function orderAmountMinor(order) {
  // Works for two decimal currencies.
  return Math.round(parseFloat(order.total) * 100);
}

export function reportAmountMinor(reportRow) {
  return Math.round(parseFloat((reportRow && reportRow.total_sales) || 0) * 100);
}

export function decide(order, reportRow) {
  const status = order.status;
  if (!COUNTED_STATUSES.has(status)) return ["skip", "order status is not counted in Analytics"];
  if (!reportRow) return ["missing", "no Analytics stats row for a countable order"];
  if (reportRow.status !== status) return ["resync", "stats row has a stale status"];
  if (Math.abs(orderAmountMinor(order) - reportAmountMinor(reportRow)) > 1) {
    return ["resync", "stats row total does not match the order total"];
  }
  return ["ok", "stats row matches the order"];
}
5

Rebuild the row by re-saving the order

There is no public REST endpoint that says "rebuild the stats row for order 501." What does exist is the same trick the built-in "Regenerate data" tool relies on internally: saving an order through the normal CRUD layer re-fires the hooks that write its stats row. We do that with the smallest possible write, setting the status to its own current value, so nothing about the order changes except that its cache gets rebuilt. Then we add a note so a shop manager can see why the order was touched.

apply.py
def touch_order(order):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": order["status"]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": "Analytics stats resynced after an HPOS migration mismatch. "
                      "Order data was untouched; only the stats lookup row was rebuilt."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function touchOrder(order) {
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: order.status }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Analytics stats resynced after an HPOS migration mismatch. " +
            "Order data was untouched; only the stats lookup row was rebuilt.",
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard and the small pause between writes. On the first run, leave DRY_RUN on so the script only reports what it would touch. Read the output, trust it, then switch it off. Since this is a one-time repair for most stores, running it manually once is usually enough, though it is just as safe to schedule for a store that regularly bulk-imports or bulk-edits orders.

Run it safe

Always start with DRY_RUN=true. Even though this script only ever re-saves a status an order already has, you still want to see the full list of affected orders before anything writes, so you can sanity check the count against what you already suspect is wrong.

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 skips every order whose stats row already matches.

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

resync_order_stats.py
"""Find WooCommerce orders whose Analytics stats disagree with the real order,
the classic symptom left behind by a High-Performance Order Storage (HPOS) migration,
and nudge each one back into sync. Read only by default. Run on a schedule or once
after a migration.
"""
import os
import time
import logging
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("resync_order_stats")

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

# Orders in these statuses should have a matching row in the analytics
# lookup tables with a total_sales greater than zero.
COUNTED_STATUSES = {"processing", "completed", "on-hold", "refunded"}


def order_amount_minor(order):
    """Order total in minor units (cents). Two decimal currencies only."""
    return round(float(order["total"]) * 100)


def report_amount_minor(report_row):
    """Analytics report row total in minor units (cents)."""
    return round(float(report_row.get("total_sales", 0)) * 100)


def decide(order, report_row):
    """Pure decision function. No I/O.

    order: dict from GET /wp-json/wc/v3/orders/{id}
    report_row: dict from GET /wp-json/wc-analytics/reports/orders?order_id={id}
                or None when no row exists for that order.

    Returns a tuple of (action, reason) where action is one of:
      "skip"   - order status is not one Analytics is expected to count
      "missing"- order should be counted but has no stats row at all
      "resync" - a stats row exists but disagrees with the real order
      "ok"     - the stats row matches the real order
    """
    status = order["status"]
    if status not in COUNTED_STATUSES:
        return ("skip", "order status is not counted in Analytics")

    if report_row is None:
        return ("missing", "no Analytics stats row for a countable order")

    if report_row.get("status") != status:
        return ("resync", "stats row has a stale status")

    if abs(order_amount_minor(order) - report_amount_minor(report_row)) > 1:
        return ("resync", "stats row total does not match the order total")

    return ("ok", "stats row matches the order")


def list_orders(lookback_days):
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=lookback_days)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"after": after, "per_page": 50, "page": page, "orderby": "date", "order": "asc"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def get_report_row(order_id):
    """The Analytics report line for one order, or None when it has no row yet."""
    r = requests.get(
        f"{WOO_URL}/wp-json/wc-analytics/reports/orders",
        params={"order_id": order_id, "per_page": 1},
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return None
    r.raise_for_status()
    rows = r.json()
    return rows[0] if rows else None


def touch_order(order):
    """Re-save the order through the CRUD layer so WooCommerce re-fires the hooks
    that rebuild its Analytics stats row. Setting the status to its own value is
    enough: it goes through wc_get_order()->save() on the way in, which is the
    same path the built-in "Regenerate data" tool uses under the hood, just for
    one order instead of the whole store.
    """
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"status": order["status"]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": "Analytics stats resynced after an HPOS migration mismatch. "
                      "Order data was untouched; only the stats lookup row was rebuilt."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    resynced = 0
    checked = 0
    for order in list_orders(LOOKBACK_DAYS):
        checked += 1
        report_row = get_report_row(order["id"])
        action, reason = decide(order, report_row)
        if action in ("skip", "ok"):
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would resync" if DRY_RUN else "resyncing")
        if not DRY_RUN:
            touch_order(order)
            time.sleep(0.2)  # be gentle with wp-cron and the stats rebuild queue
        resynced += 1
    log.info("Done. Checked %d order(s). %d %s.", checked, resynced, "to resync" if DRY_RUN else "resynced")


if __name__ == "__main__":
    run()
resync-order-stats.js
/**
 * Find WooCommerce orders whose Analytics stats disagree with the real order,
 * the classic symptom left behind by a High-Performance Order Storage (HPOS)
 * migration, and nudge each one back into sync. Read only by default. Run on
 * a schedule or once after a migration.
 *
 * Guide: https://www.allanninal.dev/woocommerce/order-stats-wrong-after-hpos-migration/
 */
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 || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Orders in these statuses should have a matching row in the analytics
// lookup tables with a total_sales greater than zero.
const COUNTED_STATUSES = new Set(["processing", "completed", "on-hold", "refunded"]);

export function orderAmountMinor(order) {
  // Two decimal currencies only.
  return Math.round(parseFloat(order.total) * 100);
}

export function reportAmountMinor(reportRow) {
  return Math.round(parseFloat((reportRow && reportRow.total_sales) || 0) * 100);
}

/**
 * Pure decision function. No I/O.
 *
 * order: object from GET /wp-json/wc/v3/orders/{id}
 * reportRow: object from GET /wp-json/wc-analytics/reports/orders?order_id={id}
 *            or null when no row exists for that order.
 *
 * Returns [action, reason] where action is one of:
 *   "skip"    - order status is not one Analytics is expected to count
 *   "missing" - order should be counted but has no stats row at all
 *   "resync"  - a stats row exists but disagrees with the real order
 *   "ok"      - the stats row matches the real order
 */
export function decide(order, reportRow) {
  const status = order.status;
  if (!COUNTED_STATUSES.has(status)) {
    return ["skip", "order status is not counted in Analytics"];
  }
  if (!reportRow) {
    return ["missing", "no Analytics stats row for a countable order"];
  }
  if (reportRow.status !== status) {
    return ["resync", "stats row has a stale status"];
  }
  if (Math.abs(orderAmountMinor(order) - reportAmountMinor(reportRow)) > 1) {
    return ["resync", "stats row total does not match the order total"];
  }
  return ["ok", "stats row matches the order"];
}

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 wooAnalytics(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc-analytics${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo Analytics ${path} returned ${res.status}`);
  return res.json();
}

async function* listOrders(lookbackDays) {
  const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}&orderby=date&order=asc`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function getReportRow(orderId) {
  const rows = await wooAnalytics(`/reports/orders?order_id=${orderId}&per_page=1`);
  return rows && rows.length ? rows[0] : null;
}

async function touchOrder(order) {
  // Re-save the order through the CRUD layer so WooCommerce re-fires the hooks
  // that rebuild its Analytics stats row. Setting the status to its own value
  // is enough: it is the same path the built-in "Regenerate data" tool uses
  // under the hood, just for one order instead of the whole store.
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ status: order.status }),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Analytics stats resynced after an HPOS migration mismatch. " +
            "Order data was untouched; only the stats lookup row was rebuilt.",
    }),
  });
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function run() {
  let resynced = 0;
  let checked = 0;
  for await (const order of listOrders(LOOKBACK_DAYS)) {
    checked++;
    const reportRow = await getReportRow(order.id);
    const [action, reason] = decide(order, reportRow);
    if (action === "skip" || action === "ok") continue;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would resync" : "resyncing"}`);
    if (!DRY_RUN) {
      await touchOrder(order);
      await sleep(200); // be gentle with wp-cron and the stats rebuild queue
    }
    resynced++;
  }
  console.log(`Done. Checked ${checked} order(s). ${resynced} ${DRY_RUN ? "to resync" : "resynced"}.`);
}

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 orders get re-saved and which are left alone. Because we kept decide pure, the test needs no network and no live store. It just feeds in plain objects and checks the action.

test_hpos_resync_decide.py
from resync_order_stats import decide, order_amount_minor, report_amount_minor


def order(**over):
    base = {"id": 501, "status": "processing", "total": "50.00"}
    base.update(over)
    return base


def report_row(**over):
    base = {"order_id": 501, "status": "processing", "total_sales": "50.00"}
    base.update(over)
    return base


def test_ok_when_row_matches_order():
    assert decide(order(), report_row())[0] == "ok"


def test_missing_when_no_stats_row_for_countable_order():
    assert decide(order(), None)[0] == "missing"


def test_resync_when_status_is_stale():
    assert decide(order(status="completed"), report_row(status="processing"))[0] == "resync"


def test_resync_when_total_mismatches():
    assert decide(order(total="80.00"), report_row(total_sales="50.00"))[0] == "resync"


def test_skip_when_status_not_counted():
    assert decide(order(status="pending"), None)[0] == "skip"


def test_skip_when_cancelled_even_with_stale_row():
    # A cancelled order should never be counted, no matter what the leftover row says.
    assert decide(order(status="cancelled"), report_row())[0] == "skip"
resync-order-stats.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./resync-order-stats.js";

const order = (over = {}) => ({ id: 501, status: "processing", total: "50.00", ...over });
const reportRow = (over = {}) => ({ order_id: 501, status: "processing", total_sales: "50.00", ...over });

test("ok when row matches order", () => {
  assert.equal(decide(order(), reportRow())[0], "ok");
});

test("missing when no stats row for countable order", () => {
  assert.equal(decide(order(), null)[0], "missing");
});

test("resync when status is stale", () => {
  assert.equal(decide(order({ status: "completed" }), reportRow({ status: "processing" }))[0], "resync");
});

test("resync when total mismatches", () => {
  assert.equal(decide(order({ total: "80.00" }), reportRow({ total_sales: "50.00" }))[0], "resync");
});

test("skip when status not counted", () => {
  assert.equal(decide(order({ status: "pending" }), null)[0], "skip");
});

test("skip when cancelled even with a stale row", () => {
  assert.equal(decide(order({ status: "cancelled" }), reportRow())[0], "skip");
});

Case studies

Interrupted migration

The store where the migrator timed out overnight

A shop with about sixty thousand orders enabled HPOS and let the background migrator run overnight on a shared host with tight execution limits. It finished moving the order data, but the process that was meant to also rebuild Analytics stopped silently partway through, leaving roughly eight percent of older orders with no stats row at all.

The revenue chart looked like sales had dropped sharply around a date that was really just where the migrator gave up. Running the script in dry run listed exactly the missing order IDs, all from before that date, which confirmed the theory in minutes instead of a long support back and forth.

Bulk import

The catalog migration that skipped the hooks

A store moving off an old platform imported thousands of historical orders directly through a database script to save time, well after HPOS was already active. The orders displayed correctly, but since they never passed through WooCommerce's own save method, Analytics never counted a single one of them.

The script flagged every imported order as missing on its first run. The team reviewed the dry run output, confirmed the count matched their import batch exactly, then let it write for real, and the historical revenue numbers lined up with their old platform's export.

What good looks like

After this runs once, Analytics and the real orders agree again, and you have a clear log of exactly which orders were touched and why. Keep the script around even after the immediate mismatch is fixed. It costs nothing to run again after a future bulk import or another platform move, and it will always tell you the truth before it changes anything.

FAQ

Why did my WooCommerce Analytics stats break after moving to HPOS?

HPOS changed where orders live, but the Analytics reports read from separate stats tables that are only filled in when an order is saved through the normal WooCommerce hooks. Orders created or changed around the time of the migration can be missing from those tables or left with a stale total, so the reports disagree with the real orders.

Is it safe to run a script that touches every order just to fix stats?

Yes, when the script only re-saves the order's existing status rather than changing any order data, and it skips every order whose stats already match. Start in dry run mode so you can see the exact list of affected orders before anything writes.

Do I still need this if I already ran WooCommerce's built-in Regenerate data tool?

Usually not, but that tool can time out or get interrupted on a large catalog, and it does not tell you which specific orders were missed. This script is a smaller, targeted check you can run afterward, or on a schedule, to confirm every order's stats really match.

Related field notes

Citations

On the problem:

  1. WooCommerce developer docs: High-Performance Order Storage overview and how order data and analytics tables relate. developer.woocommerce.com/docs/hpos-overview
  2. WooCommerce docs: Analytics reports and how the stats lookup tables are built from order data. woocommerce.com/document/woocommerce-analytics
  3. WooCommerce developer docs: the "Regenerate data" tool for rebuilding Analytics reports after data changes. developer.woocommerce.com/docs/analytics-data-updates

On the solution:

  1. WooCommerce REST API: retrieve and update an order, including setting its status. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce Analytics REST endpoints: the reports namespace used by the Analytics dashboard. github.com/woocommerce/woocommerce (Admin API Reports)
  3. WooCommerce developer blog: notes on HPOS migration steps and keeping analytics in sync during a store move. developer.woocommerce.com (HPOS general availability)

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 reports?

If this saved you a confusing afternoon staring at a revenue chart that made no sense, 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