Advanced WP_Query: taxonomies, meta data and performance (2026)

Advanced WP_Query: taxonomies, meta data and performance (2026)

Last verified: October 18, 2026
16 min read
Guide
Full-stack developer
500+ WP projects

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 int

What each flag does:

  • no_found_rows => true: skips the expensive count of all matches. With SQL_CALC_FOUND_ROWS, MySQL has to walk every matching row even when it returns only five; without it, MySQL can stop at the LIMIT when the order comes from an index. MySQL has deprecated SQL_CALC_FOUND_ROWS and FOUND_ROWS() since 8.0.17 (MySQL manual).
  • fields => 'ids': the SELECT returns IDs only. Ideal when you call get_post() selectively afterwards or build an ID map. Do not combine it with a loop that calls the_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 or get_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:

  1. Several keys with the same meaning: _price, price, product_price after migrations. The query hits an empty result or inconsistent data. Unify the key before you optimize the SQL.
  2. Serialized arrays in meta: LIKE on PHP serialization is brittle and slow. Keep relations in taxonomies or in separate meta rows with flat values.
  3. meta_query with an empty key: historically this allowed comparing the value alone, but it scans the whole table. Avoid it.
  4. include_children => true on deep category trees: WordPress expands the tree in PHP and adds terms to IN (...). On a catalogue with hundreds of child categories the ID list balloons; sometimes it is cheaper to store a flat “leaf only” taxonomy.
  5. A double query in the template: new WP_Query in front-page.php plus overriding the main query with query_posts. The main request runs anyway; the second one only adds load. Use pre_get_posts to 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_Query when 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 where WP_Query query 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_query in 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_flush runs 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:

  1. Open the page with the new filter.
  2. Save the SQL from Query Monitor.
  3. Run it through EXPLAIN on a copy of the database.
  4. Compare it with the no_found_rows + fields => ids variant.
  5. 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(). The update_post_*_cache flags decide whether meta and terms are loaded straight away in one batch query, or left to lazy load.
  • With fields => ids there are no full WP_Post objects in $query->posts, so the post cache does not fill up along the way. If you then run foreach ( $ids as $id ) { get_post( $id ); get_post_meta( $id, ... ); }, you are back to N+1, unless you call update_postmeta_cache( $ids ) yourself.
  • A transient holding a serialized list of IDs for 5-15 minutes is often cheaper than a “clever” orderby rand on every hit. Invalidation: the save_post / edited_term hook 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_changed value of the posts group, plus terms when the query has a tax_query. Saving any post, or adding, updating or deleting any post meta value, moves last_changed (core hooks wp_cache_set_posts_last_changed() to updated_post_meta and the other meta actions in default-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 orderby contains RAND( skips the cache, and so does a query whose selected fields were changed by the posts_fields or posts_clauses filters.
  • Without a persistent object cache (Redis, Memcached), the default WP_Object_Cache keeps 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.

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:

  1. Do you really need pagination? If not, no_found_rows.
  2. Do you render meta and terms in the loop? If not, turn off both update_post_*_cache flags.
  3. Are IDs enough? Then fields => ids plus a deliberate hydrate.
  4. Does meta_query use type for numbers and dates?
  5. Do you avoid LIKE '%…%' on hot filters?
  6. Did you measure the query in Query Monitor and EXPLAIN?
  7. 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.

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.

When should you use no_found_rows => true in WP_Query?#
Whenever pagination is not needed, such as in sidebar widgets, related posts, teaser carousels, or fixed-limit REST API endpoints. It prevents the costly SQL_CALC_FOUND_ROWS calculation, which significantly speeds up database execution.
Why are meta_query requests often slow?#
Because the wp_postmeta table is not designed for relational filtering. The meta_value column is stored as LONGTEXT and cannot be fully indexed, so queries using LIKE or unindexed comparisons force full scans across matching rows.
Why is pre_get_posts preferred over creating a new WP_Query in a template?#
Because WordPress executes the main query before loading the template. Creating an additional WP_Query in template files duplicates database queries, wastes server resources, and often breaks native pagination and canonical URLs.

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

Let’s discuss

Related Articles