Your category archive loads in 1.8 seconds. Query Monitor shows 200 queries, most of them identical SELECT statements against wp_term_relationships and wp_term_taxonomy. You assume a plugin is at fault. It is not. The template is calling the one term function that never reads the cache WordPress already primed for you.
Symptom
An archive loop that renders a category label per post produces a query count that scales linearly with posts_per_page. Set the archive to 30 posts and you get roughly 30 extra term queries. Set it to 100 and you get roughly 100. The queries are near-identical, differing only in the object_id passed to wp_term_relationships.
Query Monitor’s query list is the fastest confirmation. Look for repeated SELECT statements joining wp_term_relationships, wp_term_taxonomy, and wp_terms, each with a different object_id IN (...) clause. If the count matches your loop length, you have an N+1 introduced by the template, not by WP_Query.
A quick check without Query Monitor: enable SAVEQUERIES in wp-config.php and dump the log at the end of the request.
define( 'SAVEQUERIES', true );
// In a mu-plugin or template footer during debugging:
add_action( 'shutdown', function () {
global $wpdb;
$term_queries = array_filter( $wpdb->queries, function ( $q ) {
return str_contains( $q[0], 'wp_term_relationships' );
} );
error_log( 'term queries: ' . count( $term_queries ) );
} );
If that number is close to your loop length plus one, the diagnosis is settled.
Trace
Two functions look interchangeable and are not. The first is wp_get_post_terms(). Its entire body is a wrapper:
function wp_get_post_terms( $post_id = 0, $taxonomy = 'post_tag', $args = array() ) {
$post_id = (int) $post_id;
$defaults = array( 'fields' => 'all' );
$args = wp_parse_args( $args, $defaults );
$tags = wp_get_object_terms( $post_id, $taxonomy, $args );
return $tags;
}
That is the whole function, per the WordPress developer reference. It parses defaults and delegates to wp_get_object_terms(). It does not consult the object cache at any point.
The second is get_the_terms(). Its body reads the cache first:
$terms = get_object_term_cache( $post->ID, $taxonomy );
if ( false === $terms ) {
$terms = wp_get_object_terms( $post->ID, $taxonomy );
if ( ! is_wp_error( $terms ) ) {
$term_ids = wp_list_pluck( $terms, 'term_id' );
wp_cache_add( $post->ID, $term_ids, $taxonomy . '_relationships' );
}
}
That is the source from the get_the_terms() reference page. On a cache hit it returns immediately. On a miss it queries once and writes the result back into the {taxonomy}_relationships cache group.
The wp_get_object_terms() reference page states the consequence plainly: “the results from wp_get_object_terms are not cached which will result in a db call everytime this function is called. For performance, functions like get_the_terms() (which the results of has been cached), should be used.”
So the two paths diverge at the first line. wp_get_post_terms() goes straight to the database. get_the_terms() checks the cache group first.
What WP_Query Already Did
Before your template runs, WP_Query has already primed the term cache for the posts in the loop. The function responsible is update_object_term_cache(), which is called by update_post_caches() and _prime_post_caches() according to the function reference.
Here is what it does. First, it checks the cache group per taxonomy for every post ID in the batch:
$taxonomies = get_object_taxonomies( $object_type );
foreach ( $taxonomies as $taxonomy ) {
$cache_values = wp_cache_get_multiple( (array) $object_ids, "{$taxonomy}_relationships" );
foreach ( $cache_values as $id => $value ) {
if ( false === $value ) {
$non_cached_ids[] = $id;
}
}
}
Only the IDs that miss the cache are collected. Then a single batched query runs for all of them:
$terms = wp_get_object_terms( $non_cached_ids, $taxonomies, array(
'fields' => 'all_with_object_id',
'orderby' => 'name',
'update_term_meta_cache' => false,
) );
One query for the whole loop. The results are assembled per taxonomy and written back with wp_cache_add_multiple():
foreach ( $cache_values as $taxonomy => $data ) {
wp_cache_add_multiple( $data, "{$taxonomy}_relationships" );
}
By the time your template’s while ( have_posts() ) loop starts, the {taxonomy}_relationships cache group already holds the term IDs for every post in the query. The cache is warm. The question is whether your template reads it.
Root Cause
The template calls wp_get_post_terms() inside the loop. That function never touches {taxonomy}_relationships. It goes directly to wp_get_object_terms(), which runs a fresh query for each post. The primed cache sits unused.
The N+1 is not in WP_Query. It is in the template. WP_Query did its job: one batched query, one cache write. The template then bypassed the cache entirely, once per iteration.
This is the diagnostic tell when reading core source: a function that reads {taxonomy}_relationships participates in the loop-priming contract. A function that goes straight to wp_get_object_terms() does not. The cache group name in the source is the signal.
Two clarifications that matter for the fix.
First, wp_get_post_terms() is not a bug. It exists because get_the_terms() does not accept the third $args parameter, as the wp_get_post_terms() reference page notes. When you need fields => 'ids' or fields => 'names', wp_get_post_terms() is the correct function. The problem is placement, not existence.
Second, a persistent object cache does not fix this. The {taxonomy}_relationships group is populated per request by update_object_term_cache(). The template’s uncached call issues its own query regardless of whether Redis or Memcached is present. Adding a persistent backend to a template that calls wp_get_post_terms() in a loop changes nothing about the query count.
Fix
Replace wp_get_post_terms() with get_the_terms() inside the loop. The signature is different: get_the_terms( $post, $taxonomy ) takes two arguments, not three.
Before:
while ( have_posts() ) {
the_post();
$terms = wp_get_post_terms( get_the_ID(), 'category' );
foreach ( $terms as $term ) {
echo esc_html( $term->name );
}
}
After:
while ( have_posts() ) {
the_post();
$terms = get_the_terms( get_the_ID(), 'category' );
if ( $terms && ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
echo esc_html( $term->name );
}
}
}
The second version reads the primed cache and issues zero additional queries for posts already primed by WP_Query.
If you need fields => 'ids' or fields => 'names', you cannot use get_the_terms(). In that case, hoist the call out of the loop and batch it. Collect the post IDs first, then call wp_get_object_terms() once with the full array:
$post_ids = wp_list_pluck( $query->posts, 'ID' );
$all_terms = wp_get_object_terms( $post_ids, 'category', array(
'fields' => 'all_with_object_id',
) );
// Group by object_id for use in the loop.
$terms_by_post = array();
foreach ( $all_terms as $term ) {
$terms_by_post[ $term->object_id ][] = $term;
}
One query for the whole loop instead of one per post. The all_with_object_id field is what makes the grouping possible; it is documented in the WP_Term_Query reference as returning term objects with the object_id property populated.
Verification
Query Monitor is the fastest check. The WP_Object_Cache reference lists it alongside Debug Bar as a tool for analyzing the object cache. Load the archive before the fix and note the count of queries against wp_term_relationships. Load it after and note the count again.
What you should see: the per-post term queries disappear. One priming query remains, issued by update_object_term_cache() before the loop. The total query count drops by roughly the loop length.
If you want to confirm the cache is being read rather than just assuming, check the cache hit counter. The WP_Object_Cache class exposes $cache_hits and $cache_misses as public properties. A quick mu-plugin can log them:
add_action( 'shutdown', function () {
global $wp_object_cache;
error_log( sprintf(
'cache hits: %d, misses: %d',
$wp_object_cache->cache_hits,
$wp_object_cache->cache_misses
) );
} );
Before the fix, the miss count will be high because every wp_get_post_terms() call bypasses the cache entirely. After the fix, the hits should increase and the misses should drop.
For a more targeted check, inspect the {taxonomy}_relationships group directly. The cache key is the post ID, and the value is an array of term IDs:
$term_ids = wp_cache_get( get_the_ID(), 'category_relationships' );
var_dump( $term_ids ); // array of ints, or false on miss
If that returns an array of term IDs inside the loop, the cache is primed and get_the_terms() will read it. If it returns false, something upstream prevented priming, and you should check whether the query has update_post_term_cache set to false or whether the taxonomy is registered in a way that excludes it from the default priming path.
Why This Keeps Happening
The two functions have nearly identical names and nearly identical signatures. wp_get_post_terms( $post_id, $taxonomy, $args ) and get_the_terms( $post, $taxonomy ) differ by one argument and one prefix. The prefix is the tell: wp_get_* functions in WordPress tend to be lower-level and uncached; get_the_* functions tend to be template-facing and cached. That is not a universal rule, but it holds here.
The deeper issue is that the cache group name is not visible from the calling code. You have to read the source of both functions to see that one reads {taxonomy}_relationships and the other does not. The function reference pages do state it, but the distinction is buried in user-contributed notes rather than in the function signatures.
The general rule for this class of failure: when a core function has a cached sibling, the uncached one is a deliberate escape hatch for arguments the cached one cannot express. Using it inside a loop is the bug. The cache group name in the source is the tell.
FAQ
Does this affect single post templates too?
On a single post, wp_get_post_terms() issues one query. That is not an N+1. The failure mode is specific to loops where the same call repeats per iteration. If your single template calls it once, the cost is negligible.
Will a persistent object cache fix this?
No. The {taxonomy}_relationships group is populated per request by update_object_term_cache(). The template’s uncached call issues its own query regardless of whether a persistent backend is present. The fix is the function swap, not the cache backend.
What about the ‘exponential’ warning in the update_object_term_cache() docs?
That warning is about the priming call itself, not about the template’s per-post calls. The reference page states: “Processing time can increase exponentially depending on both the number of passed term IDs and the number of taxonomies those terms belong to.” That is a caution about calling update_object_term_cache() with large ID sets. The template’s N+1 is linear in post count. Conflating the two leads teams to optimize the wrong function.
Can I just remove wp_get_post_terms() from the codebase?
No. It is the correct function when you need $args that get_the_terms() cannot express, particularly fields. The rule is placement: uncached calls belong outside the loop or batched, not per-iteration.
How do I know if the cache was primed for my query?
Check wp_cache_get( $post_id, $taxonomy . '_relationships' ) inside the loop. If it returns an array of term IDs, the cache is primed. If it returns false, check whether the query has update_post_term_cache set to false or whether the taxonomy is excluded from the default priming path. The WP_Term_Query reference documents cache_results and update_term_meta_cache as separate parameters, both defaulting to true.