WooCommerce Stock Not Reducing After Order: The Real Fix

WooCommerce orders complete but stock levels never drop? Here is why HPOS plus page caching hides the bug and the exact hook order that fixes it today.
WooCommerceWordPressHPOS
June 6, 20266 min read1165 words

The Problem

Client called last week because their warehouse team had been shipping product that the WooCommerce dashboard still listed as "In stock, 47 available". Some of those SKUs had not had 47 units on hand since February. Orders were coming through, payments were captured, completion emails went out, but the _stock value on the product never moved.

The setup was the usual mid-size store: WooCommerce 10.8, HPOS enabled, a managed WordPress host with full-page caching, two payment gateways (Stripe Checkout and PayPal), and a "low stock" notification plugin that had silently been pulling cached values for months.

I confirmed the bug with a fresh test order:

// Before placing test order
$product = wc_get_product( 1234 );
error_log( 'Pre-order stock: ' . $product->get_stock_quantity() );
// Pre-order stock: 47

// After payment captured, order moved to processing
$product = wc_get_product( 1234 );
error_log( 'Post-order stock: ' . $product->get_stock_quantity() );
// Post-order stock: 47

Same number. The order itself had _reduced_stock meta set to 1, so internally WooCommerce thought the reduction had happened. The product row in wp_wc_product_meta_lookup told a different story.

Why It Happens

There are three things that can break stock reduction on a modern WooCommerce store, and on this client we had all three at once.

The first is Manage stock not being set per product. The global setting under WooCommerce > Settings > Products > Inventory is a default for new products. If a product was imported from a CSV without manage_stock set to yes, the per-product flag wins and the global is ignored. wc_maybe_reduce_stock_levels() checks the per-product flag first and exits early when it is empty.

The second is the HPOS lookup table getting out of sync with wp_postmeta. When HPOS is on with backward compatibility off, the source of truth for order data is wp_wc_orders and friends, but the stock reduction code path still calls wc_update_product_stock(), which writes to wp_wc_product_meta_lookup. If a previous sync run failed halfway, the lookup table holds a stale value and the reduction silently writes to a row the rest of the store ignores. The HPOS reference calls this out under "stale lookup data".

The third is the page cache holding wc-ajax=update_order_review and wc-ajax=checkout responses. When the cart fragment refresh is cached, the second customer who hits the product page sees the pre-purchase stock count and the "low stock" plugin reads that same cached payload. Stripe Checkout in redirect mode is especially affected because the customer leaves the site entirely, then returns to a thank-you URL that the cache treats as a brand-new visit.

Layer those together and you get the symptom my client had: orders complete, _reduced_stock is set on the order, but the product surface area used by the storefront, admin lists, and reporting is all looking at frozen data.

The Fix

Fix the three causes in order. Each step is verifiable before you move on.

Step 1: Force manage_stock on every product that should have inventory. Run this WP-CLI command on staging first, then production:

wp wc product list --manage_stock=no --field=id --user=1 \
  | xargs -I {} wp wc product update {} --manage_stock=true --user=1

If that returns zero IDs, you are fine. If it returns a list, every one of those products has been silently selling without decrementing.

Step 2: Rebuild the product meta lookup table. WooCommerce ships a tool for this under WooCommerce > Status > Tools > Regenerate product lookup tables. The CLI version is faster and gives you a log:

wp wc tool run regenerate_product_lookup_tables --user=1

While that runs, do not place test orders, because the rebuild truncates the lookup table and repopulates it. After it finishes, query a recently-sold SKU directly:

SELECT product_id, stock_quantity, stock_status
FROM wp_wc_product_meta_lookup
WHERE product_id = 1234;

The stock_quantity should now match the value on the product edit screen. If it does not, you have a third-party plugin writing to _stock outside of WooCommerce's API. Find it with this:

wp eval 'add_action( "updated_post_meta", function( $mid, $oid, $key ) {
  if ( $key === "_stock" ) error_log( "_stock updated by: " . wp_debug_backtrace_summary() );
}, 10, 3 );'

Place a test order and read the resulting log entry. The backtrace tells you which plugin is reaching past the API.

Step 3: Bypass the page cache on AJAX and thank-you URLs. This is host-specific, but the rules are the same. For Cloudflare, add a cache rule that bypasses anything matching *wc-ajax=* or /checkout/order-received/*. For LiteSpeed or WP Rocket, exclude the same patterns in the plugin UI. For Nginx FastCGI cache, add this to the server block:

set $skip_cache 0;
if ($request_uri ~* "wc-ajax=|/checkout/|/cart/|/my-account/|/order-received/") {
    set $skip_cache 1;
}
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;

Reload Nginx, then place one more test order with browser dev tools open. The response headers on the order-received page should show X-Cache: MISS or your host's equivalent.

Step 4: Pick the right reduction hook for your gateway mix. WooCommerce reduces stock on woocommerce_payment_complete for gateways that capture immediately, and on woocommerce_order_status_processing for gateways that move the order to processing without firing payment_complete. Stripe Checkout in redirect mode and PayPal Standard both fall into the second bucket. If you have custom code overriding the default, make sure both hooks are wired:

add_action( 'woocommerce_payment_complete', 'qs_reduce_stock_safely', 10, 1 );
add_action( 'woocommerce_order_status_processing', 'qs_reduce_stock_safely', 10, 1 );

function qs_reduce_stock_safely( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( ! $order || $order->get_data_store()->get_stock_reduced( $order_id ) ) {
        return;
    }
    wc_reduce_stock_levels( $order );
}

The get_stock_reduced check is the guard that keeps you from double-decrementing if both hooks fire on the same order. Without it, a customer who pays with Stripe Checkout can drop stock by two units per order during cache flushes.

The Lesson

When WooCommerce stock will not reduce, three things are usually wrong at once: manage_stock was lost during an import, the lookup table is stale from a partial HPOS sync, and the page cache is serving the pre-purchase fragment back to the storefront. Fix them in that order, verify each one against the database, then audit the gateways your store actually uses to confirm the reduction hook fires. The dashboard agrees with reality after that.

If your WooCommerce store is leaking inventory like this, that is exactly the kind of audit I run for clients. See my services. For another HPOS-era stock gotcha, read WooCommerce HPOS bulk status update failing.

Need someone to find the inventory leak in your store? Get it fixed.

Back to blogStart a project