Repair WooCommerce core: database bloat and maintenance

The WooCommerce session table that grows without bound

Every shopper who opens your store, whether they buy or not, gets a row in wp_woocommerce_sessions. WooCommerce is supposed to delete those rows once they expire. On plenty of stores that cleanup quietly stops working, and the table just keeps growing, slowing down every page that touches it. Here is why the cleanup breaks and a small script that clears it back down safely, without touching a shopper who is checking out right now.

Python and Node.js Runs on a schedule Safe by default (dry run)
A file cabinet
Photo by Maksym Kaharlytskyi on Unsplash
The short answer

The sessions table only shrinks when a scheduled cleanup event actually fires, and on many stores it stops firing without anyone noticing. Run a small Python or Node.js job on a schedule that reads the table's real size from the WooCommerce REST API's system status report, checks Stripe to make sure no shopper is mid-checkout right now, and only then calls WooCommerce's own clear_sessions maintenance tool. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce needs somewhere to remember a shopper's cart between page loads, and that place is a database table called wp_woocommerce_sessions. Every visitor gets a row, whether they are logged in or not, whether they buy anything or not. Each row carries an expiry time, usually about two days out.

WooCommerce is meant to sweep out expired rows on its own, on a schedule, through WordPress's background task system. When that sweep stops happening, nothing deletes the old rows. New visitors keep adding new ones. The table only ever grows, and every query that touches it, including the one that loads a cart on every single page view, gets slower as it grows.

Visitors browse a row per session Rows expire after about 2 days cleanup stopped Table balloons nothing ever removed Slow carts Slow every page
Every visit adds a row. Without the scheduled cleanup, no row ever leaves, and the table just keeps getting bigger.

Why it happens

The cleanup that keeps this table small runs through WordPress's own scheduled task system, and that system depends on real page visits or a real server cron to keep ticking. A few common reasons it quietly stops:

This is a well known WooCommerce complaint. Store owners have reported this single table reaching hundreds of megabytes, and in extreme cases several gigabytes, almost entirely expired rows that were simply never swept out. See the citations at the end for real reports of the size this can reach.

The key insight

WooCommerce already ships the fix. There is a REST reachable maintenance tool, clear_sessions, built for exactly this table. The hard part is not clearing it, it is knowing when it is safe to clear it, because a blunt run can drop a real shopper's cart mid checkout. A small job that checks size first and checks for an active checkout second turns a risky manual action into a routine, scheduled one.

The fix, as a flow

We do not touch the live checkout path. We add a job that runs every so often, reads the sessions table's real size from WooCommerce's own system status report, and only if it has crossed a size we choose, checks Stripe for any recent order whose payment still looks open. If nobody looks like they are actively paying right now, it calls the built in cleanup tool and the table drops back down.

Scheduled job every hour or so Read table size from system status Check Stripe for an open checkout Big enough and nobody checking out? yes no, wait Clear sessions table drops back down
The job only acts when the table has actually crossed the size you set, and only once it confirms no checkout looks to be in progress.

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 access to orders and manage access to system status, since the tool that clears sessions lives under system status. 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_SESSIONS_MB="50"
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_SESSIONS_MB="50"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the sessions table's real size

WooCommerce's system status report already knows the size of every one of its database tables, in megabytes, split into data and index. Ask the REST API for it and pull out the row for woocommerce_sessions. This is the same number a shop owner would see if they read the report by hand, just automated.

step2.py
import requests
from requests.auth import HTTPBasicAuth

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

def sessions_table_size_mb(system_status):
    tables = ((system_status.get("database") or {}).get("database_tables") or {}).get("other") or {}
    row = tables.get("woocommerce_sessions") or tables.get("wp_woocommerce_sessions") or {}
    return float(row.get("data", 0) or 0) + float(row.get("index", 0) or 0)

def get_system_status():
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/system_status", auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").toString("base64");

export function sessionsTableSizeMb(systemStatus) {
  const tables = systemStatus?.database?.database_tables?.other || {};
  const row = tables.woocommerce_sessions || tables.wp_woocommerce_sessions || {};
  return Number(row.data || 0) + Number(row.index || 0);
}

