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 WP_Query adds SQL_CALC_FOUND_ROWS (or a separate COUNT in newer code paths) 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; // tablica intWhat each flag does:
no_found_rows => true: skips the expensive count of all matches. On a set of 50k+ posts the difference can be hundreds of milliseconds per query.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.
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 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.
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.
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.







