Repair WooCommerce core: HPOS and housekeeping

Invisible auto-draft orders

Open your checkout page and close the tab without paying. Somewhere in your database, an order just got created anyway. Nobody sees it. It never shows in the Orders list, no email goes out, no stock is touched, but the row is there, and it stays there forever unless something removes it. On a busy store this becomes thousands of rows of pure clutter. Here is why it happens and a small script that finds the old ones and cleans them up safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
A computer screen with code on it
Photo by Chris Ried on Unsplash
The short answer

WooCommerce, especially the block based checkout, creates an order the instant a buyer lands on checkout, marked auto-draft or checkout-draft, before any money moves. If the buyer never finishes, that order just sits there, invisible in the admin, forever. Run a small Python or Node.js job on a schedule that lists orders in those hidden statuses, leaves alone anything with a Stripe PaymentIntent still in progress or already paid, and deletes the rest once they pass a safety age. Full code, tests, and a dry run guard are below.

The problem in plain words

Most order statuses in WooCommerce mean something happened: a payment was tried, a payment succeeded, an order was cancelled. Auto-draft is different. It means "someone opened the checkout page." That is all. The block based checkout creates this draft order early on purpose, so it has somewhere to attach the cart, the shipping address, and the payment attempt as the buyer types.

If the buyer pays, that draft becomes a real order and everything is fine. If the buyer closes the tab, gets distracted, or the page just times out, the draft is never touched again. It does not appear in WooCommerce, Orders, because the admin list filters those statuses out by design. It just sits in wp_wc_orders (or in old style post storage) taking up space, quietly, forever.

Buyer opens the checkout page Draft order made status: auto-draft buyer leaves Never finishes no payment attempt Hidden row stays forever
The draft order is created the moment checkout loads. If the buyer never pays, nothing ever removes it, and it never appears in the Orders list.

Why it happens

This is by design, not a bug in the usual sense. WooCommerce needs somewhere to hold the cart and address details while the buyer is filling out the form, and an order row is the natural place. The trouble is that cleanup was never built to keep up with how often checkout pages get opened and abandoned:

This has been reported often enough that WooCommerce added guidance for site owners running into slow admin screens and bloated database tables traced back to unremoved draft orders. See the citations at the end for the exact references.

The key insight

An auto-draft order only ever means "checkout was opened." It does not mean a payment was tried. That makes the cleanup rule simple: if the order is still a draft, and there is no Stripe PaymentIntent tied to it that is in progress or already paid, and it has been sitting around longer than a safety window, it is nothing but clutter and it is safe to remove.

The fix, as a flow

We do not touch checkout itself, and we never remove anything less than a day old, so a buyer who is slowly filling out a long form is never at risk. The job lists orders in the draft statuses, checks whether each one has a real payment attempt behind it, and only deletes the ones that are both stale and empty of any live activity.

Scheduled job once a day List draft orders auto-draft, checkout-draft Check PaymentIntent from order meta Stale and no live payment? yes no, keep Delete order stale draft removed
The job only removes a draft order once it is old and has no in-progress or paid Stripe attempt behind it. Anything still active is left completely alone.

Build it step by step

1

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 and write access to orders, since deleting an order counts as a write. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export MAX_AGE_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export MAX_AGE_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write
2

List the orders sitting in a draft status

Ask the WooCommerce REST API for orders whose status is auto-draft or checkout-draft. These never show in the admin Orders screen, but the REST API will still return them when you ask for that status directly. Page through the full result, since a store that has never cleaned this up can have a lot of rows.

step2.py
import requests
from requests.auth import HTTPBasicAuth

