WooCommerce 11.0 Broke a Client's Prices — How Product Object Caching Silently Serves Stale Data
· 12 min read
A client messaged me last week: "Some of our prices are wrong. The metal products are showing yesterday's rates." Their WooCommerce store sells brass and copper fittings, with prices tied to a daily commodity feed. A cron job pulls the latest metal index each morning and updates product prices accordingly. It had worked flawlessly for two years.
I checked the feed — it had run at 06:15 as usual. The _regular_price and _price values in the database were correct. But the front-end was showing yesterday's figures on some page loads and today's on others. Not every product, either — simple products displayed the right price. Variable products with multiple material options were the ones showing stale data.
WooCommerce had been updated to 11.0 three days earlier. That was the only change.
What WooCommerce 11.0 changed
WooCommerce 11.0, released on 4 August 2026, ships with a feature called product object caching. It was experimental in WooCommerce 10.5 (January 2026) and is now enabled by default for new installations. Existing stores that upgrade to 11.0 keep whatever setting they had — the upgrade does not flip it on. But this client had built their store fresh on WooCommerce 10.6 with the experimental toggle enabled, and 11.0 carried that setting forward.
Product object caching intercepts every wc_get_product() call and serves product objects from an in-memory cache scoped to the current PHP request. The first time you call wc_get_product( 42 ) in a request, WooCommerce loads the product from the database as usual. Every subsequent call for product 42 within that same request returns a cloned copy from cache instead of hitting the database again.
The cache is non-persistent — it lives only in PHP memory and clears when the request ends. It is not Redis, not Memcached, not a transient. A fresh page load always starts with an empty product cache.
The performance gain is real. WooCommerce's own benchmarks show variable product pages loading 9–12% faster and bundle products processing 6–12% faster during checkout. On a product with 800+ variations, get_variation_prices() dropped from roughly 500ms to 40–50ms on cached requests. For stores that call wc_get_product() multiple times per request — which is most of them, once you factor in shortcodes, widgets, related products, and cross-sell blocks — the savings add up.
The problem is what happens when something updates a product's data mid-request without telling the cache. I have written before about WooCommerce database performance and lookup table desync — this is a different flavour of the same theme: caching layers and raw SQL do not mix.
The root cause: raw SQL bypassing cache invalidation
The client's pricing plugin pulled commodity rates from an external API and wrote updated prices directly to the database. The relevant code looked like this:
$wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => $new_price ),
array(
'post_id' => $product_id,
'meta_key' => '_price',
)
);
$wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => $new_price ),
array(
'post_id' => $product_id,
'meta_key' => '_regular_price',
)
);
This writes directly to the wp_postmeta table using $wpdb->update(). It is fast, it works, and for two years it was fine. But it bypasses every WordPress and WooCommerce hook that the product object cache relies on for invalidation.
When you update a price through the proper channels — update_post_meta(), $product->set_regular_price() followed by $product->save(), or even a filter on woocommerce_product_get_price — WordPress fires action hooks like updated_post_meta that tell the caching layer to discard its stale copy. Raw SQL fires nothing. The cached product object, loaded earlier in the request with the old price, stays in memory. Any code that calls wc_get_product() for that product later in the same request gets the stale version.
The reason variable products were affected more visibly is that get_variation_prices() — the method WooCommerce uses to compute the "From £X" price range on variable product pages — calls wc_get_product() for each variation. With product object caching enabled, those calls are served from the in-memory cache. If the pricing plugin had already updated the variation prices via raw SQL earlier in the same request (which it did, because the cron job ran the feed sync and then triggered a cache warm-up that loaded the product pages), the cached objects still held the old prices.
How I confirmed it
First, I checked whether product object caching was actually enabled:
wp option get woocommerce_feature_product_object_caching_enabled --path=/var/www/html
It returned yes. (For stores on WooCommerce 11.0+ that were freshly installed, this is the default. For upgraded stores, check the setting at WooCommerce → Settings → Advanced → Features → Cache Product Objects.)
Next, I reproduced the issue in wp shell to prove the cache was serving stale data:
wp shell --path=/var/www/html
// Load a product — this populates the cache
$product = wc_get_product( 1042 );
echo $product->get_regular_price(); // Shows: 14.20 (yesterday's price)
// Simulate the plugin's raw SQL update
global $wpdb;
$wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => '15.85' ),
array( 'post_id' => 1042, 'meta_key' => '_regular_price' )
);
// Load the product again — from cache, not database
$product2 = wc_get_product( 1042 );
echo $product2->get_regular_price(); // Still shows: 14.20 — stale!
The database had the correct price (15.85), but wc_get_product() returned the cached copy (14.20) because nothing invalidated the cache after the raw SQL write.
To confirm the cache was the culprit, I disabled it temporarily:
wp option update woocommerce_feature_product_object_caching_enabled no --path=/var/www/html
Reloaded the product page — correct price. Re-enabled it — stale price returned on the next simulated update cycle.
The fix
There are three approaches, depending on how much control you have over the plugin code.
Option 1: Replace raw SQL with update_post_meta() (best fix)
update_post_meta( $product_id, '_price', $new_price );
update_post_meta( $product_id, '_regular_price', $new_price );
This fires updated_post_meta, which triggers cache invalidation. It is marginally slower than raw SQL on bulk operations (because it loads the old value first to compare), but it is the correct approach. For this client, I patched the pricing plugin to use update_post_meta() instead of $wpdb->update(). The performance difference on their 400-product catalogue was negligible — roughly 200ms extra on the full daily sync.
Option 2: Invalidate the cache manually after raw SQL
If you cannot change the SQL (perhaps a vendor plugin, or the bulk operation genuinely needs the speed of raw queries on thousands of products), call clean_post_cache() after each update:
$wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => $new_price ),
array( 'post_id' => $product_id, 'meta_key' => '_price' )
);
$wpdb->update(
$wpdb->postmeta,
array( 'meta_value' => $new_price ),
array( 'post_id' => $product_id, 'meta_key' => '_regular_price' )
);
clean_post_cache( $product_id );
clean_post_cache() is a WordPress core function that clears the object cache for a specific post and fires the clean_post_cache action, which WooCommerce listens for to purge its product object cache entry.
Option 3: Disable product object caching entirely
If you are not ready to audit every plugin for raw SQL writes:
wp option update woocommerce_feature_product_object_caching_enabled no --path=/var/www/html
Or toggle it off in the admin at WooCommerce → Settings → Advanced → Features → Cache Product Objects.
This sacrifices the 9–12% performance improvement but eliminates the stale data risk entirely. For stores running dynamic pricing from external feeds, this may be the pragmatic choice until the pricing plugin is updated.
How to audit your plugins for this problem
The dangerous pattern is any direct $wpdb query that writes to product-related meta keys without a subsequent cache invalidation call. You can search for it across your plugins directory:
grep -rn 'wpdb->update\|wpdb->query\|wpdb->replace' \
/var/www/html/wp-content/plugins/ \
| grep -i '_price\|_regular_price\|_sale_price\|_stock\|_sku'
This will not catch every case — some plugins use prepared statements or variable interpolation that makes the meta key harder to grep for — but it catches the majority. Any match should be investigated: does the code also call clean_post_cache(), wp_cache_delete(), or update_post_meta() nearby? If not, it is a cache invalidation gap.
For a more thorough check, search for any raw write to wp_postmeta or the WooCommerce HPOS tables:
grep -rn 'wpdb->update.*postmeta\|wpdb->query.*postmeta\|wpdb->replace.*postmeta' \
/var/www/html/wp-content/plugins/
Also check mu-plugins and any custom code in your theme's functions.php.
Which stores are affected
This issue only manifests when all three conditions are true:
-
Product object caching is enabled. New stores installed on WooCommerce 11.0 have it on by default. Existing stores that previously enabled the experimental feature in 10.5/10.6 also have it on. Stores that upgraded from older versions without touching the feature toggle are not affected — it stays off.
-
A plugin or custom code writes product meta via raw SQL. The most common offenders are dynamic pricing plugins that sync from external feeds, ERP integrations that bulk-update prices, and custom import scripts that use
$wpdbfor speed. -
The raw SQL write and the product display happen within the same PHP request. Because the cache is request-scoped, a cron job that updates prices via raw SQL at 06:00 will not cause stale prices on a customer's page load at 10:00 — the cache is empty at the start of each request. The issue appears when the price update and the product rendering happen in the same execution context, which is common in cache warm-up routines, WP-CLI scripts that update and then verify, and plugins that modify prices on-the-fly during page rendering.
What I changed for this client
- Patched the pricing plugin to use
update_post_meta()instead of$wpdb->update(). - Left product object caching enabled — the performance gain is worth keeping.
- Added a post-sync verification step to the cron job that compares database values to
wc_get_product()output for a sample of products, logging any discrepancy. - Added a note to the client's maintenance runbook: before enabling any new pricing or inventory plugin, grep its source for raw
$wpdbwrites to postmeta.
The fix took about forty minutes once I understood the root cause. The investigation took longer — the inconsistent symptoms (some products wrong, some right, varies by page load) initially pointed me toward a page caching issue rather than an application-level object cache.
The broader lesson
WooCommerce's product object caching is a genuine performance improvement, and I would not recommend disabling it unless you have a specific incompatibility. But it exposes a long-standing fragility in the WordPress ecosystem: plugins that bypass the meta API for speed have always been technically wrong, but they used to get away with it because nothing cached product objects within a request. WooCommerce 11.0 changed that assumption.
If you maintain a WooCommerce store with custom pricing logic, ERP integrations, or any plugin that bulk-updates product data, audit for raw SQL before enabling this feature. And if you have just installed a fresh WooCommerce 11.0 store, be aware that the feature is already on — your first pricing plugin install could introduce a conflict you do not expect. This is exactly the kind of subtle breakage that a WooCommerce maintenance plan catches before customers notice.
For the stores I manage, product object caching is now part of the post-update checklist: verify it is in the expected state, and if it is on, run a quick grep across plugin code for unsafe $wpdb writes. Ten minutes of auditing saves a week of wrong prices.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