async function getSystemStatus() {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3/system_status`, {
    headers: { Authorization: AUTH },
  });
  if (!res.ok) throw new Error(`system_status returned ${res.status}`);
  return res.json();
}
3

Check for a checkout that might still be in progress

Clearing sessions is blunt. It drops every active cart along with the expired ones, so before we act we look at orders created in the last few minutes that are still pending, on hold, or a checkout draft, and read each one's saved Stripe PaymentIntent id from order meta _stripe_intent_id (or transaction_id as a fallback). If Stripe says any of those intents are still open, somebody may be mid checkout right now.

step3.py
import time, stripe

OPEN_INTENT_STATUSES = {"requires_action", "requires_confirmation", "processing", "requires_payment_method"}
LIVE_ORDER_STATUSES = {"pending", "on-hold", "checkout-draft"}

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 recent_live_orders(guard_minutes):
    after = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - guard_minutes * 60))
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"status": ",".join(LIVE_ORDER_STATUSES), "after": after, "per_page": 50},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()
step3.js
const OPEN_INTENT_STATUSES = new Set([
  "requires_action", "requires_confirmation", "processing", "requires_payment_method",
]);
const LIVE_ORDER_STATUSES = ["pending", "on-hold", "checkout-draft"];

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 recentLiveOrders(guardMinutes) {
  const after = new Date(Date.now() - guardMinutes * 60000).toISOString();
  return woo(`/orders?status=${LIVE_ORDER_STATUSES.join(",")}&after=${after}&per_page=50`);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the table size, the threshold, and a count of open checkouts, 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 table is not actually bloated, skip it. If it is bloated but someone looks like they are checking out, wait. Otherwise, clear it.

decide.py
def decide(sessions_size_mb, threshold_mb, open_checkout_count):
    if sessions_size_mb < threshold_mb:
        return ("skip", "sessions table is under the size threshold")
    if open_checkout_count > 0:
        return ("wait", "a checkout looks in progress, wait for it to settle")
    return ("clear", "sessions table is bloated and no checkout is in progress")
decide.js
export function decide(sessionsSizeMb, thresholdMb, openCheckoutCount) {
  if (sessionsSizeMb < thresholdMb) return ["skip", "sessions table is under the size threshold"];
  if (openCheckoutCount > 0) return ["wait", "a checkout looks in progress, wait for it to settle"];
  return ["clear", "sessions table is bloated and no checkout is in progress"];
}
5

Clear sessions the way WooCommerce itself would

When the action is clear, call WooCommerce's own maintenance tool, the same one listed under WooCommerce, Status, Tools in the admin. It is reachable through the REST API as a system status tool, so the same safe request that a shop manager would trigger by hand is the one our script sends.

apply.py
def clear_sessions():
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/system_status/tools/clear_sessions",
        auth=AUTH, timeout=60,
    ).raise_for_status()
apply.js
async function clearSessions() {
  await woo("/system_status/tools/clear_sessions", { method: "PUT" });
}
6

Wire it together with a dry run guard

The run function ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports the table's size, the checkout guard result, and what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron, once an hour is usually plenty.

Run it safe

Always start with DRY_RUN=true. Clearing sessions removes real, active carts along with the expired ones, so you want to see the size and the checkout guard's answer before it ever runs for real.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never clears the table without first checking that nobody looks like they are mid checkout.

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

clear_stale_sessions.py
"""Clear a bloated wp_woocommerce_sessions table, without cutting off a live checkout.

WooCommerce is supposed to prune expired session rows on its own, every time a
scheduled cleanup event runs. When that event stops firing (WP-Cron disabled, Action
Scheduler stuck, a host that kills long requests), expired rows never get removed and
the table grows without bound. Some stores have reported this table alone reaching
several gigabytes, almost all of it expired rows.

WooCommerce ships a REST-reachable maintenance tool that empties the sessions table:
PUT /wp-json/wc/v3/system_status/tools/clear_sessions. It is effective but blunt, it
wipes every session, including a shopper who is mid-checkout right now. So before we
run it we check Stripe for any PaymentIntent created in the last few minutes that is
still open (requires_action, processing, or requires_payment_method), using the
PaymentIntent id saved on the matching WooCommerce order's _stripe_intent_id meta (or
transaction_id as a fallback). If anyone looks like they are actively paying, we wait.

Safe 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("clear_stale_sessions")

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_SESSIONS_MB = float(os.environ.get("MAX_SESSIONS_MB", "50"))
CHECKOUT_GUARD_MINUTES = int(os.environ.get("CHECKOUT_GUARD_MINUTES", "15"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

OPEN_INTENT_STATUSES = {"requires_action", "requires_confirmation", "processing", "requires_payment_method"}
LIVE_ORDER_STATUSES = {"pending", "on-hold", "checkout-draft"}


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 sessions_table_size_mb(system_status):
    """Read the sessions table's data + index size, in MB, from a system_status payload."""
    tables = ((system_status.get("database") or {}).get("database_tables") or {}).get("other") or {}
    row = tables.get("woocommerce_sessions") or tables.get("wp_woocommerce_sessions") or {}
    return float(row.get("data", 0) or 0) + float(row.get("index", 0) or 0)


def decide(sessions_size_mb, threshold_mb, open_checkout_count):
    """Pure decision: should we clear the sessions table right now?

    sessions_size_mb    -- current size (data + index, MB) of wp_woocommerce_sessions
    threshold_mb        -- size at which the table counts as bloated
    open_checkout_count -- number of recent orders with a Stripe PaymentIntent that is
                           still open (a shopper who may be mid-checkout right now)
    """
    if sessions_size_mb < threshold_mb:
        return ("skip", "sessions table is under the size threshold")
    if open_checkout_count > 0:
        return ("wait", "a checkout looks in progress, wait for it to settle")
    return ("clear", "sessions table is bloated and no checkout is in progress")


def get_system_status():
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/system_status", auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def recent_live_orders(guard_minutes):
    after = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - guard_minutes * 60))
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"status": ",".join(LIVE_ORDER_STATUSES), "after": after, "per_page": 50},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    return r.json()


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 count_open_checkouts(guard_minutes):
    open_count = 0
    for order in recent_live_orders(guard_minutes):
        intent = get_intent(intent_id_of(order))
        if intent is not None and intent.get("status") in OPEN_INTENT_STATUSES:
            open_count += 1
    return open_count


def clear_sessions():
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/system_status/tools/clear_sessions",
        auth=AUTH, timeout=60,
    ).raise_for_status()


def run():
    status = get_system_status()
    size_mb = sessions_table_size_mb(status)
    open_checkouts = count_open_checkouts(CHECKOUT_GUARD_MINUTES)
    action, reason = decide(size_mb, MAX_SESSIONS_MB, open_checkouts)

    if action == "skip":
        log.info("Sessions table is %.1f MB, under the %.1f MB threshold. Nothing to do.", size_mb, MAX_SESSIONS_MB)
        return
    if action == "wait":
        log.warning(
            "Sessions table is %.1f MB (over %.1f MB) but %d checkout(s) look in progress. %s",
            size_mb, MAX_SESSIONS_MB, open_checkouts, reason,
        )
        return

    log.info("Sessions table is %.1f MB (over %.1f MB) and no checkout is in progress. %s",
              size_mb, MAX_SESSIONS_MB, "Would clear it." if DRY_RUN else "Clearing it now.")
    if not DRY_RUN:
        clear_sessions()
        log.info("Cleared wp_woocommerce_sessions.")


if __name__ == "__main__":
    run()
clear-stale-sessions.js
/**
 * Clear a bloated wp_woocommerce_sessions table, without cutting off a live checkout.
 *
 * WooCommerce is supposed to prune expired session rows on its own, every time a
 * scheduled cleanup event runs. When that event stops firing (WP-Cron disabled, Action
 * Scheduler stuck, a host that kills long requests), expired rows never get removed and
 * the table grows without bound. Some stores have reported this table alone reaching
 * several gigabytes, almost all of it expired rows.
 *
 * WooCommerce ships a REST-reachable maintenance tool that empties the sessions table:
 * PUT /wp-json/wc/v3/system_status/tools/clear_sessions. It is effective but blunt, it
 * wipes every session, including a shopper who is mid-checkout right now. So before we
 * run it we check Stripe for any PaymentIntent created in the last few minutes that is
 * still open (requires_action, processing, or requires_payment_method), using the
 * PaymentIntent id saved on the matching WooCommerce order's _stripe_intent_id meta (or
 * transaction_id as a fallback). If anyone looks like they are actively paying, we wait.
 *
 * Safe by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/session-table-balloons/
 */
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_SESSIONS_MB = Number(process.env.MAX_SESSIONS_MB || 50);
const CHECKOUT_GUARD_MINUTES = Number(process.env.CHECKOUT_GUARD_MINUTES || 15);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const OPEN_INTENT_STATUSES = new Set([
  "requires_action", "requires_confirmation", "processing", "requires_payment_method",
]);
const LIVE_ORDER_STATUSES = ["pending", "on-hold", "checkout-draft"];

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 sessionsTableSizeMb(systemStatus) {
  const tables = systemStatus?.database?.database_tables?.other || {};
  const row = tables.woocommerce_sessions || tables.wp_woocommerce_sessions || {};
  return Number(row.data || 0) + Number(row.index || 0);
}

/**
 * Pure decision: should we clear the sessions table right now?
 *
 * sessionsSizeMb    -- current size (data + index, MB) of wp_woocommerce_sessions
 * thresholdMb       -- size at which the table counts as bloated
 * openCheckoutCount -- number of recent orders with a Stripe PaymentIntent that is
 *                      still open (a shopper who may be mid-checkout right now)
 */
export function decide(sessionsSizeMb, thresholdMb, openCheckoutCount) {
  if (sessionsSizeMb < thresholdMb) return ["skip", "sessions table is under the size threshold"];
  if (openCheckoutCount > 0) return ["wait", "a checkout looks in progress, wait for it to settle"];
  return ["clear", "sessions table is bloated and no checkout is in progress"];
}

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 getSystemStatus() {
  return woo("/system_status");
}

async function recentLiveOrders(guardMinutes) {
  const after = new Date(Date.now() - guardMinutes * 60000).toISOString();
  return woo(`/orders?status=${LIVE_ORDER_STATUSES.join(",")}&after=${after}&per_page=50`);
}

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

async function countOpenCheckouts(guardMinutes) {
  const orders = await recentLiveOrders(guardMinutes);
  let openCount = 0;
  for (const order of orders) {
    const intent = await getIntent(intentIdOf(order));
    if (intent && OPEN_INTENT_STATUSES.has(intent.status)) openCount++;
  }
  return openCount;
}

async function clearSessions() {
  await woo("/system_status/tools/clear_sessions", { method: "PUT" });
}

export async function run() {
  const status = await getSystemStatus();
  const sizeMb = sessionsTableSizeMb(status);
  const openCheckouts = await countOpenCheckouts(CHECKOUT_GUARD_MINUTES);
  const [action, reason] = decide(sizeMb, MAX_SESSIONS_MB, openCheckouts);

  if (action === "skip") {
    console.log(`Sessions table is ${sizeMb.toFixed(1)} MB, under the ${MAX_SESSIONS_MB} MB threshold. Nothing to do.`);
    return;
  }
  if (action === "wait") {
    console.warn(
      `Sessions table is ${sizeMb.toFixed(1)} MB (over ${MAX_SESSIONS_MB} MB) but ${openCheckouts} ` +
      `checkout(s) look in progress. ${reason}`
    );
    return;
  }

  console.log(
    `Sessions table is ${sizeMb.toFixed(1)} MB (over ${MAX_SESSIONS_MB} MB) and no checkout is in progress. ` +
    (DRY_RUN ? "Would clear it." : "Clearing it now.")
  );
  if (!DRY_RUN) {
    await clearSessions();
    console.log("Cleared wp_woocommerce_sessions.");
  }
}

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 whether a live shopper's cart gets wiped. Because we kept decide pure, the test needs no network, no Stripe account, and no real WooCommerce store. It just feeds in plain numbers and checks the action.

test_session_balloon_decide.py
from clear_stale_sessions import decide, intent_id_of, sessions_table_size_mb


def test_skip_when_under_threshold():
    assert decide(10.0, 50.0, 0)[0] == "skip"


def test_clear_when_over_threshold_and_no_open_checkout():
    assert decide(120.0, 50.0, 0)[0] == "clear"


def test_wait_when_over_threshold_but_checkout_in_progress():
    assert decide(120.0, 50.0, 2)[0] == "wait"


def test_skip_takes_priority_even_with_open_checkout():
    # If the table isn't actually bloated yet, an in-progress checkout is irrelevant.
    assert decide(5.0, 50.0, 3)[0] == "skip"


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"


def test_sessions_table_size_mb_sums_data_and_index():
    status = {"database": {"database_tables": {"other": {
        "woocommerce_sessions": {"data": "4.10", "index": "0.15"}
    }}}}
    assert abs(sessions_table_size_mb(status) - 4.25) < 1e-9
clear-stale-sessions.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, sessionsTableSizeMb } from "./clear-stale-sessions.js";

test("skip when under threshold", () => {
  assert.equal(decide(10.0, 50.0, 0)[0], "skip");
});

test("clear when over threshold and no open checkout", () => {
  assert.equal(decide(120.0, 50.0, 0)[0], "clear");
});

test("wait when over threshold but checkout in progress", () => {
  assert.equal(decide(120.0, 50.0, 2)[0], "wait");
});

test("intentIdOf from meta", () => {
  assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});

test("sessionsTableSizeMb sums data and index", () => {
  const status = { database: { database_tables: { other: {
    woocommerce_sessions: { data: "4.10", index: "0.15" },
  } } } };
  assert.ok(Math.abs(sessionsTableSizeMb(status) - 4.25) < 1e-9);
});

Case studies

WP-Cron disabled

The performance guide that broke its own cleanup

A store followed a well meaning performance guide that disabled WP-Cron and replaced it with a system cron entry, but the replacement only ever hit the homepage, not the admin-ajax endpoint that WooCommerce's scheduler needs. The sessions table quietly passed a hundred megabytes over a few months while nobody looked at system status.

Running this job in dry run first showed the exact size and confirmed no checkout was ever caught mid-run, since it only checked when nobody was buying. Once trusted, it ran hourly and kept the table under twenty megabytes from then on.

Large catalog, high traffic

The flash sale that overwhelmed the cleanup query

During a busy sale weekend, so many sessions were created that the built in cleanup query started timing out before it could finish, so it kept failing without ever shrinking the table. By Monday the table was large enough to slow down every cart request storewide.

The checkout guard in this job waited out the last hour of real sale traffic, then cleared the backlog safely once the rush had genuinely ended, without an admin needing to guess when it was safe.

What good looks like

After this runs on a schedule, a stalled cleanup event is no longer a slow, creeping problem. The worst case becomes a short wait of one run cycle while a real checkout finishes. Keep it running even after you fix WP-Cron or Action Scheduler, since it is a cheap, read-mostly check that only ever acts when the table is actually oversized.

FAQ

Why does wp_woocommerce_sessions keep growing?

WooCommerce is supposed to delete expired session rows on a schedule, but that cleanup runs through WP-Cron and Action Scheduler. If WP-Cron is disabled, Action Scheduler is stuck, or the request that runs cleanup keeps timing out, expired rows are never removed and the table only ever grows.

Is it safe to clear the sessions table on a live store?

Yes, as long as you check first that no shopper is mid-checkout right now. Clearing sessions drops every active cart along with the expired ones, so a script should confirm there is no recent order with an open Stripe PaymentIntent before it runs, and default to dry run so you can see its plan first.

How big does the sessions table need to get before this is worth running?

There is no universal number, but many stores never need it under a few megabytes. Stores with real, unresolved cleanup problems have reported this single table reaching hundreds of megabytes or more, almost entirely expired rows. Set your own threshold and let the job tell you when it is crossed.

Related field notes

Citations

On the problem:

  1. WordPress.org support: the wp_woocommerce_sessions table growing very large despite a small amount of real data. wordpress.org/support/topic/woocommerce_session-table-is-too-big-despite-small-amount-of-data
  2. WordPress.org support: WooCommerce not clearing old sessions, with real reports of table size and row counts. wordpress.org/support/topic/woocommerce-not-clearing-old-sessions
  3. WooCommerce docs: understanding the system status report, including the database tables section. woocommerce.com/document/understanding-the-woocommerce-system-status-report

On the solution:

  1. WooCommerce developer docs: the system status tools endpoint, including the clear_sessions tool. developer.woocommerce.com/docs/apis/rest-api/v2/system-status-tools
  2. WooCommerce developer docs: the system status endpoint and its database table size fields. developer.woocommerce.com/docs/apis/rest-api/v3/system-status
  3. Stripe API: retrieve a PaymentIntent and read its current status. docs.stripe.com/api/payment_intents/retrieve

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 shrink your sessions table?

If this saved your store from a slow cart on every page, 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