WP_Query is the SQL builder behind every front-end request: archives, single posts, REST endpoints, WooCommerce shop loops. When a page is slow, the cause is usually one of three things, a meta_query against a non-indexed meta_key, an unbounded posts_per_page, or SQL_CALC_FOUND_ROWS running on every page load when nothing on the page shows a total count.
Order of magnitude on a real WooCommerce shop with 50,000 products: a meta_query against _stock_status with no index on (meta_key, post_id) runs 1.5-3 seconds. After adding the composite index, the same query drops to 50-200 ms. The query is identical; only the index changed.
The rest of this guide is the muscle memory needed to avoid the three failure modes above and recover when an inherited theme has already wired them in. Taxonomy and meta query design, composite indexes and the query cache added in WordPress 6.1 have their own article: advanced WP_Query, taxonomies, meta and performance. Official references: WP_Query, pre_get_posts and the class-wp-query.php source.
How WP_Query builds and runs a request
To master WP_Query, you must understand what happens when you instantiate it. WordPress performs the following steps:
- Parsing Arguments: Converting your array of arguments into a standardized format.
- Generating SQL: Building a complex SQL SELECT statement.
- Executing Query: Sending the request to the database.
- Filling the Object: Populating the
WP_Queryobject with post objects and metadata.
WP_Query object properties
When you run $query = new WP_Query($args), you aren’t just getting an array of posts. You are getting a massive object with critical properties:
$query->posts: An array ofWP_Postobjects.$query->post_count: Number of posts being displayed on the current page.$query->found_posts: Total number of posts matching the criteria (regardless of pagination).$query->max_num_pages: Total number of pages of results.$query->query_vars: The arguments WordPress actually used to run the query.
Main query vs secondary WP_Query
The main query ($wp_the_query, exposed as the global $wp_query) is built before the template loads. It decides which template WordPress picks (is_home(), is_archive(), is_singular() and the rest) and which posts the default loop shows through have_posts() and the_post(). A secondary loop is a separate WP_Query instance: it does not change the main query, and it is not the place to repair archive filters after the fact. To change what an archive shows, use pre_get_posts; to show extra content next to the main list, use new WP_Query().
1. The main loop (global context)
This is the loop triggered by the URL request. WordPress handles the instantiation; you just iterate:
if ( have_posts() ) :
while ( have_posts() ) : the_post();
get_template_part( 'template-parts/content', get_post_type() );
endwhile;
endif;2. The secondary loop (custom queries)
Used for related posts, recent news widgets, or any section that pulls a list outside the main query. Three constructors get used in the wild and they are not interchangeable:
new WP_Query( $args ), full object, exposesfound_posts,max_num_pages, supportsthe_post()iteration. Use this when you need pagination or full template-tag access.get_posts( $args ), thin wrapper that returns an array ofWP_Postobjects. Defaults to'suppress_filters' => trueand always sets'no_found_rows' => trueand'ignore_sticky_posts' => true, which is faster but skipsposts_*filters. Use it for short, fixed-size sidebar lists.query_posts(), destructive: it replaces the global$wp_querywith a new query, breaksis_*()conditionals further down the page, and runs a second main query on top of the one WordPress already ran. The function reference says it is not intended for use by plugins or themes. Do not use it. If you find it in an inherited theme, replace it withpre_get_postsfor the main query andnew WP_Queryfor everything else.
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'no_found_rows' => true, // CRITICAL FOR PERFORMANCE
];
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
// Render post title, excerpt, etc.
}
wp_reset_postdata(); // MANDATORY to restore the global $post object
}How to change the main query with pre_get_posts
The pre_get_posts reference describes the hook as firing “after the query variable object is created, but before the actual query is run”. That timing is the point: you change the arguments once and WordPress still runs one query, instead of running the main query and then a second WP_Query on top of it.
add_action(
'pre_get_posts',
static function ( WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'category__not_in', [ 17 ] );
}
if ( $query->is_post_type_archive( 'case' ) ) {
$query->set( 'orderby', 'menu_order title' );
$query->set( 'order', 'ASC' );
}
}
);Three rules from the reference page and from core:
- Call conditionals as methods on the passed object (
$query->is_home()), not as global functions. The globalis_main_query()reads$wp_query, not the query being filtered. In core,WP_Query::is_main_query()is a single comparison:$wp_the_query === $this. - Guard with
is_admin()and$query->is_main_query(). Without both, the callback also changes admin list tables, feeds, REST requests and every secondary loop on the page. is_front_page()does not work inside the hook, because the query is not set up yet;is_home()does. For a static front page, inspect the query vars instead.
Use $query->set() to change arguments and never call query_posts() or a second new WP_Query() from inside the callback.
What the_post() and have_posts() do behind the loop
Reading class-wp-query.php explains several bugs that look random in templates.
- On the first
the_post()call, core primes author caches for the whole result set and fires theloop_startaction. If the query did not return full post objects, it also primes post, meta and term caches at that point (unlessupdate_post_meta_cacheorupdate_post_term_cacheisfalse). - With
'fields' => 'ids', current core still turns each ID into a full post object withget_post()insidethe_post(), so the loop works, but the ID list then costs the same as a full query. setup_postdata()sets more globals than$post:$id,$authordata,$currentday,$currentmonth,$page,$pages,$multipage,$moreand$numpages, then fires thethe_postaction. A secondary loop that is not reset leaves all of them pointing at the last related post, which is why an author box or a paginated post (<!--nextpage-->) breaks after a related-posts block.- When
have_posts()runs out of posts it firesloop_endand rewinds the query. If the query matched nothing, it firesloop_no_resultsinstead.
How to use wp_reset_postdata after secondary loops
Calling the_post() on a secondary query sets the global $post. From then on, template tags such as the_title() and get_permalink() behave as if they were still on the last post of that loop, until you call wp_reset_postdata() after it.
wp_reset_postdata or wp_reset_query
In wp-includes/query.php the difference is two lines. wp_reset_postdata() calls $wp_query->reset_postdata(), which restores the global $post and the globals above from the main query. wp_reset_query() first puts $wp_the_query back into $wp_query and then calls wp_reset_postdata(). You only need wp_reset_query() after query_posts(), and the fix there is removing query_posts().
When you iterate over an array from get_posts() with foreach, template tags do not know about the post until you call setup_postdata( $post ); reset afterwards in the same way:
global $post;
$items = get_posts( [ 'numberposts' => 3 ] );
foreach ( $items as $post ) {
setup_postdata( $post );
the_title( '<li>', '</li>' );
}
wp_reset_postdata();setup_postdata() does not assign the global $post itself, which is why the loop variable is the global $post here.
Common WP_Query performance mistakes
1. Why posts_per_page => -1 exhausts memory
-1 instructs WP_Query to load every matching row into memory as a hydrated WP_Post object, then prime the meta and term caches for all of them (update_postmeta_cache() and update_object_term_cache() in core). On a CPT that grew from 50 entries to 5,000 over two years, the same code that ran in 80 ms now allocates 200+ MB of PHP memory and exhausts the memory_limit ceiling on shared hosting.
The fix is a hard cap that matches the rendered surface:
- A sidebar widget showing 5 items gets
'posts_per_page' => 5. - A sitemap generator that genuinely needs all rows should batch with
pagedin a loop, not-1. 200 IDs per batch is a safe ceiling on typical hosts. - Internal export tools where memory pressure is acceptable should still use
'fields' => 'ids'so you do not hydrate full post objects.
2. When to skip SQL_CALC_FOUND_ROWS with no_found_rows
Without no_found_rows, and whenever the query has a LIMIT, WP_Query adds SQL_CALC_FOUND_ROWS to the SELECT and then runs SELECT FOUND_ROWS() (through the found_posts_query filter). MySQL has to keep counting after the LIMIT is reached, which on a 200,000-row wp_posts table with a tax_query JOIN typically adds 80-250 ms per request, depending on cache state.
If the page does not display “Page 3 of 47” or a total count, set 'no_found_rows' => true. The follow-up question is always “but I want pagination with next/prev links”, those work without found_posts as long as you check have_posts() against an over-fetched query (e.g. ask for posts_per_page + 1 and render posts_per_page, with the extra row signalling that a next page exists).
For paginated archives that genuinely show a total (“Showing 1-10 of 4,200 articles”), keep the count, but cache it in a 5-minute transient keyed on the filter signature. The count rarely changes within five minutes; the JOIN scan does not need to repeat on every page load.
3. Ordering by random (orderby => rand)
ORDER BY RAND() makes MySQL assign a random value to every matching row and sort the full set, usually in a temporary table, before the LIMIT applies. Core also refuses to cache it: the WordPress 6.1 query cache skips any request whose ORDER BY contains RAND(. Engineering Fix:
- Fetch IDs of the last 50 posts.
- Select 5 random IDs in PHP.
- Run a second query with
post__in => $random_ids.
4. meta_query against unindexed meta_key
This is the single most common cause of slow archive pages on legacy WooCommerce installs. Core’s schema.php creates wp_postmeta with two single-column indexes, post_id and a prefix of meta_key, and no composite index. The meta_key index is too low-cardinality to help when WordPress filters by meta_key = '_stock_status' AND meta_value = 'instock': MySQL still has to scan every row matching the key.
War story: a Polish WooCommerce shop with 50,000 products. The shop archive page hooked pre_get_posts to add meta_query filtering by _stock_status and a custom _brand field. P95 TTFB on the shop page was 4.1 seconds. SAVEQUERIES showed 80% of the time inside a single JOIN against wp_postmeta. The fix:
ALTER TABLE wp_postmeta
ADD INDEX idx_meta_key_post_id (meta_key(32), post_id);After the index, the same query dropped to 180 ms. No PHP changed. For values that participate in actual range comparisons ('compare' => '>', 'compare' => 'BETWEEN'), you also need an index on meta_value, typically a prefix index because the column is LONGTEXT.
When the value space is fixed (sizes, colours, brand slugs), do not use meta_query at all. Register a custom taxonomy. wp_term_relationships has a primary key on (object_id, term_taxonomy_id) and a separate index on term_taxonomy_id, so the same filter that took 1.8 s as a meta_query runs in 30-60 ms as a tax_query.
5. tax_query clauses add up at scale
WP_Tax_Query first resolves your terms into term_taxonomy_id values, then each IN clause adds one LEFT JOIN against wp_term_relationships. AND clauses become a correlated COUNT subquery, NOT IN a subquery, and only EXISTS and NOT EXISTS touch wp_term_taxonomy. Every extra taxonomy in the same query adds another join or subquery for MySQL to combine, so faceted-filter pages that compose six taxonomies (size, colour, brand, season, material, price range) are where this hurts first. Either denormalise into a single faceting taxonomy, switch to FacetWP / SearchWP which index off-table, or push faceting to a search engine (Elasticsearch via ElasticPress, or Algolia for read-only catalogues).
WP_Query tax_query and date_query examples
1. Relationships with tax_query
Querying for multiple categories or tags requires the tax_query argument. Use the relation parameter to handle AND vs OR logic.
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'color',
'field' => 'slug',
'terms' => [ 'red', 'blue' ],
'operator' => 'IN',
],
[
'taxonomy' => 'size',
'field' => 'slug',
'terms' => [ 'large' ],
],
],
];2. Date queries (dynamic range)
Native date queries are powerful and efficient. Use them instead of manual SQL filtering.
$args = [
'date_query' => [
[
'after' => 'January 1st, 2025',
'before' => [
'year' => 2026,
'month' => 12,
'day' => 31,
],
'inclusive' => true,
],
],
];Fix WP_Query pagination 404 in custom loops
One of the most common issues is “Pagination on my custom page returns a 404.” Why it happens: WordPress doesn’t know which Page number you are on for a custom WP_Query. Archive URLs (/page/2/) set the paged query var; a static front page sets page instead.
The Solution:
$paged = ( get_query_var( 'paged' ) ) ? get_query_var( 'paged' ) : 1;
if ( is_front_page() ) {
$paged = ( get_query_var( 'page' ) ) ? get_query_var( 'page' ) : 1;
}
$args = [
'paged' => $paged,
'posts_per_page' => 10,
// ...
];Do not combine offset with paged. The offset argument overrides the page calculation, so every page returns the same slice. If you need “one featured post, then a paginated list”, run two queries or compute the offset per page yourself.
How to cache WP_Query results
In 2026, a high-traffic site should rarely touch the database for static lists. Since WordPress 6.1, core already caches the ID list of repeated identical queries in the object cache (cache_results); details are in the advanced WP_Query guide. The patterns below cover what that cache does not.
1. Using transients API
If you have a complex query (e.g., a localized “Trending Jobs” list), store the IDs in a transient for 1 hour. Do not store the whole WP_Query object: it serializes every post and the query state, and the cached copy no longer behaves like a fresh query.
$cache_key = 'home_trending_jobs_v1';
$ids = get_transient( $cache_key );
if ( false === $ids ) {
$q = new WP_Query( [
/* Complex Args */
'fields' => 'ids',
'no_found_rows' => true,
] );
$ids = $q->posts;
set_transient( $cache_key, $ids, HOUR_IN_SECONDS );
}
$trending = new WP_Query( [
'post__in' => $ids ?: [ 0 ],
'orderby' => 'post__in',
'no_found_rows' => true,
] );Delete the transient on save_post for the post type it lists. Without a persistent object cache, transients live in wp_options.
2. Disabling cache priming for one query
After WP_Query fetches post IDs, it calls update_postmeta_cache() and update_object_term_cache() to bulk-load every meta row and every term row for those posts into the object cache. That is the right default for a regular template loop that is about to call get_post_meta() and get_the_terms(). It is wasteful for a loop that only renders title and permalink.
$args = [
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'no_found_rows' => true,
'fields' => 'ids',
];For an archive of 50 posts where each post has 30 meta rows, skipping the meta cache prime avoids 1,500 rows being read and cached on a single request. Combined with 'fields' => 'ids', you avoid hydrating WP_Post objects entirely, useful for sitemap generators, sidebar related-post ID lists, and admin dashboard widgets, as long as you read $q->posts directly and do not run the_post() over it.
When WP_Query splits a query into IDs and objects
Core does not always fetch full rows in one SELECT. In WP_Query::get_posts(), when the SQL was not changed by posts_* filters and either an external object cache is active or the query has a LIMIT with posts_per_page under 500, WordPress first selects only IDs and then fills the post objects, from the object cache where they already sit. The split_the_query filter can switch this on or off.
Two consequences for debugging:
- The SQL in Query Monitor may be
SELECT wp_posts.ID ...followed by a separate fetch of missing posts. That is core behaviour, not a plugin bug. - Any plugin that changes the SQL through the
posts_*clause filters turns the split off. A plugin that changes the selected fields (posts_fields,posts_fields_request,posts_clauses_request) also turns off the 6.1 query cache for that request.
There is also a full short-circuit: the posts_pre_query filter runs before the database call, and if it returns an array WordPress skips the SQL entirely. Search plugins use this to answer WP_Query from an external index. If Query Monitor shows a WP_Query with results and no matching SQL, look for a posts_pre_query callback.
Modify WP_Query results in the REST API
If you are building a React frontend using WordPress headless, you don’t use WP_Query directly in JS, but the REST API uses it on the backend.
Filter REST API query arguments
You can use the rest_{post_type}_query filter to modify how the API queries database based on URL parameters. Check the endpoint first: /wp/v2/posts already accepts a native exclude parameter, so a custom one only makes sense for logic core does not cover.
add_filter( 'rest_post_query', function( $args, $request ) {
$exclude = $request->get_param( 'exclude_ids' );
if ( ! empty( $exclude ) ) {
$args['post__not_in'] = wp_parse_id_list( $exclude );
}
return $args;
}, 10, 2 );wp_parse_id_list() turns a comma-separated string into an array of positive integers, so a crafted parameter cannot inject anything else into the query.
How to debug slow WP_Query queries
The four tools that actually find the answer:
- Query Monitor (John Blackbourn). Install on staging, never on production. The Queries panel groups by component, so you can immediately see whether the 2-second TTFB came from your theme, WooCommerce core, or a third-party plugin hooking
pre_get_posts. The “duplicate queries” tab catches N+1 loops where a template tag insidethe_post()re-queries the database per row. SAVEQUERIES. Setdefine( 'SAVEQUERIES', true );inwp-config.phpon staging only, because every query of the request is kept in memory. WordPress then populates$wpdb->queries; each entry holds the SQL, its duration, the caller stack, the start time and optional custom data (wpdb::log_query()in core).error_log( print_r( $wpdb->queries, true ) )atshutdownproduces the canonical “what ran and how long it took” trace.$query->requestplusEXPLAIN. Afternew WP_Query(),echo $query->request;prints the SQL WordPress actually generated. Run that string underEXPLAINin TablePlus ormysql -e. Look fortype: ALL(full table scan),Using filesort, orUsing temporary, those are the rows that need an index.- WP-CLI.
wp db query --skip-column-names "EXPLAIN $SQL"runsEXPLAINagainst the live DB without the browser overhead.wp post list --post_type=product --fields=ID --posts_per_page=10reproduces query logic from a deterministic environment, useful when the slow page is behind a paywall or auth.
A heuristic for prioritising: any query over 50 ms is suspicious on a warm cache, anything over 200 ms is broken. web.dev puts a good TTFB at 0.8 seconds or less; if a single WP_Query eats 250 ms of that, a large share of the budget is gone before the template starts rendering. TTFB is not itself a Core Web Vital, but it is the first part of LCP time, so every millisecond of it shows up in LCP.
When to use get_posts instead of WP_Query
get_posts() is a thin wrapper around WP_Query that returns an array of WP_Post objects instead of the loop API. In core’s post.php it always sets 'no_found_rows' => true and 'ignore_sticky_posts' => true and defaults to 'suppress_filters' => true.
$posts = get_posts(
[
'post_type' => 'post',
'posts_per_page' => 5,
'fields' => 'ids',
'suppress_filters' => false, // keep filters on when plugins must apply
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]
);Use get_posts() when you need a list and no loop with template tags. Stay with WP_Query when you need have_posts(), pagination through max_num_pages, or explicit control over the found-rows count.
Watch suppress_filters => true: pre_get_posts still fires, because WP_Query::get_posts() triggers that hook with do_action_ref_array() regardless of the argument. suppress_filters only switches off the posts_* SQL filters such as posts_where, posts_join and posts_clauses, plus the_posts. A plugin that hooks its logic into those filters does not apply to get_posts() by default; set suppress_filters => false when it has to.
For admin AJAX or REST callbacks that only need IDs for a select field, get_posts() with 'fields' => 'ids' is usually clearer than a full loop. For template rendering with the_title() and thumbnail tags, stay with WP_Query and reset afterwards.
WP_Query performance checklist
When a page is slow, work the list in order, the first three account for most of the WP_Query slowness in production WordPress sites.
- Run Query Monitor on the slow URL. Sort by Time. The longest query is almost always the one to fix.
- Check whether
meta_queryfilters reference an unindexedmeta_key. AddINDEX (meta_key(32), post_id)onwp_postmetaif so. - Add
'no_found_rows' => trueto every secondary loop that does not render a total count. - Replace
posts_per_page => -1with a hard cap, or batch viapaged. - Replace
query_posts()withpre_get_posts(for the main query) ornew WP_Query(for secondary loops). - Guard every
pre_get_postscallback withis_admin()and$query->is_main_query(). - For loops that only render title and link, set
'fields' => 'ids'andupdate_post_meta_cache => false. - After custom loops, call
wp_reset_postdata()to restore the global$post. - If the same WP_Query runs on every page load and the data changes infrequently, cache its IDs in a transient keyed on the args signature.
Related reading: WordPress speed optimisation.
Query-level fixes are usually the cheapest way to recover a TTFB budget, cheaper than upgrading the hosting plan, cheaper than adding a CDN, and far cheaper than rebuilding the site headless.







