Root Cause Diagnosis
WooCommerce reduces stock through wc_maybe_reduce_stock_levels(), hooked to woocommerce_payment_complete, woocommerce_order_status_processing, woocommerce_order_status_on-hold and woocommerce_order_status_completed. The function exits silently if global stock management is off, the product has manage_stock disabled, the order’s _order_stock_reduced flag is already set, or a filter returns false. COD orders never call payment_complete(), so reduction depends entirely on the status-transition hooks firing and passing those guards. For a COD order that moved processing → completed, stock was normally already reduced at processing, so no second reduction occurs at completed.
Quick Fix
Verify the global setting and the product setting, then reduce stock manually for an affected order (replace 123 with the order ID and 456 with the product ID):
wp option get woocommerce_manage_stock # must return: yes
wp post meta get 456 _manage_stock # must return: yes
wp eval '
$id = 123;
$order = wc_get_order( $id );
foreach ( $order->get_items() as $item ) {
$item->delete_meta_data( "_reduced_stock" );
$item->save();
}
$order->get_data_store()->set_stock_reduced( $id, false );
wc_reduce_stock_levels( $id );
'
Step-by-Step Resolution
1. Check the order notes. Open WooCommerce → Orders → [order] and read the notes panel. A line such as Stock levels reduced: Product (SKU) 10→9 means reduction already ran, likely at processing. If no such note exists at any status change, continue.
2. Enable global stock management. Go to WooCommerce → Settings → Products → Inventory and check Enable stock management. When this is off, wc_reduce_stock_levels() returns immediately.
3. Enable product-level stock management. Go to Products → [product] → Edit → Inventory and check Manage stock?. For variable products, either enable it on the parent (variations set to “Parent” inherit it) or enable it per variation under Variations → [variation] → Manage stock?.
4. Inspect the order’s reduction flags. If an order was cloned, imported, or created by a script that copied meta, the flag may be set although stock never changed.
wp eval '
$o = wc_get_order( 123 );
var_dump( $o->get_data_store()->get_stock_reduced( 123 ) );
foreach ( $o->get_items() as $i ) { var_dump( $i->get_meta( "_reduced_stock" ) ); }
'
true or a non-empty item value with no matching order note confirms a stale flag. Clear it using the Quick Fix snippet.
5. Detect filters blocking reduction.
wp eval '
var_dump( has_filter( "woocommerce_can_reduce_order_stock" ) );
var_dump( has_filter( "woocommerce_payment_complete_reduce_order_stock" ) );
'
A non-false result means a theme or plugin is hooked. Isolate it by deactivating plugins one by one:
wp plugin deactivate --all --skip-plugins
wp plugin activate woocommerce
Retest, then reactivate plugins individually. Inventory sync, ERP, multi-warehouse, and order auto-complete plugins are the usual cause.
6. Audit custom code that sets order status. Status changes must go through the CRUD API so the transition hooks fire:
$order = wc_get_order( $order_id );
$order->update_status( 'completed' ); // fires woocommerce_order_status_completed
These bypass the hooks and will not reduce stock:
wp_update_post( [ 'ID' => $order_id, 'post_status' => 'wc-completed' ] );
$wpdb->update( $wpdb->posts, [ 'post_status' => 'wc-completed' ], [ 'ID' => $order_id ] );
7. Flush stale stock displays. If the _stock value is correct in the database but the storefront or admin shows the old quantity:
wp wc tool run clear_transients --user=1
wp cache flush
Then purge the page cache (Cloudflare, Nginx FastCGI cache, LiteSpeed, etc.).
8. Confirm the fix. Place a test COD order for a stock-managed product and check that the order note appears once at processing. Then verify the value:
wp post meta get 456 _stock
Why This Fails for Some Users
- Reduction occurs at
processing, notcompleted. COD orders usually enterprocessingat checkout, so stock drops at checkout and nothing changes atcompleted. The_order_stock_reducedflag prevents a double reduction. - COD gateway status override. The
woocommerce_cod_process_payment_order_statusfilter or a custom checkout plugin can leave orders inpending. Stock is then only held, not reduced, until a reducing status transition occurs. - HPOS (High-Performance Order Storage). Stock flags live in order meta tables. If HPOS sync is on and
wp_postmetaandwc_orders_metadiverge, a stale_order_stock_reducedvalue can persist. Check WooCommerce → Settings → Advanced → Features and resolve sync discrepancies before editing flags. - Direct SQL or imported orders. CSV importers and REST clients that write status directly do not trigger transition hooks, and orders created through them have no
_reduced_stockitem meta. - Persistent object cache (Redis/Memcached). A misconfigured key prefix or a shared cache across sites can serve stale
_stockpost meta even though the database row is correct. Verify withwp db query "SELECT meta_value FROM wp_postmeta WHERE post_id=456 AND meta_key='_stock'"and compare it withwp post meta get 456 _stock. - Variations inheriting from the parent. If the parent has Manage stock? disabled and the variation’s stock setting is “Parent”,
managing_stock()returnsfalseand the item is skipped. - Table prefix mismatch. Replace
wp_in the SQL above with your actual prefix.