WordPress WP_Query Performance — Writing Efficient Database Queries
WP_Query performance is one of the most overlooked causes of slow WordPress sites. Every WordPress site eventually develops a slow page. The usual suspects — unoptimised images, render-blocking scripts, no caching — get fixed first. Then the page is still slow. A database profiler reveals dozens of queries, several taking 400 milliseconds each, some running four times on the same page load. The culprit is almost always WP_Query used without understanding what it costs.
WP_Query is the correct tool for fetching posts in WordPress. It handles caching, permissions, post status filtering, and hook integration. The problem is not the class — it is the arguments passed to it. A single poorly constructed query can add two seconds to a page load on a site with ten thousand posts. The same data fetched correctly takes eight milliseconds. Understanding WP_Query performance starts with understanding what each argument group does to the generated SQL.
What WP_Query actually does to the database
Every WP_Query call translates to at least one SQL query against wp_posts. With default arguments, it also runs a second query to count total matching posts for pagination. Add a meta_query and it joins wp_postmeta. Add a tax_query and it joins wp_term_relationships and wp_term_taxonomy. Add both and the query plan multiplies.
The Query Monitor plugin makes this visible. Install it on a staging environment, load a page with several widget areas and a complex archive template, and read the query log. A typical poorly optimised page with three custom queries, a sidebar of recent posts, and a related posts section will show forty to sixty database calls. Many are duplicates. Several are full table scans. Fixing WP_Query performance on these pages can cut load time by 60–80%.
WP_Query Performance Pattern 1 — Avoid meta_query value scans
meta_query without an index. Post meta is stored in a single flat table — wp_postmeta — with columns for post ID, meta key, and meta value. There is no index on meta_value by default. A query filtering on a meta value scans every row in the table. On a site with fifty thousand posts and ten meta rows per post, that is five hundred thousand rows per query. The fix is either indexing the column (requires database access) or restructuring to avoid filtering on meta values entirely.
// Slow — scans all of wp_postmeta for a value match
$query = new WP_Query( [
'post_type' => 'project',
'meta_query' => [
[
'key' => 'project_featured',
'value' => '1',
'compare' => '=',
],
],
] );
// Faster — filter by key existence only (uses the meta_key index)
$query = new WP_Query( [
'post_type' => 'project',
'meta_key' => 'project_featured',
'meta_value' => '1',
] );
WP_Query Performance Pattern 2 — Never use posts_per_page -1
posts_per_page => -1. This retrieves every matching post in a single query with no limit clause. On an archive with two thousand posts, it loads all two thousand into memory, builds two thousand post objects, and holds them in the PHP process for the duration of the request. Even if the template only renders the first ten, the database delivered all of them. Always set an explicit limit. If you genuinely need all posts, paginate the query in batches. This single change has the biggest WP_Query performance impact on content-heavy sites.
// Dangerous — loads every matching post into memory
$query = new WP_Query( [ 'post_type' => 'project', 'posts_per_page' => -1 ] );
// Better — batch in chunks of 100 if you need all records
$paged = 1;
do {
$query = new WP_Query( [
'post_type' => 'project',
'posts_per_page' => 100,
'paged' => $paged,
'no_found_rows' => true,
] );
// process $query->posts
$paged++;
} while ( $query->have_posts() );
WP_Query Performance Pattern 3 — Disable the count query
Missing no_found_rows on non-paginated queries. By default, WP_Query runs SQL_CALC_FOUND_ROWS on every query, which forces MySQL to count all matching rows even when the query has a LIMIT. This doubles the work for queries that do not need pagination. If you are fetching the three most recent posts for a widget, you will never paginate — disable the count. This is the most common WP_Query performance improvement that costs zero effort.
// Every widget, related posts block, and sidebar query should include this
$recent = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 3,
'no_found_rows' => true, // skip the COUNT(*) query
] );
WP_Query Performance Pattern 4 — Use pre_get_posts for archive queries
Archive pages, category pages, search results, and author pages all run a main query before the template loads. Adding a second WP_Query call inside the template to re-fetch posts with different parameters runs two queries where one would suffice. The correct approach is modifying the main query before it runs using the pre_get_posts hook. For a deeper look at how to control archive queries, see the pre_get_posts control snippet.
// Wrong — secondary query inside the template duplicates work
$projects = new WP_Query( [
'post_type' => 'project',
'posts_per_page' => 12,
'orderby' => 'date',
'no_found_rows' => false,
] );
// Right — modify the main query before it runs
add_action( 'pre_get_posts', function ( WP_Query $q ) {
if ( ! $q->is_main_query() || is_admin() ) return;
if ( $q->is_post_type_archive( 'project' ) ) {
$q->set( 'posts_per_page', 12 );
$q->set( 'orderby', 'date' );
}
} );
The hook fires on every query WordPress runs, so the is_main_query() and is_admin() checks are essential. Without them, the modification applies to every query site-wide, including admin queries, widget queries, and REST API requests.
WP_Query Performance Pattern 5 — Reduce returned fields
By default, WP_Query fetches every column from wp_posts and builds a full WP_Post object for every result. If you only need post IDs — to build a related posts list, check post existence, or pass IDs to another function — fetching full objects wastes memory and processing time. Using fields => 'ids' is one of the simplest WP_Query performance improvements available.
// Returns full WP_Post objects — expensive for ID-only use cases
$query = new WP_Query( [ 'post_type' => 'project', 'posts_per_page' => 10 ] );
// Returns only IDs — minimal memory, faster query
$query = new WP_Query( [
'post_type' => 'project',
'posts_per_page' => 10,
'fields' => 'ids',
'no_found_rows' => true,
] );
$ids = $query->posts; // [ 14, 27, 38, ... ]
Caching WP_Query results
A query that runs on every page load with identical arguments and returns identical results should run once per cache period, not once per visitor. Caching is the final layer of WP_Query performance optimisation — applied after you have already fixed the query arguments. With a persistent cache backend — Redis or Memcached — results survive across requests.
function get_featured_projects(): array {
$cache_key = 'featured_projects_v1';
$cached = wp_cache_get( $cache_key, 'projects' );
if ( false !== $cached ) {
return $cached;
}
$query = new WP_Query( [
'post_type' => 'project',
'posts_per_page' => 6,
'meta_key' => 'project_featured',
'meta_value' => '1',
'no_found_rows' => true,
'fields' => 'ids',
] );
$ids = $query->posts;
wp_cache_set( $cache_key, $ids, 'projects', HOUR_IN_SECONDS );
return $ids;
}
Invalidate on save by hooking into save_post:
add_action( 'save_post_project', function () {
wp_cache_delete( 'featured_projects_v1', 'projects' );
} );
The 5 WP_Query performance rules
Applied consistently, these five rules eliminate most WP_Query performance problems on any WordPress site.
1. Always set no_found_rows => true on non-paginated queries. Widgets, sidebars, related posts, homepage featured sections — none of them paginate. The count query is waste.
2. Never use posts_per_page => -1 in production. Set an explicit limit. If you need all records, batch in chunks of 100.
3. Use fields => 'ids' when you only need IDs. Full post objects cost memory. IDs cost almost nothing.
4. Use pre_get_posts to modify archive queries instead of running a second query. One database round-trip is always faster than two.
5. Cache results that do not change per request. A featured projects list that updates once a day should hit the database once a day, not ten thousand times.
The biggest performance gains in WordPress almost always come from the database layer — not from caching plugins, CDNs, or server upgrades. Fix your WP_Query performance first and the rest becomes much cheaper. For more on the broader approach to WordPress performance, see the guide on lazy loading and resource loading strategy. The official WP_Query class reference is also worth bookmarking for every parameter available.


No comments yet. Be the first.