14+ years building on WordPress / Replies in under 5 hours
Development 11 min read · Updated July 2026

Advanced WP_Query Techniques: Custom Fields, Taxonomies, and Meta Queries

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Advanced WP_Query Techniques: Custom Fields, Taxonomies, and Meta Queries
TL;DR

<code>meta_query</code> filters posts by custom field values using <code>AND</code>/<code>OR</code> relation logic and operators like <code>BETWEEN</code>, <code>IN</code>, and <code>EXISTS</code>. <code>tax_query</code> filters by taxonomy terms. Name meta clauses to sort results by a specific field unambiguously (WP 4.2+). Always add <code>no_found_rows => true</code> when not paginating, set <code>update_post_meta_cache => false</code> when the loop skips post meta, and cache expensive query results with the Transient API.

WP_Query is WordPress’s primary data retrieval class, and most developers know the basics — post_type, posts_per_page, orderby. The real power lives in two parameters almost always used together: meta_query for custom field filtering and tax_query for taxonomy filtering. Understanding how to combine them, name their clauses for sorting, and tune the surrounding parameters for performance is what separates a slow filtered listing from a fast, maintainable one.

The Basic WP_Query Loop

Every custom query follows the same structure. The wp_reset_postdata() call at the end is not optional — omitting it corrupts the global $post object for any template code that runs after your loop.

$args = array(
    'post_type'      => 'post',
    'posts_per_page' => 10,
    // ... query parameters go here
);

$query = new WP_Query( $args );

if ( $query->have_posts() ) :
    while ( $query->have_posts() ) :
        $query->the_post();
        // Display content here: get_the_title(), get_the_permalink(), etc.
    endwhile;
    wp_reset_postdata(); // Always reset after a custom query.
else :
    echo '<p>No results found.</p>';
endif;

meta_query: Filtering by Custom Field Values

Custom fields added by ACF, MetaBox, or Pods are all stored in wp_postmeta and queried through the same meta_query parameter. For a single condition with a top-level comparison you can use the shorthand meta_key / meta_value / meta_compare parameters:

// Products with price above $50, ordered by price ascending.
$args = array(
    'post_type'    => 'product',
    'meta_key'     => 'price',
    'meta_value'   => 50,
    'meta_type'    => 'NUMERIC',
    'meta_compare' => '>',
    'orderby'      => 'meta_value_num',
    'order'        => 'ASC',
    'no_found_rows' => true, // Skip COUNT(*) — no pagination needed here.
);
$products = new WP_Query( $args );

meta_compare Operators

Operator Use case
= != > >= < <= Standard value comparisons
LIKE / NOT LIKE Partial string match (%value%)
IN / NOT IN Match any of an array of values
BETWEEN / NOT BETWEEN Numeric or date range (array of two values)
EXISTS / NOT EXISTS Post has (or lacks) the meta key — no value needed
REGEXP / NOT REGEXP Regular expression match

Multiple Conditions with relation

Wrap multiple clauses in a meta_query array and set relation to AND or OR. Each inner array is one condition. For numeric and date comparisons, always set type — without it, WordPress compares values as strings, which gives wrong sort and comparison results for numbers.

// Products between $50-$100 that are in stock.
$args = array(
    'post_type'  => 'product',
    'meta_query' => array(
        'relation' => 'AND',
        array(
            'key'     => 'price',
            'value'   => array( 50, 100 ),
            'type'    => 'NUMERIC',
            'compare' => 'BETWEEN',
        ),
        array(
            'key'     => 'in_stock',
            'value'   => '1',
            'compare' => '=',
        ),
    ),
    'no_found_rows' => true,
);
$products = new WP_Query( $args );

Named Clauses for Precise Ordering (WP 4.2+)

When you use meta_query and want to orderby one of the clauses, give that clause a string key. Without a named clause, orderby => 'meta_value' is ambiguous when multiple meta clauses are present — WordPress may pick the wrong one or produce unpredictable sort results.

// Named clauses (WP 4.2+) allow you to reference a specific clause in `orderby`.
// Without named clauses, ordering by a field inside meta_query is ambiguous.
$args = array(
    'post_type'  => 'product',
    'meta_query' => array(
        'relation'     => 'AND',
        'price_clause' => array(          // named key
            'key'     => 'price',
            'type'    => 'NUMERIC',
            'compare' => 'EXISTS',        // EXISTS so every product qualifies
        ),
        'stock_clause' => array(          // named key
            'key'     => 'in_stock',
            'value'   => '1',
            'compare' => '=',
        ),
    ),
    'orderby' => array(
        'price_clause' => 'ASC',          // Order by the named clause
    ),
    'no_found_rows' => true,
);
$products = new WP_Query( $args );

EXISTS and NOT EXISTS

EXISTS finds posts that have the meta key at all (regardless of value). NOT EXISTS finds posts where the key is absent. Neither requires a value key in the clause.

