Why One update_post_meta() Call Empties Your Query Cache
WP_Query caches its results, then a single metadata write clears the whole group. How to read that behaviour with SAVEQUERIES and what to turn off.
Martin Kiel · @mkiel
WordPress internals
- Topic
- WP
- Section
- WordPress Internals
- Published
- 24 AUG 2026
- Reading time
- 7 MIN
- Words
- 1943W
A developer adds Redis to a site, watches the uncached TTFB drop, and then cannot explain why the query count on a warm request is higher than it was before. The cache is working. The loop is still there.
The same confusion shows up in the other direction. Someone reads that WordPress caches
queries, assumes a repeated WP_Query with identical arguments costs nothing, and is
surprised to find the same SELECT in the slow query log twice in one request. It does cost
nothing, usually. The word doing the work in that sentence is usually, and the reason is that
query results are stored under a key that includes a counter.
Query results are cached under a key that includes a counter
WP_Query caches the outcome of its main post query, not the HTML and not the
post objects. It stores three things: the array of matching post IDs, the total row count, and
the page count. On a repeat of the same request, it reads the ID list from the cache and then
primes the post, meta and term caches from that list.
On WordPress 6.3 and newer those results live in a cache group called
post-queries. Older versions put them in a group called wp_query, but
the shape of the key has not changed in the way that matters here. The key is the hash of a
comparison between the full database statement and the query arguments, with a few arguments
stripped out first, and then the last-changed counters are appended to it:
// The cache key construction, condensed. Version scope: WordPress 6.3 and newer.
$key = md5( serialize( $args ) . $sql );
$last_changed = (array) wp_cache_get_last_changed( 'posts' );
if ( ! empty( $this->tax_query->queries ) ) {
$last_changed[] = wp_cache_get_last_changed( 'terms' );
}
// Roughly: wp_query:<hash>:<counter>[:<term counter>]
return "wp_query:$key:" . implode( ':', $last_changed );
Several arguments are removed from the hash before it is computed, because they do not change
which rows come back: cache_results, fields,
lazy_load_term_meta, update_post_meta_cache,
update_post_term_cache, update_menu_item_cache and
suppress_filters. So a query that disables metadata priming and a query that enables
it share a cache key. That is worth remembering when you compare two otherwise identical queries
and get identical timings.
Two kinds of query bypass the cache entirely. An orderby containing
RAND() is excluded by name, which is a kindness: randomised results have no business
being cached. And if a filter has rewritten the selected fields into something other than one of
the three field lists the query builder recognises, caching is switched off for that query. A
plugin that narrows the columns through posts_fields loses the cache for everything
it touches, and it will not say so.
A counter is how WordPress avoids scanning the cache
The interface between a cached query and the counter is one function,
wp_cache_get_last_changed( 'posts' ). It reads the key last_changed from
the posts cache group, and if the key is missing it sets it, so the first call of a
request performs a write as well as a read. wp_cache_set_posts_last_changed() is the
wrapper that every write path calls when it wants to move the counter.
This is what people mean when they say a cache group is invalidated as a whole. Nothing scans
the cache looking for keys that belong to a group, which on Redis would be a KEYS
command and on any backend would be a bad time. The keys carry the group's counter, the counter
moves, and the old keys become garbage that expires on its own schedule. It is a cheap trick and
it works, and the price is that a single write can strand a large number of entries at once.
update_post_meta() empties more than the row you wrote
The function writes one row in wp_postmeta, deletes the cached metadata for that
object, and stops. What actually clears the query cache is one step further out. WordPress
registers wp_cache_set_posts_last_changed on the updated_post_meta
action in its default filters, and that action fires once per updated metadata row. The counter
for the posts group moves, and from that moment the cache key for every previously
cached query on the site no longer matches.
Post titles, slugs, status and dates reach the same function through
clean_post_cache(), which also deletes the individual post and metadata entries and
the parent-relationship entry. On WordPress 6.2 and older that final counter bump lived in a
different place, so the accounting looked slightly different, but the outcome for a cached query
was the same.
Here is the effect measured on a scratch install, not estimated. The environment: WordPress
6.9, PHP 8.5.6, MySQL 9.6, no persistent object cache and therefore the default in-process array
cache, 500 published posts with 3,495 postmeta rows and 2,496 term relationships. Queries were
counted from $wpdb->queries with SAVEQUERIES enabled.
| Step | Queries issued | What it shows |
|---|---|---|
| First run, default args, 12 posts | 7 | ID query, FOUND_ROWS(), the post rows, then the meta and term primes. |
| Second run, identical args | 0 | The cache key matched. |
One update_post_meta() call | 3 | Reloading the post's meta, a lookup for the meta id, then the UPDATE. |
| The same query again | 3 | The key moved; the query had to run again. |
A plugin that stamps a "last viewed" or "stock checked" meta value on every post in a loop moves that counter once per post. On a page that renders twenty of them, the first query of the request is cached and every later query for the same arguments is not.
Counting queries instead of guessing at them
The cheap diagnostic needs no plugin. Define SAVEQUERIES in
wp-config.php, which makes wpdb record every statement along with its
duration and a backtrace, then print the per-request count and the duplicates at the end of the
request. This is development-machine work: the recording costs memory and time you do not want in
production.
define( 'SAVEQUERIES', true );
add_action( 'shutdown', function () {
global $wpdb;
if ( ! defined( 'SAVEQUERIES' ) || ! is_array( $wpdb->queries ) ) {
return;
}
$total = count( $wpdb->queries );
$seen = array();
// Row 0 is the SQL, row 1 the duration, row 2 the caller chain.
foreach ( $wpdb->queries as $row ) {
$sql = preg_replace( '/\s+/', ' ', $row[0] );
$seen[ $sql ] = ( $seen[ $sql ] ?? 0 ) + 1;
}
arsort( $seen );
error_log( "queries: $total" );
foreach ( array_slice( $seen, 0, 10, true ) as $sql => $count ) {
if ( $count > 1 ) {
error_log( "x$count " . substr( $sql, 0, 140 ) );
}
}
} );
Query Monitor does the same job with a better interface, and its duplicate-query panel is the
reason to install it. The behaviour to look for is specific: one SELECT over
wp_posts that appears once on a cold request and once again later in the same request.
That second appearance is a cache key that changed underneath you, and the write that changed it
happened between the two.
The three arguments that decide what the query costs
None of these turn the cache off. They change what the query does on the path where the cache misses, which on a cold cache, an invalidated cache or a low-traffic site is the path you are actually on.
| Argument | Default | What it costs | Set it false when |
|---|---|---|---|
no_found_rows | false | Adds SQL_CALC_FOUND_ROWS and a second SELECT FOUND_ROWS(). | You never paginate and never read found_posts. |
update_post_meta_cache | true | One extra SELECT priming metadata for every returned post, whether or not you read it. | The loop reads no metadata, or only reads it on a few posts. |
update_post_term_cache | true | One extra SELECT priming terms for every returned post. | You render no terms on that page, or only term counts. |
cache_results | true | Adds a cache read and a cache write; the SELECT is skipped only on an exact repeat. | Arguments are unique per request, so the key will never be reused. |
The measured difference is smaller than the folklore suggests and larger than nothing. With
the same setup as above and a cold cache, one 12-post query cost 7 queries with the defaults, 6
with no_found_rows or with metadata priming off, 5 with term priming off, and 3 with
all three off. The four that the defaults add are the FOUND_ROWS() call, the second
pass over wp_posts to build the objects, the metadata prime and the term prime. None
of those numbers is a law; they are what a twelve-post page costs on a 500-post site with one meta
structure and two taxonomies attached to it.
cache_results deserves a note because it is the argument people reach for first
and benefit from least. Setting it to false removes the cache write, and the SELECT
runs every time regardless. If the same query runs once per request, you have saved a write and
gained nothing. If the same query runs five times on one page, because a shortcode and a widget
and a template part each build it, leave it on.
The cache did not fix the loop, it postponed it
This is the part that makes warm-cache benchmarks misleading, and the numbers above show it
plainly. With a cold cache and default arguments, the twelve-post query primed metadata and terms
and then twelve calls to get_post_meta() and twelve calls to
get_the_terms() inside the loop added nothing. With
update_post_meta_cache => false, the same twelve get_post_meta()
calls cost twelve queries. Metadata was cached per object already, so the difference was the key
you asked for, not whether caching exists.
The unavoidable case is a loop that reads something the query cannot prime.
wp_get_post_terms() went to the database once per post even with term priming on,
because it builds its own query rather than reading the primed object cache, while
get_the_terms() on the same posts cost nothing. Twelve posts, two functions, twelve
queries apart. Nothing about the object cache changes that; it is an API choice inside the loop.
So the rule I would apply: enable a persistent object cache, then measure the cold path, then look at what the loop asks for. A cache that turns an N+1 into an N+0 on a warm request has moved the cost to the first request after a flush, to the low-traffic hour of the night, and to every request on a host where the object cache keeps evicting under memory pressure. On a busy site those moments are rare and the trade is worth making. On a quiet one, where the cache is cold most of the time, the loop is still the loop and the cache is decoration.
What this approach does not solve: it will not tell you which of your loops is wrong. Query
counts tell you how many statements ran, and the duplicate panel tells you one ran twice. Neither
says that a template part is calling get_post_meta() once per post for a value it
could have fetched in the priming query. That reading is still yours to do, one backtrace at a
time.
What to do on Monday
- Turn on
SAVEQUERIESon a staging copy and log the query count for your three heaviest templates. That number is your baseline. - Log the duplicate statements, not just the total. Repeats are where cache keys are moving.
- Set
no_found_rowstotrueon every query whose results you do not paginate, thenupdate_post_meta_cacheandupdate_post_term_cachetofalseon queries whose loops read neither. - Look for
update_post_meta()inside a loop before you look at anything else. Each call invalidates the group for every query on the page after it. - Re-measure with the object cache flushed. A number from a warm cache is not a measurement of your site; it is a measurement of your cache.
Martin Kiel · @mkiel
WordPress internals
Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.
Continue