Taxonomy Queries
tax_query filters posts by terms using indexed term tables, with field, terms, operator and relation.
'tax_query' => array( 'relation' => 'AND', array( 'taxonomy' => 'genre', 'field' => 'slug', 'terms' => array( 'noir' ), 'operator' => 'IN' ) )tax_query filters a post query by taxonomy terms. It is parsed by WP_Tax_Query, which joins wp_term_relationships and wp_term_taxonomy. Unlike a meta query, these joins hit indexed columns, so taxonomy filtering is the cheap way to slice a large post set.
Structure
tax_query is an array of clauses, optionally prefixed by 'relation', which accepts 'AND' or 'OR' and defaults to 'AND'. Each clause takes taxonomy, terms, and optionally field, operator and include_children. field is what terms is matched against: 'term_id' (the default), 'slug', 'name' or 'term_taxonomy_id'. operator accepts 'IN' (the default), 'NOT IN', 'AND', 'EXISTS' and 'NOT EXISTS'. include_children defaults to true and only matters on hierarchical taxonomies.
AND versus IN
These are the two operators people confuse. 'IN' with three terms matches a post in any of them. 'AND' matches only posts carrying all three. The clause-level relation is a different axis again: it joins whole clauses, usually across different taxonomies.
Shorthand and longhand
The built-in shortcuts are shorthand for a tax_query clause. 'category_name' => 'news' is the same query as a clause with taxonomy 'category', field 'slug', terms 'news'; 'tag' does the same for post_tag. Custom taxonomies also accept 'my_tax' => 'slug' at the top level. Use the full form once you need an operator or a second clause.
Example
<?php
// Reviews tagged 'noir' or 'thriller', but not anything in the 'spoilers' tag.
$reviews = new WP_Query(
array(
'post_type' => 'review',
'posts_per_page' => 10,
'tax_query' => array(
'relation' => 'AND', // both clauses must hold
array(
'taxonomy' => 'genre',
'field' => 'slug', // default is 'term_id'
'terms' => array( 'noir', 'thriller' ),
'operator' => 'IN', // any of these terms; 'AND' = all of them
'include_children' => true, // default; hierarchical taxonomies only
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => array( 'spoilers' ),
'operator' => 'NOT IN',
),
),
)
);When to use it
- Build a faceted filter where each selected term narrows the results
- List posts in a category while excluding one tag
- Find posts that have no term assigned in a required taxonomy
- Restrict a landing page to a parent term without pulling in child terms
More examples
Taxonomy shortcuts and their tax_query longhand
The shortcuts read well for a single category or tag and are what you will see in most themes. Move to the longhand as soon as you need NOT IN, a custom taxonomy alongside a built-in one, or an OR relation between clauses.
<?php
// Shorthand: top-level taxonomy arguments.
$q = new WP_Query(
array(
'category_name' => 'news',
'tag' => 'php',
)
);
// Longhand: the same query written out. Note field => 'slug' —
// the shortcuts match on slug, while tax_query defaults to 'term_id'.
$q = new WP_Query(
array(
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'news',
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => 'php',
),
),
)
);
// 'cat' => 5 is the term_id equivalent of 'category_name'.IN versus AND on the same terms
This is the single most common tax_query bug: a faceted filter built with the default 'IN' returns everything the user did not ask for. Use 'AND' for cumulative filters where each checkbox narrows the results.
<?php
$terms = array( 'php', 'mysql', 'linux' );
// IN: posts tagged with ANY of the three. Wide result set.
$any = new WP_Query(
array(
'tax_query' => array(
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => $terms,
'operator' => 'IN', // the default
),
),
)
);
// AND: posts tagged with ALL three. Narrow, and a heavier join.
$all = new WP_Query(
array(
'tax_query' => array(
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => $terms,
'operator' => 'AND',
),
),
)
);Turning off include_children on a hierarchical taxonomy
include_children is true by default, which surprises people building a landing page that should list only the section's own posts. NOT EXISTS is the editorial-cleanup query: everything missing a required classification.
<?php
// 'category' is hierarchical, so a query for the parent 'Hardware'
// also returns posts filed only under its children by default.
$parent_only = new WP_Query(
array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'hardware',
'include_children' => false, // exact term only
),
),
)
);
// Also useful: find posts with no term at all in a taxonomy.
$untagged = new WP_Query(
array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'brand',
'operator' => 'NOT EXISTS', // no 'terms' key needed
),
),
)
);
Discussion