Every WordPress developer can run a simple loop. The trouble starts when a client asks for a compound filter: products from the seasonal collection in a given colour category, without items marked as unavailable, sorted by a custom field. That is where cat=5 and query_posts() end up in debug.log, or on production under load.
This guide is hands-on. We build tax_query and meta_query, set relation to AND/OR, turn on no_found_rows and fields => ids, avoid the index traps in wp_postmeta, and finally check the query in Query Monitor and the object cache. The code targets WordPress 6.x; the patterns also work on older 6.0+ releases, as long as you do not mix legacy parameters with the newer array syntax.
How tax_query works in WP_Query
Forget cat, tag_id and category__and as your default API. Those shortcuts still work, but they do not scale to nested logic. tax_query is the standard: each clause is an array with taxonomy, field, terms, and optionally operator and include_children.
AND relation between taxonomies
You want movie CPT entries that are in the action genre and in the year 2026 at the same time:
$args = [
'post_type' => 'movie',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'genre',
'field' => 'slug',
'terms' => 'action',
],
[
'taxonomy' => 'year',
'field' => 'slug',
'terms' => '2026',
],
],
];
$query = new WP_Query( $args );A top-level relation applies only to its direct children. Without an explicit 'relation' => 'AND', WordPress assumes AND anyway. Write it out regardless, because in code review it shows the intent.
OR relation within one taxonomy
When a list of terms should act as “any of”, one clause with a terms array and the default IN operator is enough. Separate clauses with relation => OR are needed when you combine different taxonomies or different operators (IN vs NOT IN).
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'OR',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'on-sale', 'clearance' ],
],
[
'taxonomy' => 'product_label',
'field' => 'slug',
'terms' => 'last-pieces',
],
],
];Nesting groups (AND around OR)
A real catalogue filter usually looks like this: (category A OR category B) AND (colour C) AND NOT discontinued status. In tax_query you do that with a nested array that has its own relation:
$args = [
'post_type' => 'product',
'tax_query' => [
'relation' => 'AND',
[
'relation' => 'OR',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 't-shirts', 'hoodies' ],
],
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'accessories',
],
],
[
'taxonomy' => 'pa_color',
'field' => 'slug',
'terms' => 'red',
],
[
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => [ 'outofstock', 'exclude-from-catalog' ],
'operator' => 'NOT IN',
],
],
];Each nested group generates a separate JOIN to wp_term_relationships. Three levels of nesting on a catalogue with hundreds of thousands of rows is a signal to measure with EXPLAIN. Not to “simplify for marketing”, but to know whether MySQL picks the indexes.
Filtering by custom fields with meta_query
meta_query filters on wp_postmeta. Each clause has a key, and optionally value, compare and type. type (NUMERIC, DECIMAL, DATE, CHAR, BINARY) controls the CAST in SQL. Without NUMERIC, comparing “10” and “2” is lexicographic: '2' > '10' in CHAR.
A basic clause with CAST
$args = [
'post_type' => 'product',
'meta_query' => [
[
'key' => '_stock',
'value' => 0,
'compare' => '>',
'type' => 'NUMERIC',
],
],
];EXISTS and NOT EXISTS
Sometimes you do not care about the value, only whether the key is there. compare => 'EXISTS' (and NOT EXISTS) needs no value. It is cheaper than LIKE on an empty string and easier to read in code review.
$args = [
'post_type' => 'event',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'event_start',
'compare' => 'EXISTS',
],
[
'key' => 'event_cancelled',
'compare' => 'NOT EXISTS',
],
],
];BETWEEN, IN and LIKE in meta_query
BETWEEN needs an array of two values. IN needs a list. LIKE with % on both sides forces a full scan of meta_value, because an index on meta_key is not enough to search the middle of a value. If you filter by a fragment of text in meta, consider a separate taxonomy or a denormalized column in a custom table. LIKE '%foo%' on a million rows of wp_postmeta is a classic hotspot from trac.wordpress.org and core performance tickets.
AND/OR relation in meta_query
The syntax mirrors tax_query. You can nest groups. Named clauses (a string key instead of a numeric index) let you sort later with an orderby that points to the clause name. That is the official pattern from the WP_Query documentation, not a hack.
$args = [
'post_type' => 'product',
'meta_query' => [
'relation' => 'AND',
'stock_clause' => [
'key' => '_stock_status',
'value' => 'instock',
'compare' => '=',
],
'rating_clause' => [
'key' => '_wc_average_rating',
'value' => 4,
'compare' => '>=',
'type' => 'DECIMAL',
],
],
'orderby' => [
'rating_clause' => 'DESC',
'date' => 'DESC',
],
];Combining tax_query and meta_query in one query
Both arguments work together. WordPress merges them into one SELECT with JOINs to terms and meta. A typical catalogue scenario: a category taxonomy plus a _featured field or a date range.
$args = [
'post_type' => 'product',
'posts_per_page' => 24,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 't-shirts',
],
],
'meta_query' => [
'relation' => 'AND',
[
'key' => '_stock',
'value' => 0,
'compare' => '>',
'type' => 'NUMERIC',
],
[
'key' => '_featured',
'value' => 'yes',
'compare' => '=',
],
],
];A practical note: every extra meta JOIN is another scan of wp_postmeta. Two meta filters on a large WooCommerce store can stretch response time far faster than three taxonomies, because term_relationships has a better index shape than the EAV model in postmeta. If you filter by the same key in many places (for example stock and featured), consider denormalizing into taxonomy attributes or an index table. Not because it is “best practice”, but because EXPLAIN shows Using where; Using temporary on meta.
How to speed up WP_Query without pagination
By default, when the query has a LIMIT, WP_Query adds SQL_CALC_FOUND_ROWS and then runs SELECT FOUND_ROWS() to fill $query->found_posts and $query->max_num_pages. When you build a “related” widget, a shortcode with three cards or a sidebar block, you do not need pagination.
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
];
$ids = ( new WP_Query( $args ) )->posts; // array of intWhat each flag does:
no_found_rows => true: skips the expensive count of all matches. WithSQL_CALC_FOUND_ROWS, MySQL has to walk every matching row even when it returns only five; without it, MySQL can stop at theLIMITwhen the order comes from an index. MySQL has deprecatedSQL_CALC_FOUND_ROWSandFOUND_ROWS()since 8.0.17 (MySQL manual).fields => 'ids': the SELECT returns IDs only. Ideal when you callget_post()selectively afterwards or build an ID map. Do not combine it with a loop that callsthe_title()without loading the objects, or you get N+1.update_post_meta_cache => false: skips bulk-loading meta into the object cache for the whole result. Turn it back on if the loop reads ACF orget_post_meta.update_post_term_cache => false: the same for terms. Turn it off when you do not render categories and tags.
orderby => rand looks tempting for “similar posts”, but on large tables it generates ORDER BY RAND(), which sorts the entire result in memory. A better pattern: fetch a pool of IDs with fields => ids and no_found_rows, then use array_rand in PHP, or cache the randomized set for a few minutes in a transient or the object cache.
Suppressing filters and avoiding query hooks overhead
When third-party plugins hook into posts_where, posts_join or posts_clauses, secondary queries can inherit unexpected constraints, multilanguage conditions, or security checks meant strictly for the main loop.
In custom background routines, CLI scripts, and micro-queries, you can isolate WP_Query by passing 'suppress_filters' => true:
$clean_args = [
'post_type' => 'product',
'posts_per_page' => 10,
'no_found_rows' => true,
'suppress_filters' => true,
'fields' => 'ids',
];
$clean_query = new WP_Query( $clean_args );Note that get_posts() sets 'suppress_filters' => true by default, whereas new WP_Query() defaults to false. This distinction explains why identical arguments can yield different results depending on the caller. suppress_filters switches off the posts_* filters (posts_where, posts_join, posts_clauses and the rest), but not the pre_get_posts action, which core always fires. If a multilingual plugin scopes the language through those filters, the query returns posts in every language; check how your plugin filters before you turn it on.
Slow meta_query and wp_postmeta indexes
WordPress does not create an index on (meta_key, meta_value) that is useful for numeric filters on the value. The standard index on meta_key helps find rows for a given key; filtering and sorting by meta_value often ends in a filesort. Symptoms in Query Monitor: a long time on the main SELECT, Using filesort, a high Rows examined.
Common traps:
- Several keys with the same meaning:
_price,price,product_priceafter migrations. The query hits an empty result or inconsistent data. Unify the key before you optimize the SQL. - Serialized arrays in meta:
LIKEon PHP serialization is brittle and slow. Keep relations in taxonomies or in separate meta rows with flat values. meta_querywith an emptykey: historically this allowed comparing the value alone, but it scans the whole table. Avoid it.include_children => trueon deep category trees: WordPress expands the tree in PHP and adds terms toIN (...). On a catalogue with hundreds of child categories the ID list balloons; sometimes it is cheaper to store a flat “leaf only” taxonomy.- A double query in the template:
new WP_Queryinfront-page.phpplus overriding the main query withquery_posts. The main request runs anyway; the second one only adds load. Usepre_get_poststo modify the main query.
If a meta filter is business-critical (availability, region, SKU), consider a custom table with proper indexes and a thin posts_clauses / posts_join layer. Core meta_query stays for rare admin filters, not for the storefront hot path.
Composite indexing and database schema fixes for wp_postmeta
The WordPress schema (wp-admin/includes/schema.php) creates three keys on wp_postmeta: PRIMARY KEY (meta_id), KEY post_id (post_id), and KEY meta_key (meta_key(191)).
When a query runs:
SELECT post_id FROM wp_postmeta WHERE meta_key = '_stock_status' AND meta_value = 'instock';MySQL can only use the index on meta_key. It reads all index entries matching _stock_status, and then inspects every row in clustered storage to evaluate meta_value = 'instock'. With many rows per key, that means many buffer pool reads.
A composite index on (meta_key(191), meta_value(100)) lets MySQL check key and value inside the index, so it reads from the table only the rows that already match. It is not a covering index: an InnoDB secondary index stores the primary key (meta_id), not post_id, so the row is still read to get post_id:
ALTER TABLE wp_postmeta ADD INDEX meta_key_value_composite (meta_key(191), meta_value(100));Always benchmark such schema adjustments in staging with Query Monitor and EXPLAIN before deploying. While extra indexes speed up targeted read queries, they add write overhead to every update_post_meta() and add_post_meta() operation.
Raw SQL with wpdb prepare versus WP_Query
WP_Query is designed for flexibility, post capability checks, taxonomies, and object caching. But when you need complex aggregate calculations, multi-table joins across custom tables, or pure ID projections without core overhead, a raw $wpdb query is vastly more efficient.
Always use $wpdb->prepare() to enforce parameterized query security against SQL injection:
global $wpdb;
$min_stock = 5;
$country = 'PL';
$sql = $wpdb->prepare(
"SELECT p.ID, pm_stock.meta_value AS stock_count
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm_stock
ON p.ID = pm_stock.post_id AND pm_stock.meta_key = %s
INNER JOIN {$wpdb->postmeta} pm_geo
ON p.ID = pm_geo.post_id AND pm_geo.meta_key = %s
WHERE p.post_type = %s
AND p.post_status = %s
AND CAST(pm_stock.meta_value AS UNSIGNED) >= %d
AND pm_geo.meta_value = %s
ORDER BY CAST(pm_stock.meta_value AS UNSIGNED) DESC
LIMIT %d",
'_stock',
'_target_country',
'product',
'publish',
$min_stock,
$country,
20
);
$results = $wpdb->get_results( $sql );Choosing between the two approaches comes down to requirements:
- Use
WP_Querywhen building theme templates, handling pagination with standard page numbers, triggering standard template tags, or when results should automatically populate the WordPress object cache. - Use raw
$wpdb->prepare()for export routines, analytical reports, complex math operations (SUM(),AVG()), subqueries, or specialized micro-services whereWP_Queryquery parsing and filter cascades introduce unacceptable latency.
How to inspect WP_Query in Query Monitor
Query Monitor is the default diagnostic tool on staging. Install it, open the Queries panel and filter by the WP_Query caller. Look for:
- the number of queries per request (duplicates of the same
meta_queryin widgets), - the time of a single SELECT with JOINs to
wp_postmeta, - the component (theme vs plugin): the “slow WP_Query” often lives in a third-party related posts plugin, not in your shortcode,
- duplicates: the same SQL twice, because the object cache is off or
wp_cache_flushruns in a loop.
On staging, also enable SAVEQUERIES, only for the duration of the measurement. On production, keep Query Monitor behind the view_query_monitor capability or turn it off entirely, since the plugin itself has a cost.
A practical routine before merge:
- Open the page with the new filter.
- Save the SQL from Query Monitor.
- Run it through
EXPLAINon a copy of the database. - Compare it with the
no_found_rows+fields => idsvariant. - Only then cache the result.
How WP_Query uses the object cache
An object cache (Redis, Memcached via a drop-in) does not magically speed up bad SQL. It speeds up repeated reads of the same post, meta and term objects within a request and across requests, as long as the cache groups are not flushed on every write.
How this connects to WP_Query:
- After the SELECT, core calls
update_post_caches(). Theupdate_post_*_cacheflags decide whether meta and terms are loaded straight away in one batch query, or left to lazy load. - With
fields => idsthere are no fullWP_Postobjects in$query->posts, so the post cache does not fill up along the way. If you then runforeach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, you are back to N+1, unless you callupdate_postmeta_cache( $ids )yourself. - A transient holding a serialized list of IDs for 5-15 minutes is often cheaper than a “clever”
orderby randon every hit. Invalidation: thesave_post/edited_termhook for the given CPT.
Do not confuse page cache (full HTML) with the object cache. Page cache bypasses PHP; the object cache helps when PHP has to build the response anyway (cart, logged-in user, ESI). A related posts widget on a product page in a B2B store usually hits the object cache, not the full-page cache.
How WP_Query caches query results since WordPress 6.1
Since WordPress 6.1, WP_Query keeps its own result cache. After the SELECT, core stores the list of post IDs together with found_posts and max_num_pages in the post-queries object cache group. The key is a hash of the query arguments and the generated SQL (WP_Query::generate_cache_key(), marked @since 6.1.0 in class-wp-query.php). The cache_results argument turns it on or off; the default is true.
Three details from the core source decide whether this cache helps you:
- Invalidation is global. The key is salted with the
last_changedvalue of thepostsgroup, plustermswhen the query has atax_query. Saving any post, or adding, updating or deleting any post meta value, moveslast_changed(core hookswp_cache_set_posts_last_changed()toupdated_post_metaand the other meta actions indefault-filters.php). On a store where an import updates stock every few minutes, the hit rate stays low. - Some queries are never cached. A query whose
orderbycontainsRAND(skips the cache, and so does a query whose selected fields were changed by theposts_fieldsorposts_clausesfilters. - Without a persistent object cache (Redis, Memcached), the default
WP_Object_Cachekeeps data only for the current request. The result cache then helps only when the same query runs twice in one page load.
That is one more reason to fetch an ID pool and shuffle it in PHP instead of using orderby => rand: the pool query can be served from post-queries, the random one cannot. If you turn off cache_results for an export or a CLI script, the post cache is not filled with the returned objects either.
Related posts in WordPress without a plugin
function wppoland_get_related_post_ids( int $post_id, int $limit = 3 ): array {
$terms = get_the_terms( $post_id, 'category' );
if ( empty( $terms ) || is_wp_error( $terms ) ) {
return [];
}
$term_ids = wp_list_pluck( $terms, 'term_id' );
$cache_key = 'related_' . $post_id . '_' . $limit;
$cached = wp_cache_get( $cache_key, 'wppoland_related' );
if ( false !== $cached ) {
return $cached;
}
$query = new WP_Query(
[
'post_type' => 'post',
'category__in' => $term_ids,
'post__not_in' => [ $post_id ],
'posts_per_page' => $limit * 4,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'fields' => 'ids',
'ignore_sticky_posts' => true,
]
);
$pool = $query->posts;
if ( empty( $pool ) ) {
wp_cache_set( $cache_key, [], 'wppoland_related', 10 * MINUTE_IN_SECONDS );
return [];
}
shuffle( $pool );
$picked = array_slice( $pool, 0, $limit );
wp_cache_set( $cache_key, $picked, 'wppoland_related', 10 * MINUTE_IN_SECONDS );
return $picked;
}There is no ORDER BY RAND() in the SQL here. You fetch a larger pool of IDs with a cheap query, shuffle it in PHP and cache the result. On a blog with a few thousand posts, that gives a steadier profile than randomizing in MySQL.
WP_Query optimization checklist before going to production
Before you paste a new WP_Query into production:
- Do you really need pagination? If not,
no_found_rows. - Do you render meta and terms in the loop? If not, turn off both
update_post_*_cacheflags. - Are IDs enough? Then
fields => idsplus a deliberate hydrate. - Does
meta_queryusetypefor numbers and dates? - Do you avoid
LIKE '%…%'on hot filters? - Did you measure the query in Query Monitor and EXPLAIN?
- Can the result be cached in the object cache or a transient with sensible invalidation?
Summary
tax_query and meta_query follow the same pattern: clauses, relation, nesting. Performance does not come from “faster hosting”, but from deliberate flags (no_found_rows, fields, meta/term cache), from avoiding full scans of wp_postmeta and from measuring in Query Monitor. An object cache amplifies a good query; it will not fix a bad one.
Official sources: the WP_Query class, the taxonomy parameters and custom field parameters sections, the WP_Tax_Query and WP_Meta_Query classes, performance optimization and WP_Object_Cache.







