Diagnose and Fix N+1 Query Problems in WordPress with Query Monitor

The N+1 query problem is the most common performance anti-pattern in WordPress: a loop runs one initial query to fetch N posts, then executes one additional query per post to retrieve metadata or related terms, producing N+1 total queries. On a category archive with 20 posts and a theme that calls get_post_meta() inside the loop, WordPress fires 1 query for the post list plus 20 queries for meta — 21 total — when a single pre-fetch or a JOIN would suffice. The free Query Monitor plugin surfaces this pattern by grouping duplicate queries by caller and showing the call stack that triggered each one, making the source file and line number immediately visible without reading MySQL slow query logs. WordPress has a built-in mechanism for eliminating N+1 on meta: WP_Query accepts update_post_meta_cache => true (the default) and update_post_term_cache => true (also the default), which pre-fetch all meta and terms for the queried posts in two bulk queries — but only when the query object owns the loop. Custom code that manually runs $wpdb->get_results() to fetch post IDs and then calls get_post_meta() in a loop bypasses this cache mechanism entirely. The _prime_post_caches() function accepts an array of post IDs and pre-populates the object cache for meta and terms, enabling N+1 elimination in code that does not use WP_Query. Taxonomy queries exhibit the same pattern: calling wp_get_post_terms() inside a loop fires one query per post; the fix is to pass all post IDs to wp_get_object_terms() once and index the results by post ID. WooCommerce order loops can produce hundreds of N+1 queries when $order->get_items() is called inside a loop that iterates orders — batch-loading order items via a single IN() query and grouping results by order ID is the correct approach. Setting SAVEQUERIES = true in wp-config.php stores every query in memory and must be used only in development, never on production. The replication post shows the infrastructure-level read scaling that complements query-level optimization — both layers are needed for high-traffic WordPress sites.

Problem: A WordPress theme or plugin runs one database query per post inside a loop to retrieve metadata or taxonomy terms, producing N+1 total queries that scale linearly with the number of posts and cause page timeouts on archives with more than 50 items.

Solution: Use Query Monitor to identify N+1 patterns by duplicate query signature, replace per-post get_post_meta() calls with a single _prime_post_caches() pre-fetch, and replace per-post wp_get_post_terms() calls with a bulk wp_get_object_terms() call indexed by post ID.

// BEFORE: N+1 meta queries — fires 1 + N database queries
$post_ids = $wpdb->get_col("SELECT ID FROM {$wpdb->posts} WHERE post_status='publish' LIMIT 50");

foreach ($post_ids as $id) {
    // Each call fires a separate SELECT from wp_postmeta
    $price = get_post_meta($id, '_price', true);
    echo "$id: $price\n";
}

// AFTER: 1 bulk pre-fetch + N cache reads — fires 2 database queries total
$post_ids = $wpdb->get_col("SELECT ID FROM {$wpdb->posts} WHERE post_status='publish' LIMIT 50");

// Pre-populate the meta cache and post object cache for all IDs at once
_prime_post_caches($post_ids, true, true);

foreach ($post_ids as $id) {
    // These now read from the in-memory object cache — zero extra DB queries
    $price = get_post_meta($id, '_price', true);
    echo "$id: $price\n";
}

// BEFORE: N+1 term queries
$post_ids = get_posts(['fields' => 'ids', 'numberposts' => 50]);
foreach ($post_ids as $id) {
    $cats = wp_get_post_terms($id, 'category', ['fields' => 'names']);
    echo implode(', ', $cats) . "\n";
}

// AFTER: single bulk term query indexed by post ID
$post_ids  = get_posts(['fields' => 'ids', 'numberposts' => 50]);
$all_terms = wp_get_object_terms($post_ids, 'category', ['fields' => 'all_with_object_id']);

$terms_by_post = [];
foreach ($all_terms as $term) {
    $terms_by_post[$term->object_id][] = $term->name;
}

foreach ($post_ids as $id) {
    $cats = $terms_by_post[$id] ?? [];
    echo implode(', ', $cats) . "\n";
}

// WP_Query with caches enabled (default — shown explicitly for clarity)
$query = new WP_Query([
    'post_type'              => 'post',
    'posts_per_page'         => 50,
    'update_post_meta_cache' => true,
    'update_post_term_cache' => true,
    'no_found_rows'          => true,
]);
while ($query->have_posts()) {
    $query->the_post();
    $price = get_post_meta(get_the_ID(), '_price', true); // reads from cache
    echo get_the_title() . ": $price\n";
}
wp_reset_postdata();

NOTE: Query Monitor groups queries by call stack signature — if you see the same SELECT pattern repeated N times with only the ID changing, the caller line shown in the stack trace is the N+1 source. Set SAVEQUERIES = true in wp-config.php only in development, as it stores every query string in memory and will exhaust RAM on high-traffic pages.