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

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

Last verified: September 21, 2026
12 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 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 int

What 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 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.

#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:

  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.

#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.

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.

Related cluster

Explore other WordPress services and knowledge base

Strengthen your business with professional technical support in key areas of the WordPress ecosystem.

Related Articles