Skip to content
System nominalUTC --:--:--Articles 041

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
Technical cover graphic for Why One update_post_meta() Call Empties Your Query Cache
Generated schematic — WP series, REV 2.6.

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.

StepQueries issuedWhat it shows
First run, default args, 12 posts7ID query, FOUND_ROWS(), the post rows, then the meta and term primes.
Second run, identical args0The cache key matched.
One update_post_meta() call3Reloading the post's meta, a lookup for the meta id, then the UPDATE.
The same query again3The 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.

ArgumentDefaultWhat it costsSet it false when
no_found_rowsfalseAdds SQL_CALC_FOUND_ROWS and a second SELECT FOUND_ROWS().You never paginate and never read found_posts.
update_post_meta_cachetrueOne 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_cachetrueOne extra SELECT priming terms for every returned post.You render no terms on that page, or only term counts.
cache_resultstrueAdds 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

  1. Turn on SAVEQUERIES on a staging copy and log the query count for your three heaviest templates. That number is your baseline.
  2. Log the duplicate statements, not just the total. Repeats are where cache keys are moving.
  3. Set no_found_rows to true on every query whose results you do not paginate, then update_post_meta_cache and update_post_term_cache to false on queries whose loops read neither.
  4. 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.
  5. 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

Related in WordPress Internals