Short answer
WooCommerce core already prevents most checkout overselling: since 4.3 it reserves stock in the wc_reserved_stock table with a locking, conditional insert, and its stock decrement is a single atomic SQL update. Overselling at volume usually comes from orders that bypass that reservation (REST API, admin, imports, renewals), from a disabled hold-stock setting, or from external syncs that overwrite stock with absolute values. Fix those paths before adding locks or queues of your own.
The usual advice for overselling is “make the stock decrement atomic”. For WooCommerce that advice is about ten years late. The decrement has been a single relative UPDATE for a long time, and a checkout reservation layer with row locks has existed since 4.x. Proposing your own SELECT ... FOR UPDATE without knowing this tends to duplicate core, add a second hot lock on the same row, and still miss the real leak. This article starts with what core does, then shows where it stops.
What WooCommerce core actually does
Everything below is from the current WooCommerce source on GitHub (ReserveStock.php and wc-stock-functions.php), not from plugin marketing.
1. Reservation at order placement
When a customer places an order through the classic checkout (woocommerce_checkout_order_created) or the Store API checkout used by the Checkout block (on the POST that places the order), WooCommerce calls wc_reserve_stock_for_order(). That goes through the ReserveStock class, which for each managed-stock product runs one statement of this shape:
INSERT INTO wp_wc_reserved_stock (order_id, product_id, stock_quantity, `timestamp`, expires)
SELECT %d, %d, %d, NOW(), DATE_ADD(NOW(), INTERVAL %d MINUTE)
FROM DUAL
WHERE ( <current _stock> FOR UPDATE ) - ( <sum of active reservations> LOCK IN SHARE MODE ) >= %d
ON DUPLICATE KEY UPDATE ...
This is simplified, but the important parts are real: the stock row is read with FOR UPDATE, other reservations are summed under a shared lock, and the insert only happens if available stock minus existing holds covers the quantity. Two simultaneous checkouts for the last unit cannot both insert a reservation; the second gets a ReserveStockException and an “insufficient stock” error. Products are processed in ascending ID order to avoid deadlocks between multi-item carts.
Reservations count only while the order is checkout-draft or pending and the hold has not expired. They are released on payment completion, on the processing, on-hold, completed and cancelled statuses, and when checkout throws.
2. Atomic decrement at payment
Stock is actually reduced by wc_maybe_reduce_stock_levels(), hooked to woocommerce_payment_complete and to the processing, completed and on-hold status transitions. It is guarded twice against double reduction: an order-level “stock reduced” flag and a per-item _reduced_stock meta. For each item it calls wc_update_product_stock( $product, $qty, 'decrease' ), which ends up in the product data store as:
UPDATE wp_postmeta SET meta_value = meta_value - 1
WHERE post_id = 123 AND meta_key = '_stock';
That update is atomic: concurrent decrements will not lose writes. It is not conditional, though. Nothing in it stops the value going below zero. Core relies on the reservation step to have already refused the order; the decrement just records it. A negative _stock value is therefore the fingerprint of an order that reached payment without a valid reservation.
3. What HPOS changes, and what it does not
High-Performance Order Storage, the default for new stores since WooCommerce 8.2, moves orders into dedicated tables (wc_orders and friends). That removes a lot of write contention from wp_posts and wp_postmeta during a sale. Product stock, however, still lives in wp_postmeta under _stock, mirrored into wc_product_meta_lookup. HPOS is worth having for throughput, but it does not change the locking story for stock.
Where overselling still gets through
| Leak | Why core misses it | Fix |
|---|---|---|
| Hold stock is set to blank or 0 | The reservation step is skipped entirely | Set WooCommerce > Settings > Products > Inventory > Hold stock to a real number of minutes |
| Orders created via REST API, admin, CSV import, POS or marketplace connectors | They never pass through checkout, so nothing reserves stock | Call wc_reserve_stock_for_order() in that flow, or use a locked check-and-decrement |
| ERP or multichannel sync writing absolute stock | A 'set' overwrites decrements that happened since the sync read its value |
Sync deltas, or subtract reserved and unsynced orders before setting |
| Backorders allowed | Reservation deliberately skips products that allow backorders | Disable backorders on limited-stock products |
Plugins writing _stock directly |
Bypasses both the reservation and the data store | Route through wc_update_product_stock() |
| MyISAM tables | Row locks and transactions do nothing | Convert to InnoDB |
A stale “In stock” badge on a cached product page is a user-experience problem, not an overselling one: the reservation check still runs at order placement. Do not disable page caching to “fix” overselling.
Closing the gaps without fighting core
Reserve stock in non-checkout order flows
If you create orders in custom code, use the same reservation core uses. Create the order as pending, reserve, and only then take payment or move it on:
<?php
// Custom order creation (e.g. a wholesale endpoint). Assumes WooCommerce 8.8+.
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
$order = wc_create_order( array( 'status' => 'pending' ) );
$order->add_product( wc_get_product( $product_id ), $qty );
$order->calculate_totals();
try {
wc_reserve_stock_for_order( $order ); // Same locked INSERT ... SELECT as checkout.
} catch ( ReserveStockException $e ) {
$order->update_status( 'cancelled', 'Insufficient stock at reservation.' );
return new WP_Error( 'insufficient_stock', $e->getMessage(), array( 'status' => 409 ) );
}
// Payment or approval happens here; payment_complete() reduces stock and releases the hold.
This keeps a single source of truth for availability. Your custom flow and the storefront now compete for the same reservations under the same lock.
A locked check-and-decrement for flows that cannot reserve
Some integrations only learn about a sale after the fact (a marketplace order webhook, for example). There is nothing to reserve; you need to know whether the stock was there, taking active checkout holds into account. The safe way is to take the same row lock core’s reservation takes, then let core do the write:
<?php
// Assumes InnoDB and that $product_id is the ID that manages stock
// (for variations that inherit stock, use $product->get_stock_managed_by_id()).
global $wpdb;
$wpdb->query( 'START TRANSACTION' );
// Same row core locks in ReserveStock, so this serialises with live checkouts.
$stock = (float) $wpdb->get_var( $wpdb->prepare(
"SELECT meta_value FROM {$wpdb->postmeta}
WHERE post_id = %d AND meta_key = '_stock' FOR UPDATE",
$product_id
) );
$held = wc_get_held_stock_quantity( wc_get_product( $product_id ) );
if ( $stock - $held < $qty ) {
$wpdb->query( 'ROLLBACK' );
// Flag the order for manual handling instead of recording negative stock.
return false;
}
// Core write: atomic UPDATE plus stock status, caches and lookup table.
wc_update_product_stock( $product_id, $qty, 'decrease' );
$wpdb->query( 'COMMIT' );
Keep everything inside that transaction short: no HTTP calls, no emails you control. The lock blocks every checkout for that product until you commit. And treat this as the exception path; for anything that can create a pending order first, the reservation approach above is simpler.
Make external syncs delta-based
The most common real-world oversell is not two shoppers racing. It is an ERP sync that reads stock at 10:00, sells happen at 10:01, and the sync writes its 10:00 number back at 10:02, restoring units that were already sold. Send movements (“-3 for order 1234”), not snapshots, or have the sync subtract WooCommerce orders it has not yet seen. A nightly reconciliation against the authoritative system then catches drift.
Row locks and queues: when you actually need them
Core’s reservation is a row lock on one wp_postmeta row per product. For normal traffic that is fine. In a genuine flash sale on a single SKU, every checkout queues on that one row, and the symptom changes from overselling to lock-wait timeouts and slow checkouts. The question becomes throughput, not correctness.
- Keep the lock short. The reservation statement is fast; what makes it slow is a long transaction around it. Avoid wrapping order creation in custom transactions that also call payment gateways.
- Index for the lock. The
FOR UPDATEread goes throughwp_postmeta‘spost_idindex. If plugins have added heavy meta queries to the same table, review the indexes; composite indexes and query profiling in MySQL covers how to check what the optimiser is doing. - Queue admission, not stock writes. For a launch where demand vastly exceeds units, put a queue or waiting room in front of checkout so only a bounded number of shoppers reach payment at once. Queuing the stock update itself after payment is the wrong place: by then you have taken money for units you may not have.
- Allocate asynchronously for non-web channels. Marketplace and POS orders can go through a per-SKU queue with a single consumer that applies the locked check-and-decrement in order, which also keeps lock waits off the web tier. A Redis-backed worker setup for this is described in Scaling Distributed Webhook Ingestion with Redis and BullMQ.
Verify it under load
- Find past oversells:
SELECT post_id, meta_value FROM wp_postmeta WHERE meta_key = '_stock' AND CAST(meta_value AS SIGNED) < 0; - For each, look at the orders involved and how they were created. The creation channel tells you which leak in the table above you have.
- Set a test product to stock 5 and fire 50 concurrent Store API checkouts at it on staging. Exactly five should succeed.
- Repeat with your ERP sync running, and again through any custom order endpoint.
- Confirm all tables are InnoDB and Hold stock is set.
The broader scaling picture for WooCommerce (lookup tables, sessions in Redis, cart caching rules) is in E-Commerce Engineering: Shopify Custom Apps & Scalable WooCommerce Systems.
Questions people actually ask
- Why does WooCommerce allow negative stock?
- Because the decrement at payment is unconditional by design. Core expects the checkout reservation to have refused the order earlier. Negative stock means an order reached payment without a reservation: a non-checkout order source, backorders, or hold stock disabled.
- Do I need a stock-hold plugin to stop overselling?
- Not for checkout races; core reserves stock at order placement. Plugins that hold stock at add-to-cart change the customer experience (stock is held earlier), which can make sense for limited drops, but they are not required for correctness.
- How long does WooCommerce hold stock for unpaid orders?
- For the number of minutes in the Hold stock setting (the code defaults to 60). Developers can change it per order with the
woocommerce_order_hold_stock_minutesfilter, added in 8.8.
Overselling during sales?
I can trace where your oversold orders come from, close the gaps in your order and sync flows, and load-test checkout before your next launch.