def draft_orders():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "auto-draft,checkout-draft", "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
step2.js
async function* draftOrders() {
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=auto-draft,checkout-draft&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Read the Stripe PaymentIntent, if one exists

Some drafts do have a PaymentIntent attached, saved in order meta _stripe_intent_id, or occasionally in transaction_id if it already looks like a PaymentIntent id. If it is there, look it up on Stripe, since a draft with a payment that is still being confirmed is a live checkout in progress, not clutter.

step3.py
import stripe

def intent_id_of(order):
    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 get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
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;
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and an intent 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 order is not a draft, leave it alone entirely. If a Stripe PaymentIntent is still in progress or already succeeded, keep it, that is a real checkout. If the draft is younger than the safety window, keep it too. Only a stale, empty draft gets marked for deletion.

decide.py
DRAFT_STATUSES = {"auto-draft", "checkout-draft"}
IN_PROGRESS_INTENT_STATUSES = {
    "requires_payment_method", "requires_confirmation", "requires_action",
    "processing", "requires_capture", "succeeded",
}

def decide(order, intent, now=None, max_age_hours=24):
    if order.get("status") not in DRAFT_STATUSES:
        return ("skip", "order is not an auto-draft")
    if intent is not None and intent.get("status") in IN_PROGRESS_INTENT_STATUSES:
        return ("keep", "a Stripe PaymentIntent is still in progress or paid")
    if age_hours(order, now) < max_age_hours:
        return ("keep", "draft is younger than the safety window")
    return ("delete", "stale draft with no live payment attempt")
decide.js
const DRAFT_STATUSES = new Set(["auto-draft", "checkout-draft"]);
const IN_PROGRESS_INTENT_STATUSES = new Set([
  "requires_payment_method", "requires_confirmation", "requires_action",
  "processing", "requires_capture", "succeeded",
]);

export function decide(order, intent, now = Date.now() / 1000, maxAgeHours = 24) {
  if (!DRAFT_STATUSES.has(order.status)) return ["skip", "order is not an auto-draft"];
  if (intent && IN_PROGRESS_INTENT_STATUSES.has(intent.status)) {
    return ["keep", "a Stripe PaymentIntent is still in progress or paid"];
  }
  if (ageHours(order, now) < maxAgeHours) return ["keep", "draft is younger than the safety window"];
  return ["delete", "stale draft with no live payment attempt"];
}
5

Remove the stale draft for good

When the action is delete, call the WooCommerce REST API with force=true so the row is removed outright instead of sent to trash, since a trashed draft is still clutter sitting in the table. There is nothing to notify and no email to send, because nobody ever knew the order existed.

apply.py
def delete_order(order_id):
    requests.delete(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        params={"force": "true"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function deleteOrder(orderId) {
  await woo(`/orders/${orderId}?force=true`, { method: "DELETE" });
}
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 what it would remove. Read the output, trust it, then switch it off to let it delete. Run it on a schedule with cron once a day, there is no need to run this more often.

Run it safe

Always start with DRY_RUN=true. This job deletes rows for good, so you want to see its plan before it acts. Once the report looks right for a day or two, turn it off.

The full code

Here is the complete cleanup job 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 an order that is not a stale, empty draft.

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

purge_auto_drafts.py
"""Find and remove invisible auto-draft WooCommerce orders.

The block based checkout, and some older plugins, create an order the moment a
buyer opens the checkout page, before they pay anything. That order sits with
status "auto-draft" (also seen as "checkout-draft"). It never shows in the
Orders list, so nobody notices it, but it stays in the database forever unless
something cleans it up. On a busy store this can be thousands of rows.

This walks orders in those two hidden statuses, skips anything with an
attached Stripe PaymentIntent that is actually in progress or already paid
(so a real, in-flight checkout is never touched), and deletes the rest once
they are older than a safety window. Read only by default. Run on a schedule.
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

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

DRAFT_STATUSES = {"auto-draft", "checkout-draft"}
IN_PROGRESS_INTENT_STATUSES = {
    "requires_payment_method", "requires_confirmation", "requires_action",
    "processing", "requires_capture", "succeeded",
}


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 age_hours(order, now=None):
    now = now if now is not None else time.time()
    created = order.get("date_created_gmt") or order.get("date_created")
    if not created:
        return 0.0
    created_ts = _parse_iso_utc(created)
    return max(0.0, (now - created_ts) / 3600.0)


def _parse_iso_utc(value):
    import datetime
    dt = datetime.datetime.fromisoformat(value.replace("Z", ""))
    return dt.replace(tzinfo=datetime.timezone.utc).timestamp()


def decide(order, intent, now=None, max_age_hours=MAX_AGE_HOURS):
    """Pure decision: what should happen to one draft order.

    Returns a tuple of (action, reason). action is one of:
      "skip"   - not a draft order, leave it completely alone
      "keep"   - a draft, but still young or tied to a live payment attempt
      "delete" - a stale draft with nothing real behind it, safe to remove
    """
    if order.get("status") not in DRAFT_STATUSES:
        return ("skip", "order is not an auto-draft")
    if intent is not None and intent.get("status") in IN_PROGRESS_INTENT_STATUSES:
        return ("keep", "a Stripe PaymentIntent is still in progress or paid")
    if age_hours(order, now) < max_age_hours:
        return ("keep", "draft is younger than the safety window")
    return ("delete", "stale draft with no live payment attempt")


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 draft_orders():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "auto-draft,checkout-draft", "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


def delete_order(order_id):
    requests.delete(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        params={"force": "true"},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    removed = 0
    for order in draft_orders():
        intent = get_intent(intent_id_of(order))
        action, reason = decide(order, intent)
        if action != "delete":
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would delete" if DRY_RUN else "deleting")
        if not DRY_RUN:
            delete_order(order["id"])
        removed += 1
    log.info("Done. %d order(s) %s.", removed, "to delete" if DRY_RUN else "deleted")


if __name__ == "__main__":
    run()
purge-auto-drafts.js
/**
 * Find and remove invisible auto-draft WooCommerce orders.
 *
 * The block based checkout, and some older plugins, create an order the
 * moment a buyer opens the checkout page, before they pay anything. That
 * order sits with status "auto-draft" (also seen as "checkout-draft"). It
 * never shows in the Orders list, so nobody notices it, but it stays in the
 * database forever unless something cleans it up. On a busy store this can
 * be thousands of rows.
 *
 * This walks orders in those two hidden statuses, skips anything with an
 * attached Stripe PaymentIntent that is actually in progress or already
 * paid (so a real, in-flight checkout is never touched), and deletes the
 * rest once they are older than a safety window. Read only by default.
 * Run on a schedule.
 */
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 MAX_AGE_HOURS = Number(process.env.MAX_AGE_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DRAFT_STATUSES = new Set(["auto-draft", "checkout-draft"]);
const IN_PROGRESS_INTENT_STATUSES = new Set([
  "requires_payment_method", "requires_confirmation", "requires_action",
  "processing", "requires_capture", "succeeded",
]);

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 ageHours(order, now = Date.now() / 1000) {
  const created = order.date_created_gmt || order.date_created;
  if (!created) return 0;
  const createdTs = Date.parse(created.endsWith("Z") ? created : `${created}Z`) / 1000;
  return Math.max(0, (now - createdTs) / 3600);
}

/**
 * Pure decision: what should happen to one draft order.
 * Returns [action, reason]. action is one of "skip", "keep", "delete".
 */
export function decide(order, intent, now = Date.now() / 1000, maxAgeHours = MAX_AGE_HOURS) {
  if (!DRAFT_STATUSES.has(order.status)) return ["skip", "order is not an auto-draft"];
  if (intent && IN_PROGRESS_INTENT_STATUSES.has(intent.status)) {
    return ["keep", "a Stripe PaymentIntent is still in progress or paid"];
  }
  if (ageHours(order, now) < maxAgeHours) return ["keep", "draft is younger than the safety window"];
  return ["delete", "stale draft with no live payment attempt"];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* draftOrders() {
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=auto-draft,checkout-draft&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function deleteOrder(orderId) {
  await woo(`/orders/${orderId}?force=true`, { method: "DELETE" });
}

export async function run() {
  let removed = 0;
  for await (const order of draftOrders()) {
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(order, intent);
    if (action !== "delete") continue;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would delete" : "deleting"}`);
    if (!DRY_RUN) await deleteOrder(order.id);
    removed++;
  }
  console.log(`Done. ${removed} order(s) ${DRY_RUN ? "to delete" : "deleted"}.`);
}

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 rows get deleted for good. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and a fixed clock, then checks the action.

test_invisible_draft_decide.py
from purge_auto_drafts import decide, intent_id_of, age_hours

NOW = 1_800_000_000  # fixed reference time so age math is deterministic


def order(**over):
    base = {"status": "auto-draft", "date_created_gmt": "2026-07-08T00:00:00"}
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded"}
    base.update(over)
    return base


def test_skip_when_not_a_draft_status():
    o = order(status="pending")
    assert decide(o, None, now=NOW)[0] == "skip"


def test_keep_when_intent_is_in_progress():
    o = order()
    i = intent(status="requires_action")
    assert decide(o, i, now=NOW)[0] == "keep"


def test_delete_when_stale_and_no_intent():
    o = {"status": "auto-draft", "date_created_gmt": "2026-07-01T00:00:00"}
    now = age_reference("2026-07-10T00:00:00")
    assert decide(o, None, now=now, max_age_hours=24)[0] == "delete"


def age_reference(iso_string):
    import datetime
    dt = datetime.datetime.fromisoformat(iso_string)
    return dt.replace(tzinfo=datetime.timezone.utc).timestamp()
purge-auto-drafts.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./purge-auto-drafts.js";

const NOW = Date.parse("2026-07-10T00:00:00Z") / 1000;

const order = (over = {}) => ({
  status: "auto-draft",
  date_created_gmt: "2026-07-08T00:00:00",
  ...over,
});

const intent = (over = {}) => ({ status: "succeeded", ...over });

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

test("keep when intent is in progress", () => {
  assert.equal(decide(order(), intent({ status: "requires_action" }), NOW)[0], "keep");
});

test("delete when stale and no intent", () => {
  const o = order({ date_created_gmt: "2026-07-01T00:00:00" });
  assert.equal(decide(o, null, NOW, 24)[0], "delete");
});

Case studies

Slow admin screens

The store that could not figure out why WordPress was slow

A shop running the block based checkout for over a year had never noticed anything wrong. Orders looked fine, reports looked fine, but the admin dashboard was getting slower every month. The order table had grown to hundreds of thousands of rows, and almost all of the extra rows were auto-draft orders from opened, never finished, checkout sessions.

Running the cleanup job in dry run showed the real number for the first time. After the first real run trimmed the table down, admin screens went back to normal speed.

Migration surprise

The HPOS migration that took hours longer than expected

A store migrating to High Performance Order Storage found the migration step taking far longer than the documentation suggested. Support traced it to a huge number of auto-draft orders being migrated along with everything else, none of which anyone actually wanted kept.

Running the cleanup job first, well before the migration, cut the row count dramatically and the migration finished in a fraction of the original time.

What good looks like

After this runs on a schedule, your order table only holds orders that meant something: a real attempt, a real payment, a real cancellation. Draft clutter from opened and abandoned checkouts gets cleared out automatically within a day, keeping admin screens fast and future migrations quick, without ever touching a checkout that is actually still in progress.

FAQ

Why does my WooCommerce database have so many auto-draft orders?

The block based checkout, and some older plugins, create an order the moment a buyer opens the checkout page, before any payment happens. If the buyer never finishes, that order is left behind with status auto-draft or checkout-draft. It never shows in the Orders list, so it is never cleaned up by hand, and thousands of them can build up over time.

Is it safe to delete auto-draft orders with a script?

Yes, when the script only deletes orders that are still in a draft status, checks that there is no Stripe PaymentIntent still in progress or already paid for that order, and only acts once the order has passed a safety age like 24 hours. Start in dry run mode to review the list before it deletes anything.

How often should the cleanup job run?

Once a day is enough for most stores. Auto-draft orders are only ever created seconds before a real order would replace them, so there is no rush, and a daily run keeps the table small without any risk to an order that is still being placed.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: Checkout Draft Orders, what they are and how the block based checkout uses them. woocommerce.com/document/order-statuses
  2. WordPress developer docs: the auto-draft post status and how it is used before a post or order is finished. developer.wordpress.org/reference/functions/get_post_statuses
  3. WooCommerce High Performance Order Storage (HPOS) documentation, including how custom order statuses and cleanup interact with the new order tables. woocommerce.com/document/high-performance-order-storage

On the solution:

  1. WooCommerce REST API: list, filter by status, and delete an order. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: retrieve a PaymentIntent and read its current status. docs.stripe.com/api/payment_intents/retrieve
  3. Stripe docs: the full list of PaymentIntent statuses, including which ones mean a payment is still in progress. docs.stripe.com/payments/paymentintents/lifecycle

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 clean up your database?

If this saved you a slow dashboard or a painful HPOS migration, 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