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
-
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
-
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.
- 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:
- When you only need IDs, always use fields => ids.
- If you do not need accurate total counts for pagination, set no_found_rows => true.
- Disable postmeta and term cache priming with update_post_meta_cache => false and update_post_term_cache => false when you only need IDs.
- 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.
- 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 🙂 |