How to optimize WP_Query with fields=ids and no_found_rows in WordPress

Contents

Introduction

This tutorial explains in exhaustive detail how to optimize WP_Query by using two powerful options: fields => ids and no_found_rows => true. You will learn what each option does, why they improve performance, when to use them, interactions with pagination and meta/tax queries, practical code examples, how to measure effects, pitfalls, and alternative strategies (caching, custom COUNT queries, REST/API considerations). Every example is provided as runnable PHP/SQL snippets where relevant.

Why optimize WP_Query?

WP_Query is flexible but can be heavy. By default it:

  • Generates SELECT queries that return full post fields.
  • Builds full WP_Post objects for each result (expensive in memory and CPU).
  • Runs extra queries to prime meta and term caches.
  • Performs a separate count (FOUND_ROWS) if pagination information is needed.

If you only need post IDs (for example, to build a lightweight list of IDs to pass to another function, or to perform further database operations), you can eliminate most of this overhead. Two query args are central:

  • fields => ids — return only an array of post IDs (or post ID => parent mapping).
  • no_found_rows => true — skip calculating the total number of matching rows (no SQL_CALC_FOUND_ROWS/FOUND_ROWS call), which saves an extra DB operation).

What fields => ids does

Setting fields => ids instructs WP_Query to change the SELECT clause to fetch only the post ID column (and optionally parent mapping when using id=>parent). WP avoids creating WP_Post objects and returns a simple array of IDs in query->posts. This reduces:

  • Memory usage (no objects in memory).
  • PHP time spent constructing objects and running setup_postdata-related work.
  • Automatic priming of postmeta and term caches (you can disable those separately).

What no_found_rows => true does

By default, when pagination is used (or when no_found_rows is not set to true), WP will include SQL_CALC_FOUND_ROWS and then run SELECT FOUND_ROWS() (or a separate COUNT) to compute the total number of matching rows so it can populate wp_query->found_posts. That makes the DB do additional work. Setting no_found_rows => true tells WP not to compute the full-found count, which is safe when you dont need to know the total pages/count (for infinite scroll, simple load more buttons, or server-side batch processing).

Basic optimized WP_Query example

Use this when you only need IDs and do not require pagination totals.

lt?php
args = array(
  post_type              =gt post,
  posts_per_page         =gt 100,
  fields                 =gt ids,
  no_found_rows          =gt true,
  update_post_meta_cache =gt false, // avoid fetching meta caches
  update_post_term_cache =gt false, // avoid term cache priming
)

// Fast query: returns array of IDs
query = new WP_Query( args )
ids = query-gtposts // array of integer post IDs
?gt

Difference in SQL SELECT clauses

Default WP_Query SELECT (simplified) returns all columns (p.). With fields => ids WP replaces the SELECT with p.ID (and optionally p.post_parent when using id=>parent). Example SQLs below show the change and the absence of SQL_CALC_FOUND_ROWS when no_found_rows is true.

-- Default (full objects, found rows)
SELECT SQL_CALC_FOUND_ROWS p. FROM wp_posts AS p
WHERE p.post_type = post AND p.post_status = publish
ORDER BY p.post_date DESC LIMIT 0, 10

-- With fields=ids and no_found_rows=true
SELECT p.ID FROM wp_posts AS p
WHERE p.post_type = post AND p.post_status = publish
ORDER BY p.post_date DESC LIMIT 0, 10

Why the combination is powerful

  • fields=ids reduces row width and avoids PHP object creation.
  • no_found_rows=true prevents an additional DB call to compute total hits.
  • Together they cut DB time, reduce memory usage, and minimize PHP overhead.

Where you should NOT use them

  • If you need full WP_Post objects for template output or functions expecting full objects.
  • If you need pagination UI that shows total pages or an accurate page count (unless you compute total separately).
  • If you rely on query-gtfound_posts or query-gtmax_num_pages for logic those will not be set when no_found_rows is true.

Updating cache flags

By default WP_Query will set update_post_meta_cache and update_post_term_cache to true when full objects are fetched. When fetching only IDs you should set them to false to avoid the extra cache-priming queries.

Timing and measuring performance

Example code to measure the time difference between full WP_Query and optimized query:

lt?php
global wpdb

// Non-optimized
args_full = array(
  post_type =gt post,
  posts_per_page =gt 100,
)
start = microtime(true)
q_full = new WP_Query( args_full )
time_full = microtime(true) - start

// Optimized
args_opt = array(
  post_type =gt post,
  posts_per_page =gt 100,
  fields =gt ids,
  no_found_rows =gt true,
  update_post_meta_cache =gt false,
  update_post_term_cache =gt false,
)
start = microtime(true)
q_opt = new WP_Query( args_opt )
time_opt = microtime(true) - start

echo Full:  . time_full . s, Optimized:  . time_opt . s
?gt

Pagination: the common gotcha

If you set no_found_rows => true you will not have query-gtfound_posts and query-gtmax_num_pages populated. That is fine when you only need the current page of IDs and a Load more approach that doesnt show total pages. If you need accurate total counts for page numbers you must run a separate count query.

Patterns to obtain total counts safely

  1. Run a separate optimized COUNT() query with the same WHERE/JOINS. This is the most efficient if you can mirror the query-building logic (meta queries / tax queries make this complex). Example (simple case):

        lt?php
        global wpdb
        total = (int) wpdb-gtget_var(
          wpdb-gtprepare(
            SELECT COUNT(ID) FROM {wpdb-gtposts} WHERE post_type=%s AND post_status=%s,
            post, publish
          )
        )
        ?gt
        
  2. Use a separate WP_Query configured only to calculate counts: set posts_per_page = 1 and no_found_rows = false, then inspect found_posts:

        lt?php
        count_q = new WP_Query( array_merge( args_base, array(
          posts_per_page =gt 1,
          no_found_rows =gt false,
          fields =gt ids, // still small result
        ) ) )
        total = count_q-gtfound_posts
        ?gt
        

    This still runs the SQL_CALC_FOUND_ROWS FOUND_ROWS call but can be simpler to implement when the query has complex meta/tax joins since WP builds the SQL for you.

  3. Use approximate counts or time-limited caching: if absolute accuracy is not required, store periodic counts in a transient (e.g., update once every 15 minutes) to avoid repeated heavy COUNTs.

Handling meta_query and tax_query

If your WP_Query includes meta_query or tax_query, those filters add JOINs and WHERE clauses which are the expensive part. Important notes:

  • fields=ids does NOT remove JOINs needed to filter results. Filtering still requires the database to perform the joins and comparisons.
  • You can improve performance by reducing complex meta_query logic, using direct meta_key comparisons when possible, or denormalizing data (storing frequently-filtered values in a single, indexed meta key).
  • When a meta_query uses multiple meta_keys combined with OR logic, the database may need multiple joins to postmeta try to rewrite to use IN comparisons where appropriate or store normalized data to reduce joins.

Advanced: generating COUNT SQL for complex queries

If you must compute accurate total counts server-side for a complex query, you can use the public WP_Meta_Query and WP_Tax_Query helpers to build SQL fragments and then assemble a COUNT query. This is advanced and error-prone you must ensure the COUNT query uses the same JOINs/WHERE conditions. Example of building a meta count fragment:

lt?php
// WARNING: advanced usage ensure you replicate the exact query clauses used by WP_Query.
meta_query = new WP_Meta_Query( array(
  array(
    key =gt price,
    value =gt 100,
    compare =gt gt=
  ),
) )
meta_sql = meta_query-gtget_sql( post, wpdb-gtposts, ID )
// meta_sql contains join and where fragments that you can inject into a COUNT query.
join = meta_sql[join]
where = meta_sql[where]

sql = SELECT COUNT(DISTINCT p.ID) FROM {wpdb-gtposts} p {join} WHERE p.post_type=%s AND p.post_status=%s {where}

total = (int) wpdb-gtget_var( wpdb-gtprepare( sql, product, publish ) )
?gt

Interaction with get_posts()

get_posts() is a thin wrapper around WP_Query that defaults to returning an array of posts. Notably, get_posts() sets no_found_rows => true by default (and usually sets suppress_filters => true). This is why get_posts() is already optimized for simple retrieval of lists. If you need pagination totals, avoid get_posts() or override no_found_rows.

Additional WP_Query flags to consider

  • update_post_meta_cache => false — skip priming postmeta caches.
  • update_post_term_cache => false — skip priming term caches.
  • cache_results => false — do not cache the retrieved posts in object cache (usually not needed when returning IDs).
  • ignore_sticky_posts => true — avoid extra sorting cost for sticky posts if not needed.

Using fields => id=>parent

If you need a mapping of post ID => post_parent without full objects, use id=>parent. WP will SELECT p.ID, p.post_parent and return an associative array keyed by the ID with the parent as the value.

lt?php
q = new WP_Query( array(
  post_type =gt page,
  posts_per_page =gt -1,
  fields =gt id=>parent,
  no_found_rows =gt true,
) )

id_to_parent = q-gtposts // array( ID =gt parent_ID, ... )
?gt

Practical use-cases

  • Batch processing posts in CLI scripts or WP‑Cron where you only need IDs to iterate through.
  • Building lightweight lists of candidate posts to warm a cache or perform secondary operations.
  • Infinite-scroll or Load more endpoints where total pages are not displayed.
  • REST endpoints that return an array of IDs and let the client request full objects separately.

Example: using IDs to fetch minimal dataset afterwards

A common pattern is to query IDs (fast) then fetch the fields you actually need using a direct SQL or a small selection. Example: query IDs and then retrieve only the post_title for each ID with a single optimized query or batched queries.

lt?php
// Get IDs
q = new WP_Query( array(
  post_type =gt post,
  posts_per_page =gt 200,
  fields =gt ids,
  no_found_rows =gt true,
  update_post_meta_cache =gt false,
  update_post_term_cache =gt false,
) )

ids = q-gtposts
if ( empty( ids ) ) {
  return
}

// Fetch just titles using wpdb in one query (more efficient than calling get_post for each)
global wpdb
ids_placeholder = implode( ,, array_map( absint, ids ) )
sql = SELECT ID, post_title FROM {wpdb-gtposts} WHERE ID IN ({ids_placeholder})
rows = wpdb-gtget_results( sql )

foreach ( rows as row ) {
  // row->ID, row->post_title
}
?gt

Caching strategies

Even with fields=ids and no_found_rows=true, repeated queries can still hit the DB. Combine these optimizations with caching:

  • Use transients to cache the array of IDs for a short period when the dataset is expensive to build (e.g., 1 minute to 1 hour depending on update frequency).
  • Cache computed totals (COUNT results) in a transient and invalidate when content changes (use hooks like save_post, wp_delete_post, transition_post_status).
  • For high-traffic sites, consider object caching (Redis/Memcached) and ensure transient caching uses the external cache backend.

REST API / AJAX endpoints

When exposing an endpoint that returns lists, prefer returning IDs or paginated slices of IDs and allow the client to request full objects only when needed. Use fields=ids no_found_rows=true to build the endpoint quickly. If you must supply a total count, compute it separately and cache it.

Edge cases and pitfalls

  • Fields=ids prevents filters that expect full WP_Post objects from running (some plugins may hook into posts and expect objects).
  • If you rely on hooks that run during WP_Post instantiation (or filters in setup_postdata), they wont run ensure you handle that if needed.
  • ORDER BY on meta_value often forces a JOIN to postmeta and a GROUP BY/DISTINCT which can be expensive even when selecting IDs.
  • Using posts_per_page = -1 with a very large dataset will still return lots of IDs — consider batching and LIMIT with offsets instead of -1 for large result sets.

Checklist: When to use fields=ids no_found_rows=true

Need Use optimized query?
Only need post IDs Yes
Need accurate pagination totals every page No (unless you compute totals separately)
Complex meta/tax filters Yes for ID retrieval, but filters still cause DB joins — profile the query
Short-lived caching acceptable Yes — combine with transients

Measuring real-world impact

Use Query Monitor or inspect wpdb-gtqueries to compare timings, CPU and memory usage. Measure:

  • DB execution time for the SELECT(s).
  • PHP time consumed building objects (microtime-based profiling).
  • Memory usage (memory_get_usage before/after queries).

Example: full blown pattern for an AJAX list endpoint

This example demonstrates a performant AJAX endpoint that returns a page of IDs quickly and separately computes a cached total count.

lt?php
// In your REST or admin-ajax handler:

function my_ajax_get_ids() {
  // validate / sanitize page, per_page from request
  per_page = isset( _GET[per_page] ) ? (int) _GET[per_page] : 50
  page     = isset( _GET[page] ) ? max( 1, (int) _GET[page] ) : 1

  args = array(
    post_type              =gt post,
    posts_per_page         =gt per_page,
    paged                  =gt page,
    fields                 =gt ids,
    no_found_rows          =gt true,
    update_post_meta_cache =gt false,
    update_post_term_cache =gt false,
  )

  q = new WP_Query( args )
  ids = q-gtposts

  // Return cached total, compute if missing:
  cache_key = my_posts_total_v1
  total = get_transient( cache_key )
  if ( false === total ) {
    global wpdb
    total = (int) wpdb-gtget_var(
      wpdb-gtprepare(
        SELECT COUNT(ID) FROM {wpdb-gtposts} WHERE post_type=%s AND post_status=%s,
        post, publish
      )
    )
    set_transient( cache_key, total, MINUTE_IN_SECONDS  5 )
  }

  wp_send_json_success( array(
    ids =gt ids,
    total =gt total,
  ) )
}
// Hook this to your REST or admin-ajax route.
?gt

Summary and recommended rules of thumb

Follow these practical rules:

  1. When you only need IDs, always use fields => ids.
  2. If you do not need accurate total counts for pagination, set no_found_rows => true.
  3. Disable postmeta and term cache priming with update_post_meta_cache => false and update_post_term_cache => false when you only need IDs.
  4. For accurate counts with complex queries, either run a separate optimized COUNT query or use WP_Query with no_found_rows set to false just for counting and cache results.
  5. Always profile queries (Query Monitor, wpdb-gtlast_query, microtime) before and after changes to confirm improvements.

Further reading

Consult the WordPress codebase (class-wp-query.php) to inspect how fields and no_found_rows alter SQL generation. For complex meta/tax query performance, study WP_Meta_Query and WP_Tax_Query. Use Query Monitor to identify the actual SQL and timings observed in your site.

Example links



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *