Avoid WordPress N+1 Queries: update_post_meta_cache, _prime_post_caches, and Batch Queries

The N+1 query problem occurs when code executes one database query to fetch a list of items, then one additional query for each item in the list to fetch related data — producing N+1 total queries for N items. In WordPress this surfaces in patterns like looping through posts and calling get_post_meta() on each one inside the loop, or iterating users and calling get_user_meta() per user. With 50 posts in a loop this produces 51 queries; with 200 posts it produces 201. WordPress’s object cache mitigates this for repeat requests, but on the first (uncached) load — or when object caching is not persistent — each call hits the database. WordPress provides several mechanisms to fetch all related data in a single batch query: cache priming flags on WP_Query, dedicated priming functions, and $wpdb batch queries with WHERE IN (...). Understanding these tools is essential for building performant admin list screens, REST API endpoints, and front-end archive templates.

Problem: A custom admin page displays 50 posts with their post meta (_event_date) and taxonomy terms. The current implementation calls get_post_meta() and get_the_terms() inside a loop, producing 101+ queries per page load.

Solution: Use WP_Query with update_post_meta_cache and update_post_term_cache to batch-prime both caches in two queries, then call get_post_meta() inside the loop — it reads from the primed cache with zero additional queries.

<?php
// ── BAD: N+1 pattern (1 query + 1 per post = 51 queries for 50 posts) ─
$posts = get_posts( [ 'posts_per_page' => 50, 'post_type' => 'event' ] );
foreach ( $posts as $post ) {
    $date  = get_post_meta( $post->ID, '_event_date', true );  // 1 query each
    $terms = get_the_terms( $post->ID, 'event_category' );     // 1 query each
    // ...
}

// ── GOOD: batch prime meta and term caches in WP_Query ────────────────
$query = new WP_Query( [
    'post_type'              => 'event',
    'posts_per_page'         => 50,
    'no_found_rows'          => true,   // skip COUNT query when pagination not needed
    'update_post_meta_cache' => true,   // prime meta cache in 1 query (default: true)
    'update_post_term_cache' => true,   // prime term cache in 1 query (default: true)
] );

while ( $query->have_posts() ) {
    $query->the_post();
    $date  = get_post_meta( get_the_ID(), '_event_date', true ); // reads from cache — 0 queries
    $terms = get_the_terms( get_the_ID(), 'event_category' );    // reads from cache — 0 queries
}
wp_reset_postdata();
// Total queries: 3 (main query + meta prime + term prime) regardless of post count

// ── When you don't need meta or terms, disable to save 2 queries ──────
$ids_only_query = new WP_Query( [
    'post_type'              => 'event',
    'posts_per_page'         => 50,
    'fields'                 => 'ids',  // return only IDs
    'no_found_rows'          => true,
    'update_post_meta_cache' => false,  // skip meta prime — not needed
    'update_post_term_cache' => false,  // skip term prime — not needed
] );

// ── Prime post caches manually for a known set of IDs ─────────────────
// Useful when you have IDs from a custom query or meta value
$post_ids = [ 42, 87, 130, 201 ];
_prime_post_caches( $post_ids, true, true ); // args: ids, update_term_cache, update_meta_cache

// ── Batch user meta with update_meta_cache ─────────────────────────────
$user_ids = [ 1, 3, 7, 12 ];
update_meta_cache( 'user', $user_ids ); // prime all user meta in one query
foreach ( $user_ids as $uid ) {
    $company = get_user_meta( $uid, 'company', true ); // reads from cache
}

// ── Batch custom query with WHERE IN ──────────────────────────────────
global $wpdb;
$ids_in      = implode( ',', array_map( 'absint', $post_ids ) );
$event_dates = $wpdb->get_results(
    "SELECT post_id, meta_value FROM {$wpdb->postmeta}
     WHERE meta_key = '_event_date'
     AND post_id IN ($ids_in)"
);
// Build a lookup map: post_id => date
$date_map = array_column( $event_dates, 'meta_value', 'post_id' );

NOTE: update_post_meta_cache and update_post_term_cache default to true in WP_Query — so standard WordPress loops already batch-load meta and terms. The N+1 problem appears when developers bypass WP_Query and use get_posts() with a custom SQL query that returns only IDs, then call get_post_meta() in a loop without priming first. In that case, call _prime_post_caches() with the ID array before the loop. For REST API endpoints that process many posts, disabling update_post_meta_cache and fetching only the specific meta you need with a single $wpdb query is often faster than the default prime-all approach.