A working guide to WP_Query and the loop (2026 performance edition)

A working guide to WP_Query and the loop (2026 performance edition)

Last verified: October 13, 2026
19 min read
Guide
Full-stack developer
Core Web Vitals

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:

  1. Parsing Arguments: Converting your array of arguments into a standardized format.
  2. Generating SQL: Building a complex SQL SELECT statement.
  3. Executing Query: Sending the request to the database.
  4. Filling the Object: Populating the WP_Query object 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 of WP_Post objects.
  • $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, exposes found_posts, max_num_pages, supports the_post() iteration. Use this when you need pagination or full template-tag access.
  • get_posts( $args ), thin wrapper that returns an array of WP_Post objects. Defaults to 'suppress_filters' => true and always sets 'no_found_rows' => true and 'ignore_sticky_posts' => true, which is faster but skips posts_* filters. Use it for short, fixed-size sidebar lists.
  • query_posts(), destructive: it replaces the global $wp_query with a new query, breaks is_*() 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 with pre_get_posts for the main query and new WP_Query for 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:

  1. Call conditionals as methods on the passed object ($query->is_home()), not as global functions. The global is_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.
  2. 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.
  3. 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 the loop_start action. If the query did not return full post objects, it also primes post, meta and term caches at that point (unless update_post_meta_cache or update_post_term_cache is false).
  • With 'fields' => 'ids', current core still turns each ID into a full post object with get_post() inside the_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, $more and $numpages, then fires the the_post action. 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 fires loop_end and rewinds the query. If the query matched nothing, it fires loop_no_results instead.

#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 paged in 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:

  1. Fetch IDs of the last 50 posts.
  2. Select 5 random IDs in PHP.
  3. 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:

  1. 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 inside the_post() re-queries the database per row.
  2. SAVEQUERIES. Set define( 'SAVEQUERIES', true ); in wp-config.php on 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 ) ) at shutdown produces the canonical “what ran and how long it took” trace.
  3. $query->request plus EXPLAIN. After new WP_Query(), echo $query->request; prints the SQL WordPress actually generated. Run that string under EXPLAIN in TablePlus or mysql -e. Look for type: ALL (full table scan), Using filesort, or Using temporary, those are the rows that need an index.
  4. WP-CLI. wp db query --skip-column-names "EXPLAIN $SQL" runs EXPLAIN against the live DB without the browser overhead. wp post list --post_type=product --fields=ID --posts_per_page=10 reproduces 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_query filters reference an unindexed meta_key. Add INDEX (meta_key(32), post_id) on wp_postmeta if so.
  • Add 'no_found_rows' => true to every secondary loop that does not render a total count.
  • Replace posts_per_page => -1 with a hard cap, or batch via paged.
  • Replace query_posts() with pre_get_posts (for the main query) or new WP_Query (for secondary loops).
  • Guard every pre_get_posts callback with is_admin() and $query->is_main_query().
  • For loops that only render title and link, set 'fields' => 'ids' and update_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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Frequently asked questions

Practical answers to apply the topic in real execution.

Why is my WP_Query slow even though it returns only ten posts?#
WP_Query without 'no_found_rows' => true issues a second SELECT FOUND_ROWS() that scans the full match set behind the LIMIT. On a 200,000-row wp_posts table with a tax_query JOIN, that follow-up adds 80-250 ms even when only ten rows render. Add 'no_found_rows' => true unless the page actually shows a total count.
When should I use get_posts instead of new WP_Query?#
get_posts is a thin wrapper that defaults to suppress_filters => true and no_found_rows => true. Use it for short, fixed-size sidebar lists where you do not need the_post() iteration or pagination. Use new WP_Query when you need full template-tag access, max_num_pages, or when you intentionally want posts_* filters to fire.
How do I fix a meta_query that takes seconds on a large catalogue?#
wp_postmeta ships with separate single-column indexes on post_id and meta_key and no composite index, and the meta_key index alone is too low-cardinality for value lookups. Add a composite index: ALTER TABLE wp_postmeta ADD INDEX idx_meta_key_post_id (meta_key(32), post_id). For range comparisons on meta_value, add a prefix index there too. If the value space is fixed (sizes, brands), migrate to a custom taxonomy, wp_term_relationships is already indexed for the joins WordPress generates.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles