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 => trueeliminates theSELECT 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 => falseskips pre-loading all post meta for the result set. Use it when your loop does not callget_post_meta()or ACF field functions.update_post_term_cache => falseskips pre-loading taxonomy terms. Use it when your loop does not callget_the_terms()or similar.fields => 'ids'returns only post IDs instead of fullWP_Postobjects — useful when you need the IDs to pass to another function without accessing post data.- Avoid
posts_per_page => -1on large post types — it returns every matching row with no limit. Paginate instead, or batch-process with apagedloop.
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'ortype => 'DATE'on every numeric and date comparison — string comparison gives wrong results. - Name meta clauses (
'price_clause' => array(...)) when you need toorderbya specific one. - Use
INwith an array of values instead of nestedORclauses for the same key. - Use
field => 'slug'for taxonomy terms — slugs are environment-stable;term_idvalues differ between dev and prod. - Add
no_found_rows => truewhenever pagination is not needed. - Set
update_post_meta_cache => falseandupdate_post_term_cache => falsewhen the loop does not need that data. - Cache complex query results with
set_transient()and bust onsave_post_{post_type}hooks. - Never use
posts_per_page => -1on large datasets — paginate or batch instead.