// Find posts that have a custom hero image set.
$args = array(
    'post_type'  => 'portfolio',
    'meta_query' => array(
        array(
            'key'     => '_custom_hero_image',
            'compare' => 'EXISTS', // No 'value' key needed for EXISTS / NOT EXISTS
        ),
    ),
    'no_found_rows' => true,
);

// Opposite: posts missing that key.
$args_missing = array(
    'post_type'  => 'portfolio',
    'meta_query' => array(
        array(
            'key'     => '_custom_hero_image',
            'compare' => 'NOT EXISTS',
        ),
    ),
);

tax_query: Filtering by Taxonomy Terms

Taxonomy filtering follows the same nested structure as meta_query. The field parameter controls whether you identify terms by slug, term_id, or name — use slug by default since slugs are stable across environments; term_id values differ between development and production databases.

// Portfolio items in the 'web-design' project_type term.
$args = array(
    'post_type' => 'portfolio',
    'tax_query' => array(
        array(
            'taxonomy' => 'project_type',
            'field'    => 'slug',   // 'slug', 'term_id', or 'name'
            'terms'    => 'web-design',
            'operator' => 'IN',     // IN, NOT IN, AND, EXISTS, NOT EXISTS
        ),
    ),
    'no_found_rows' => true,
);
$projects = new WP_Query( $args );

Multiple Terms and Nested Relations

For multiple values on the same taxonomy, pass an array to terms with operator => 'IN' — this is equivalent to a nested OR but far cleaner. Only reach for nested relation arrays when you need to combine conditions across different taxonomies.

// Events in London OR Paris, AND marked as featured.
$args = array(
    'post_type' => 'event',
    'tax_query' => array(
        'relation' => 'AND',
        array(
            // Pass both terms to a single IN clause — cleaner than nested OR clauses.
            'taxonomy' => 'event_location',
            'field'    => 'slug',
            'terms'    => array( 'london', 'paris' ),
            'operator' => 'IN', // IN already means "any of these terms"
        ),
        array(
            'taxonomy' => 'event_status',
            'field'    => 'slug',
            'terms'    => 'featured',
            'operator' => 'IN',
        ),
    ),
    'no_found_rows' => true,
);
$featured_events = new WP_Query( $args );

Combining meta_query and tax_query

The most common real-world scenario: filter by taxonomy (a category, status, or type) and by one or more custom field values simultaneously. WordPress runs these as a single SQL query with JOINs — it does not execute two separate queries and intersect them.

// Properties for sale under $500k with 3 or 4 bedrooms.
// Use IN for the same-key multi-value case instead of nested OR clauses.
$args = array(
    'post_type'  => 'property',
    'meta_query' => array(
        'relation' => 'AND',
        array(
            'key'     => 'price',
            'value'   => 500000,
            'type'    => 'NUMERIC',
            'compare' => '<',
        ),
        array(
            'key'     => 'bedrooms',
            'value'   => array( '3', '4' ),
            'compare' => 'IN', // More efficient than two nested OR clauses for the same key
        ),
    ),
    'tax_query'  => array(
        array(
            'taxonomy' => 'property_status',
            'field'    => 'slug',
            'terms'    => 'for-sale',
            'operator' => 'IN',
        ),
    ),
    'no_found_rows' => true,
);
$properties = new WP_Query( $args );

Note the IN operator for bedrooms — this is cleaner and more performant than wrapping two identical-key clauses in a nested OR relation.

Performance: The Parameters That Matter

A complex query with nested meta_query and tax_query produces multiple SQL JOINs. Four parameter changes make a significant difference on datasets of any size:

$args = array(
    'post_type'      => 'product',
    'posts_per_page' => 20,    // Never use -1 on large datasets — paginate instead.

    // Skip SELECT FOUND_ROWS() — saves a full-table COUNT on every query.
    // Only omit this when you need $query->found_posts for pagination.
    'no_found_rows'  => true,

    // Skip caching post meta — use when the loop does not call get_post_meta() / ACF functions.
    'update_post_meta_cache' => false,

    // Skip caching taxonomy terms — use when the loop does not call get_the_terms().
    'update_post_term_cache' => false,

    // Return IDs only — avoids full WP_Post objects when you only need IDs.
    'fields' => 'ids',

    'meta_query' => array( /* ... */ ),
);
// $query->posts is now array( 123, 456, 789 ) — post IDs only.
$product_ids = ( new WP_Query( $args ) )->posts;
  • no_found_rows => true eliminates the SELECT FOUND_ROWS() call WordPress normally makes to support pagination. If you are not paginating, this is always safe to set and saves a full-table COUNT.
  • update_post_meta_cache => false skips pre-loading all post meta for the result set. Use it when your loop does not call get_post_meta() or ACF field functions.
  • update_post_term_cache => false skips pre-loading taxonomy terms. Use it when your loop does not call get_the_terms() or similar.
  • fields => 'ids' returns only post IDs instead of full WP_Post objects — useful when you need the IDs to pass to another function without accessing post data.
  • Avoid posts_per_page => -1 on large post types — it returns every matching row with no limit. Paginate instead, or batch-process with a paged loop.

