Diagnostic WooCommerce core: HPOS and housekeeping
Legacy order rows survive after HPOS cleanup
You turned on High-Performance Order Storage, waited for the sync to finish, and switched off compatibility mode. Months later you notice the old order posts are still sitting in wp_posts, unused and untouched, as if the cleanup never ran. This happens more than WooCommerce's own documentation lets on. Here is why it happens and a small script that finds every leftover row and reports it, safely, for cleanup.
HPOS moves each order into new tables and is supposed to remove the matching legacy shop_order post once the order is fully synced and compatibility mode is off. That removal step can be interrupted, skipped for some orders, or left unfinished on a large catalog, so a chunk of old rows stay behind forever. Run a small Python or Node.js script on a schedule that reads the legacy post id each HPOS order remembers, confirms the order is settled (cross-checking Stripe for paid orders), and reports every legacy row that is safe to remove. It never deletes anything itself. Full code, tests, and a dry run guard are below.
The problem in plain words
Before HPOS, a WooCommerce order was just a post. It lived in wp_posts with the type shop_order, and all its details sat in wp_postmeta next to it. High-Performance Order Storage replaces that with dedicated order tables built for the job, which is faster and does not fight with the rest of your content for space in the posts table.
To make the switch safe, WooCommerce keeps both copies in sync for a while through compatibility mode, then offers a cleanup step that deletes the old post rows once every order has a confirmed copy in the new tables. That cleanup is the part that quietly falls short. It can time out on a large store, skip orders that a plugin touched in a way HPOS did not expect, or simply never get triggered if the store owner turned off compatibility mode without running the cleanup tool first. The order works fine either way. The old row just keeps existing, unused, forever.
Why it happens
WooCommerce's own HPOS documentation is honest that cleanup is a separate, optional step from the migration itself, and it depends on background processing to finish. A few common reasons legacy rows outlive it:
- The store disabled compatibility mode as soon as the migration screen said "done," without running or waiting for the "Delete the legacy data" cleanup tool.
- The cleanup batch job hit a host's execution time limit or memory cap partway through a large order table and never resumed.
- A plugin still writes to
wp_postmetafor order-related data outside HPOS's own sync hooks, so WooCommerce treats those orders as not fully migrated and skips them during cleanup out of caution. - Refund posts (
shop_order_refund) are migrated alongside their parent order but are easy for a custom cleanup script to miss if it only looks forshop_order.
This is a known gap in the HPOS rollout rather than a one-off bug. WooCommerce's developer documentation calls out that the legacy data cleanup is manual and can be re-run, which only makes sense if the first pass is expected to leave some rows behind. See the citations at the end for the exact references.
The HPOS order table is the source of truth once compatibility mode is off. A wp_posts row with the same id is not a second order, it is a leftover shell. The only question worth asking about it is whether the real order behind it is fully settled, because that is what makes the leftover safe to remove.
The fix, as a flow
We never delete anything directly from a script that touches production data by guesswork. Instead we add a job that walks HPOS orders, checks whether each one still has a legacy post id attached, confirms with Stripe that a paid order is genuinely finished, and writes a report so a human (or WooCommerce's own cleanup tool) can safely remove what is left.
Build it step by step
Get access to both systems
You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read access to orders, since paid orders are cross-checked against Stripe before anything is reported. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DRY_RUN="true" # start safe, change to false to write a report note
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 LOOKBACK_DAYS="90"
export DRY_RUN="true" // start safe, change to false to write a report note
List HPOS orders and read the legacy post id
When HPOS migrates an order, it saves the old post id back onto the order as meta, usually under _legacy_order_id. That single field is the bridge between the new order and whatever row might still be sitting in wp_posts. We page through orders from the REST API and pull that id out wherever it exists.
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 legacy_post_id_of(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_legacy_order_id" and meta.get("value"):
try:
return int(meta["value"])
except (TypeError, ValueError):
return None
return None
def hpos_orders():
page = 1
while True:
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders",
params={"per_page": 50, "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
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");
function legacyPostIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_legacy_order_id" && meta.value) {
const id = parseInt(meta.value, 10);
return Number.isNaN(id) ? null : id;
}
}
return null;
}
async function* hposOrders() {
let page = 1;
while (true) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3/orders?per_page=50&page=${page}`,
{ headers: { Authorization: AUTH } });
const batch = await res.json();
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Check whether the legacy row still exists
Use the small REST helper that reads a post by id and tells you its type, so you know whether the id is still a leftover shop_order post, has already been cleaned up, or was reused for something unrelated after WordPress recycled the id. Treat a missing row as already clean, and never touch a row whose type is not an order post.
def get_legacy_post(post_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/legacy-post/{post_id}",
auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json() # {"id": 9001, "post_type": "shop_order"}
async function getLegacyPost(postId) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3/orders/legacy-post/${postId}`,
{ headers: { Authorization: AUTH } });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo legacy-post lookup returned ${res.status}`);
return res.json(); // { id: 9001, post_type: "shop_order" }
}
Decide, with one pure function
Keep the decision in its own function that takes the order, the legacy post (or null), and the Stripe PaymentIntent (or null), and returns an action. A pure function like this is easy to read and easy to test, which we do later. Skip open orders outright, skip rows already gone, skip anything the post id was reused for, and only report a row once Stripe agrees the money side is finished and the amount lines up.
SETTLED_INTENT_STATUSES = {"succeeded", "canceled"}
OPEN_ORDER_STATUSES = {"pending", "on-hold", "processing"}
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, legacy_post, intent):
legacy_id = legacy_post_id_of(order) if order is not None else None
if not legacy_id:
return ("skip", "order has no legacy post id, nothing to check")
if legacy_post is None:
return ("clean", "legacy row already gone, nothing left to do")
if legacy_post.get("post_type") not in ("shop_order", "shop_order_refund"):
return ("skip", "post id is reused by unrelated content, leave it alone")
if order["status"] in OPEN_ORDER_STATUSES:
return ("skip", "order is still open, keep both rows until it settles")
if intent is not None and intent.get("status") not in SETTLED_INTENT_STATUSES:
return ("skip", "Stripe still has the payment in progress")
if intent is not None and abs(order_amount_minor(order) - intent.get("amount_received", intent.get("amount", 0))) > 1:
return ("mismatch", "Stripe amount does not match the order, needs a human look")
return ("report", "HPOS order is settled and the legacy row is a safe cleanup candidate")
const SETTLED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);
const OPEN_ORDER_STATUSES = new Set(["pending", "on-hold", "processing"]);
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, legacyPost, intent) {
const legacyId = order ? legacyPostIdOf(order) : null;
if (!legacyId) return ["skip", "order has no legacy post id, nothing to check"];
if (!legacyPost) return ["clean", "legacy row already gone, nothing left to do"];
if (!["shop_order", "shop_order_refund"].includes(legacyPost.post_type)) {
return ["skip", "post id is reused by unrelated content, leave it alone"];
}
if (OPEN_ORDER_STATUSES.has(order.status)) {
return ["skip", "order is still open, keep both rows until it settles"];
}
if (intent && !SETTLED_INTENT_STATUSES.has(intent.status)) {
return ["skip", "Stripe still has the payment in progress"];
}
if (intent) {
const received = intent.amount_received ?? intent.amount ?? 0;
if (Math.abs(orderAmountMinor(order) - received) > 1) {
return ["mismatch", "Stripe amount does not match the order, needs a human look"];
}
}
return ["report", "HPOS order is settled and the legacy row is a safe cleanup candidate"];
}
Report, never delete
When the action is report, write an order note that names the legacy post id and the reason it is safe to remove. The note is the deliverable. Deleting the actual wp_posts row is left to WooCommerce's own legacy data cleanup tool, which knows how to remove an order post along with every bit of its related meta correctly.
def report(order, legacy_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"HPOS cleanup check: legacy post {legacy_id} still exists ({reason}). "
f"Safe to remove with WooCommerce's own cleanup tool. Flagged, not deleted."},
auth=AUTH, timeout=30,
).raise_for_status()
async function report(order, legacyId, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `HPOS cleanup check: legacy post ${legacyId} still exists (${reason}). ` +
`Safe to remove with WooCommerce's own cleanup tool. Flagged, not deleted.`,
}),
});
}
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 lists what it would report. Read the log, spot check a few order ids in the database yourself, then switch it off to let it write the order notes. Run it once after any cleanup, or weekly on a schedule.
Always start with DRY_RUN=true. This script only reports, it never deletes, but you still want to see its list before it writes anything to an order. Leave the actual row removal to WooCommerce's own cleanup tool once you trust the report.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and never deletes a single row. It is safe to run again and again because it only ever reports, and it skips anything already cleaned up.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find legacy wp_posts order rows that should have been removed after an HPOS cleanup.
When a store turns on High-Performance Order Storage, WooCommerce copies every order
into the new custom tables and, once compatibility mode is turned off, is supposed to
remove the matching legacy shop_order post row. That cleanup step can be interrupted,
skipped for a subset of orders, or never run at all, leaving posts behind that still
carry an `_order_id` that points at a live HPOS order (the `_legacy_order_id` meta
mirrors it the other way). Those leftover rows can confuse anything that still scans
wp_posts directly, and they take up space for no reason.
This script walks HPOS orders through the REST API, reads the legacy post id each
order remembers, and confirms with Stripe that the order is fully settled (paid and
not still awaiting action) before reporting the legacy row as safe to remove. It never
deletes anything itself. Read only by default. Run on a schedule or by hand after a
cleanup.
"""
import os
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("find_legacy_order_rows")
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"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Statuses that mean Stripe has fully finished with the payment, so it is safe to
# consider the order settled and its legacy row a pure leftover.
SETTLED_INTENT_STATUSES = {"succeeded", "canceled"}
# Order statuses that are still in play and should never be touched.
OPEN_ORDER_STATUSES = {"pending", "on-hold", "processing"}
def legacy_post_id_of(order):
"""The old wp_posts id for this order, saved by HPOS as `_legacy_order_id`."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_legacy_order_id" and meta.get("value"):
try:
return int(meta["value"])
except (TypeError, ValueError):
return None
return None
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, legacy_post, intent):
"""Pure decision. No I/O. Returns (action, reason).
order: the HPOS order dict from the WooCommerce REST API.
legacy_post: a dict like {"id": 123, "post_type": "shop_order"} if a wp_posts row
with that id still exists, or None if it was already removed.
intent: the Stripe PaymentIntent dict for this order's saved id, or None if there
is no PaymentIntent to check (e.g. an offline payment method).
"""
legacy_id = legacy_post_id_of(order) if order is not None else None
if not legacy_id:
return ("skip", "order has no legacy post id, nothing to check")
if legacy_post is None:
return ("clean", "legacy row already gone, nothing left to do")
if legacy_post.get("post_type") not in ("shop_order", "shop_order_refund"):
return ("skip", "post id is reused by unrelated content, leave it alone")
if order["status"] in OPEN_ORDER_STATUSES:
return ("skip", "order is still open, keep both rows until it settles")
if intent is not None and intent.get("status") not in SETTLED_INTENT_STATUSES:
return ("skip", "Stripe still has the payment in progress")
if intent is not None and abs(order_amount_minor(order) - intent.get("amount_received", intent.get("amount", 0))) > 1:
return ("mismatch", "Stripe amount does not match the order, needs a human look")
return ("report", "HPOS order is settled and the legacy row is a safe cleanup candidate")
def get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return None
def hpos_orders():
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_legacy_post(post_id):
"""Look up the legacy wp_posts row through the custom endpoint the store's
HPOS compatibility helper exposes. Returns None once the row is gone.
"""
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders/legacy-post/{post_id}",
auth=AUTH, timeout=30,
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def report(order, legacy_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": f"HPOS cleanup check: legacy post {legacy_id} still exists ({reason}). "
f"Safe to remove with WooCommerce's own cleanup tool. Flagged, not deleted."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
flagged = 0
for order in hpos_orders():
legacy_id = legacy_post_id_of(order)
if not legacy_id:
continue
legacy_post = get_legacy_post(legacy_id)
intent = get_intent(intent_id_of(order))
action, reason = decide(order, legacy_post, intent)
if action in ("skip", "clean"):
continue
if action == "mismatch":
log.warning("Order %s: %s", order["id"], reason)
continue
log.info("Order %s: legacy post %s. %s", order["id"], legacy_id, "would report" if DRY_RUN else "reporting")
if not DRY_RUN:
report(order, legacy_id, reason)
flagged += 1
log.info("Done. %d legacy row(s) %s.", flagged, "to report" if DRY_RUN else "reported")
if __name__ == "__main__":
run()
/**
* Find legacy wp_posts order rows that should have been removed after an HPOS cleanup.
*
* When a store turns on High-Performance Order Storage, WooCommerce copies every order
* into the new custom tables and, once compatibility mode is turned off, is supposed to
* remove the matching legacy shop_order post row. That cleanup step can be interrupted,
* skipped for a subset of orders, or never run at all, leaving posts behind that still
* carry an id the order remembers as `_legacy_order_id`. Those leftover rows can confuse
* anything that still scans wp_posts directly, and they take up space for no reason.
*
* This script walks HPOS orders through the REST API, reads the legacy post id each
* order remembers, and confirms with Stripe that the order is fully settled before
* reporting the legacy row as safe to remove. It never deletes anything itself. Read
* only by default. Run on a schedule or by hand after a cleanup.
*
* Guide: https://www.allanninal.dev/woocommerce/legacy-order-rows-survive-after-hpos-cleanup/
*/
import Stripe from "stripe";
import { pathToFileURL } from "node:url";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
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";
// Statuses that mean Stripe has fully finished with the payment, so it is safe to
// consider the order settled and its legacy row a pure leftover.
const SETTLED_INTENT_STATUSES = new Set(["succeeded", "canceled"]);
// Order statuses that are still in play and should never be touched.
const OPEN_ORDER_STATUSES = new Set(["pending", "on-hold", "processing"]);
export function legacyPostIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_legacy_order_id" && meta.value) {
const id = parseInt(meta.value, 10);
return Number.isNaN(id) ? null : id;
}
}
return null;
}
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure decision. No I/O. Returns [action, reason].
*
* order: the HPOS order object from the WooCommerce REST API.
* legacyPost: an object like { id: 123, post_type: "shop_order" } if a wp_posts row
* with that id still exists, or null if it was already removed.
* intent: the Stripe PaymentIntent object for this order's saved id, or null if there
* is no PaymentIntent to check (e.g. an offline payment method).
*/
export function decide(order, legacyPost, intent) {
const legacyId = order ? legacyPostIdOf(order) : null;
if (!legacyId) return ["skip", "order has no legacy post id, nothing to check"];
if (!legacyPost) return ["clean", "legacy row already gone, nothing left to do"];
if (!["shop_order", "shop_order_refund"].includes(legacyPost.post_type)) {
return ["skip", "post id is reused by unrelated content, leave it alone"];
}
if (OPEN_ORDER_STATUSES.has(order.status)) {
return ["skip", "order is still open, keep both rows until it settles"];
}
if (intent && !SETTLED_INTENT_STATUSES.has(intent.status)) {
return ["skip", "Stripe still has the payment in progress"];
}
if (intent) {
const received = intent.amount_received ?? intent.amount ?? 0;
if (Math.abs(orderAmountMinor(order) - received) > 1) {
return ["mismatch", "Stripe amount does not match the order, needs a human look"];
}
}
return ["report", "HPOS order is settled and the legacy row is a safe cleanup candidate"];
}
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 getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function getLegacyPost(postId) {
return woo(`/orders/legacy-post/${postId}`);
}
async function* hposOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 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 || !batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function report(order, legacyId, reason) {
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: `HPOS cleanup check: legacy post ${legacyId} still exists (${reason}). ` +
`Safe to remove with WooCommerce's own cleanup tool. Flagged, not deleted.`,
}),
});
}
export async function run() {
let flagged = 0;
for await (const order of hposOrders()) {
const legacyId = legacyPostIdOf(order);
if (!legacyId) continue;
const legacyPost = await getLegacyPost(legacyId);
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, legacyPost, intent);
if (action === "skip" || action === "clean") continue;
if (action === "mismatch") {
console.warn(`Order ${order.id}: ${reason}`);
continue;
}
console.log(`Order ${order.id}: legacy post ${legacyId}. ${DRY_RUN ? "would report" : "reporting"}`);
if (!DRY_RUN) await report(order, legacyId, reason);
flagged++;
}
console.log(`Done. ${flagged} legacy row(s) ${DRY_RUN ? "to report" : "reported"}.`);
}
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 rows get named in a report. Because we kept decide pure, the test needs no network, no Stripe account, and no database. It just feeds in plain objects and checks the action.
from find_legacy_order_rows import decide
def order(**over):
base = {
"id": 501,
"status": "completed",
"total": "50.00",
"meta_data": [{"key": "_legacy_order_id", "value": "9001"}],
}
base.update(over)
return base
def legacy_post(**over):
base = {"id": 9001, "post_type": "shop_order"}
base.update(over)
return base
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000}
base.update(over)
return base
def test_report_when_settled_and_legacy_row_present():
assert decide(order(), legacy_post(), intent())[0] == "report"
def test_clean_when_legacy_row_already_gone():
assert decide(order(), None, intent())[0] == "clean"
def test_skip_when_order_still_open():
assert decide(order(status="processing"), legacy_post(), intent())[0] == "skip"
def test_mismatch_when_amount_disagrees():
assert decide(order(total="80.00"), legacy_post(), intent())[0] == "mismatch"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./find-legacy-order-rows.js";
const order = (over = {}) => ({
id: 501, status: "completed", total: "50.00",
meta_data: [{ key: "_legacy_order_id", value: "9001" }], ...over,
});
const legacyPost = (over = {}) => ({ id: 9001, post_type: "shop_order", ...over });
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });
test("report when settled and legacy row present", () => {
assert.equal(decide(order(), legacyPost(), intent())[0], "report");
});
test("clean when legacy row already gone", () => {
assert.equal(decide(order(), null, intent())[0], "clean");
});
test("skip when order still open", () => {
assert.equal(decide(order({ status: "processing" }), legacyPost(), intent())[0], "skip");
});
test("mismatch when amount disagrees", () => {
assert.equal(decide(order({ total: "80.00" }), legacyPost(), intent())[0], "mismatch");
});
Case studies
The cleanup that ran out of time
A store with close to forty thousand historical orders enabled HPOS, waited two weeks in compatibility mode, then ran the legacy data cleanup tool. On a modest shared host, the batch job kept hitting the execution time limit and restarting from where it left off, but a scheduled backup job locked the database each night at the exact point cleanup needed to write, and the job silently gave up on the remaining rows.
Running the reporting script found around thirty five hundred legacy rows still sitting untouched, all belonging to fully settled orders. The store owner reviewed the report, then re-ran WooCommerce's own cleanup tool a second time, which finished the job properly.
The loyalty plugin that wrote around HPOS
A loyalty points plugin wrote its own custom fields directly onto the order post using an old-style update function instead of the newer order object methods. HPOS treated every order that plugin had touched as not fully synced, and its cleanup tool quietly skipped all of them to avoid data loss.
The script flagged only the fully settled ones, since it checks the order status and the Stripe payment separately from whatever compatibility flag the plugin was tripping. Once the plugin was updated to use the supported order methods, a second cleanup pass cleared the rest.
After this runs, "did HPOS cleanup actually finish" stops being a guess. You get a clear, ordered list of exactly which legacy rows are safe to remove and why, with nothing deleted until you or WooCommerce's own tool acts on it. Run it again any time you are not sure a cleanup pass fully completed.
FAQ
Why do old order rows still exist after I turned off HPOS compatibility mode?
WooCommerce is supposed to remove the legacy wp_posts row for an order once it is fully synced to the new order tables and compatibility mode is off. That cleanup step can be interrupted, skipped for orders touched by another plugin, or never finished on a large catalog, so a portion of the old rows are simply left behind.
Is it safe to delete these leftover rows myself?
It is safe once you confirm the order is fully settled and the post id has not been reused for something else. A reporting script that checks the order status and, for paid orders, cross-checks Stripe before flagging a row is the safer path than deleting on sight, and it leaves the final delete to WooCommerce's own cleanup tool.
Will this script delete anything on its own?
No. It only reads data and adds an order note listing which legacy rows look safe to remove. Deleting posts is left to WooCommerce's built-in cleanup tool, which knows how to remove a shop_order post correctly.
Related field notes
Citations
On the problem:
- WooCommerce developer documentation: High-Performance Order Storage overview, compatibility mode, and the legacy data cleanup step. developer.woocommerce.com/docs/features/hpos/overview
- WooCommerce developer blog: HPOS is now generally available, with notes on migration and cleanup timing. developer.woocommerce.com (HPOS general availability)
- WooCommerce documentation: managing the switch between High-Performance Order Storage and legacy posts storage. woocommerce.com/document/high-performance-order-storage
On the solution:
- Stripe API: retrieve a PaymentIntent to confirm its final status and amount received. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce REST API: list orders and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
- WordPress developer reference: post types and how post ids can be reused after deletion. developer.wordpress.org/reference/functions/get_post_type
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 clear up your HPOS cleanup?
If this saved you from guessing at which old order rows were safe to remove, 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