For expensive queries that run on every page load, cache the result using the Transient API. The WordPress Transient API guide covers the caching pattern and cache-invalidation hooks in detail.

Complete Example: Cached Filtered Product Listing

Putting it all together — a function that combines meta_query, tax_query, named clauses, performance flags, and transient caching:

/**
 * Returns featured products in a price range, cached for 12 hours.
 */
function get_featured_products_in_range( int $min, int $max ): array {
    $key    = 'featured_products_' . $min . '_' . $max;
    $cached = get_transient( $key );

    if ( false !== $cached ) {
        return $cached;
    }

    $query = new WP_Query( array(
        'post_type'              => 'product',
        'posts_per_page'         => 12,
        'no_found_rows'          => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
        'meta_query'             => array(
            'relation'     => 'AND',
            'price_clause' => array(
                'key'     => 'price',
                'value'   => array( $min, $max ),
                'type'    => 'NUMERIC',
                'compare' => 'BETWEEN',
            ),
            array(
                'key'     => 'featured',
                'value'   => '1',
                'compare' => '=',
            ),
        ),
        'orderby'   => array( 'price_clause' => 'ASC' ),
        'tax_query' => array(
            array(
                'taxonomy' => 'product_status',
                'field'    => 'slug',
                'terms'    => 'active',
            ),
        ),
    ) );

    $posts = $query->posts;
    wp_reset_postdata();

    set_transient( $key, $posts, HOUR_IN_SECONDS * 12 );

    return $posts;
}

// Bust cache whenever a product is saved.
add_action( 'save_post_product', function(): void {
    global $wpdb;
    $wpdb->query(
        "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_featured_products_%'"
    );
} );

When the query grows beyond what wp_postmeta joins can handle efficiently — thousands of rows, full-text search across multiple custom fields, or geospatial queries — that is the signal to move to a custom database table with direct SQL. For controlling which users can see which query results based on their role, see the user roles and capabilities guide for the permission layer that wraps these queries.

Quick-Reference Checklist

  • Use type => 'NUMERIC' or type => 'DATE' on every numeric and date comparison — string comparison gives wrong results.
  • Name meta clauses ('price_clause' => array(...)) when you need to orderby a specific one.
  • Use IN with an array of values instead of nested OR clauses for the same key.
  • Use field => 'slug' for taxonomy terms — slugs are environment-stable; term_id values differ between dev and prod.
  • Add no_found_rows => true whenever pagination is not needed.
  • Set update_post_meta_cache => false and update_post_term_cache => false when the loop does not need that data.
  • Cache complex query results with set_transient() and bust on save_post_{post_type} hooks.
  • Never use posts_per_page => -1 on large datasets — paginate or batch instead.

Frequently asked questions

Use the top-level meta_key / meta_value / meta_compare shorthand for a single condition when you also need to orderby that field using meta_value or meta_value_num. Switch to the meta_query array when you have more than one condition, need nested AND/OR logic, or need named clauses for unambiguous ordering. The two forms cannot be mixed in the same query — meta_query takes precedence when both are present.

Give the clause you want to sort by a named string key in the meta_query array, then reference that name in the orderby array. For example: 'meta_query' => array( 'price_clause' => array( 'key' => 'price', 'type' => 'NUMERIC', 'compare' => 'EXISTS' ) ), then 'orderby' => array( 'price_clause' => 'ASC' ). Without a named clause, orderby => 'meta_value' is ambiguous when multiple meta clauses are present and may produce unexpected sort results. Named clauses were introduced in WP 4.2.

Yes — use the IN operator with an array value: array( 'key' => 'bedrooms', 'value' => array( '3', '4' ), 'compare' => 'IN' ). This is equivalent to two separate OR-related clauses for the same key but is cleaner and generates a simpler SQL IN (...) clause rather than two JOINs. NOT IN works the same way to exclude a list of values.

By default, WP_Query runs a second SQL query — SELECT FOUND_ROWS() — to count the total number of matching posts, stored in $query->found_posts for pagination. Setting no_found_rows => true skips that count query. Use it whenever you do not need pagination: single-page listings, widget queries, REST API callbacks, and admin tool queries. It is always a safe optimisation if you are not rendering page links.

Use 'compare' => 'NOT EXISTS' in a meta_query clause and omit the value key entirely: array( 'key' => '_hero_image', 'compare' => 'NOT EXISTS' ). This returns posts where the meta key is absent from wp_postmeta altogether, not just posts where the value is empty. The counterpart, EXISTS, finds posts that have the key regardless of its value.

WP_Query's meta_query generates SQL JOINs against wp_postmeta — one JOIN per meta clause. At low-to-medium post counts (under roughly 10,000 rows on standard hosting) this performs well. Performance degrades when you have tens of thousands of posts, multiple simultaneous meta clauses, full-text or geospatial queries, or aggregations (SUM, AVG) across custom fields. At that point, a custom database table with proper indexes and direct SQL outperforms WP_Query significantly.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →