2026年11月12日
2026年11月12日
Advanced WP_Query: tax_query and meta_query, Sorting, Performance, SQL Filters
This article is a localized edition of the Japanese original. Read the Japanese original
WP_Query is the central class WordPress uses to fetch posts. Beyond the basic post_type and posts_per_page, it supports combined taxonomy and custom field conditions, sorting by several fields, speedups that skip row counting and caching work, and filters that rewrite the SQL directly.
This article covers each of these, plus the points that are often misunderstood (the LIKE wildcard, how meta_value_num depends on meta_key, what wp_reset_postdata() actually does), based on what the WordPress core code (class-wp-query.php and class-wp-meta-query.php) does as of October 2026.
Combine several taxonomy conditions with tax_query
tax_query takes an array of conditions on categories, tags or custom taxonomies and joins them with relation (AND or OR). Put another array with its own relation inside the conditions to create a nested group.
<?php
// Example 1: posts in the "technology" category that also have the tag "wordpress" or "php"
$query = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'category',
'field' => 'slug', // 'term_id' (default) / 'name' / 'slug' / 'term_taxonomy_id'
'terms' => 'technology',
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => array( 'wordpress', 'php' ),
'operator' => 'IN', // 'IN' (default) / 'NOT IN' / 'AND' / 'EXISTS' / 'NOT EXISTS'
),
),
) );
// Example 2: (category "news" OR "tutorial") AND tag "featured"
$query2 = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'AND',
array(
'relation' => 'OR',
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'news',
),
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => 'tutorial',
),
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => 'featured',
),
),
) );
// Example 3: posts that are NOT in the given categories
$query3 = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'uncategorized', 'test' ),
'operator' => 'NOT IN',
),
),
) );
relation only decides how the conditions on its own level are joined. Each level can use a different relation, so a condition such as "A or B, and C" is written as a nested group, as in Example 2.
Filter by custom fields with meta_query
meta_query sets conditions on custom fields (post meta). The main options are:
| Key | Allowed values |
|---|---|
compare |
=, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, REGEXP, NOT REGEXP, RLIKE, EXISTS, NOT EXISTS |
type |
NUMERIC, BINARY, CHAR (default), DATE, DATETIME, DECIMAL, SIGNED, TIME, UNSIGNED |
Meta values are stored as strings in the database, so set type when you compare them as numbers or dates. Without it they are compared as CHAR, and you get surprises such as '10' being treated as smaller than '9'.
<?php
// Example 1: products priced 1000-5000 (compared as numbers)
$query = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
array(
'key' => '_price',
'value' => array( 1000, 5000 ), // BETWEEN takes [min, max]
'type' => 'NUMERIC',
'compare' => 'BETWEEN',
),
),
) );
// Example 2: posts whose meta value contains "WordPress" (partial match)
$query2 = new WP_Query( array(
'post_type' => 'post',
'meta_query' => array(
array(
'key' => '_seo_title',
'value' => 'WordPress', // pass it without %
'compare' => 'LIKE',
),
),
) );
// Example 3: posts that have _featured and do not have _expired
$query3 = new WP_Query( array(
'post_type' => 'post',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_featured',
'compare' => 'EXISTS',
),
array(
'key' => '_expired',
'compare' => 'NOT EXISTS',
),
),
) );
// Example 4: events in 2026, sorted by event date ascending
$query4 = new WP_Query( array(
'post_type' => 'event',
'meta_query' => array(
'event_date' => array(
'key' => '_event_date',
'value' => array( '2026-01-01', '2026-12-31' ),
'type' => 'DATE',
'compare' => 'BETWEEN',
),
),
'orderby' => 'event_date', // sort by the name of the clause above
'order' => 'ASC',
) );
LIKE adds % on both sides automatically
When compare is LIKE or NOT LIKE, WordPress escapes the value with $wpdb->esc_like() and then wraps it in % before building the SQL. Example 2 therefore becomes a partial match, LIKE '%WordPress%'.
So you do not need to add % yourself. A % you add gets escaped and becomes a search for a literal "%" character, and the query no longer matches what you intended. If you want to control where the wildcard goes, as in a prefix match ('WordPress%'), use REGEXP (for example '^WordPress') instead of LIKE.
Match date comparisons to the stored format
With type set to DATE, values are compared using CAST(meta_value AS DATE). Example 4 works when the meta value is stored in YYYY-MM-DD form, such as 2026-03-15. Some fields, like the ACF date field, store values as 20260315, so check the actual stored format before you write the condition.
Sort by several fields with orderby
Pass an array to orderby to sort by several fields in turn.
<?php
// Example 1: cheapest first, then by title when the price is the same
$query = new WP_Query( array(
'post_type' => 'product',
'meta_key' => '_price',
'orderby' => array(
'meta_value_num' => 'ASC',
'title' => 'ASC',
),
) );
// Example 2: sort by two meta fields using named meta_query clauses
$query2 = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
'rating_clause' => array(
'key' => '_rating',
'type' => 'NUMERIC',
),
'price_clause' => array(
'key' => '_price',
'type' => 'NUMERIC',
),
),
'orderby' => array(
'rating_clause' => 'DESC', // highest rating first
'price_clause' => 'ASC', // cheapest first within the same rating
),
) );
Two points matter when you sort by meta:
meta_valueandmeta_value_numonly work when a meta condition is set throughmeta_keyormeta_query. Without one, there is no error; that sort key is simply ignored (and if no other key is left, the result is sorted by post date). If the order does not change, check this first.- Setting
meta_keydrops posts that do not have that meta from the results. To include posts without the meta, you need a workaround such as joining'compare' => 'EXISTS'and'NOT EXISTS'withORin a named clause.
Other orderby values include none, ID, author, title, name, type, date, modified, parent, rand, comment_count, menu_order and post__in (the order of the IDs passed in post__in). relevance (search relevance) only has an effect when a search term s is set. rand gets slow with many posts and is not covered by the query cache described below, so use it sparingly.
Parameters that improve performance
When you do not need a total count or pagination, or only need IDs, these parameters cut the work:
| Parameter | Effect | Avoid when |
|---|---|---|
'no_found_rows' => true |
Skips counting the total (SQL_CALC_FOUND_ROWS and SELECT FOUND_ROWS()) |
The list shows pagination |
'update_post_meta_cache' => false |
Skips loading the meta of the fetched posts in bulk | You call get_post_meta() in the loop |
'update_post_term_cache' => false |
Skips loading the terms of the fetched posts in bulk | You show categories or tags in the loop |
'fields' => 'ids' |
Returns only an array of IDs, without building post objects | You display titles or content |
<?php
// A list that needs no pagination, such as "5 latest posts" in a sidebar
$recent = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 5,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
'ignore_sticky_posts' => true,
) );
// When you only need IDs ($ids_query->posts becomes an array of IDs)
$ids_query = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 100,
'no_found_rows' => true,
'fields' => 'ids',
) );
With no_found_rows set to true, found_posts and max_num_pages are not calculated and stay at 0. Pagination such as the_posts_pagination() is therefore not displayed.
Since WordPress 6.1, when cache_results is enabled (default true), WP_Query stores the result of the query that fetches post IDs in the object cache. On sites with a persistent object cache such as Redis, the same query is served from the cache on later requests too. Queries whose order includes rand are not cached.
Process a large number of posts in chunks
When you need to process every post, do not load them all at once; fetch them in fixed-size chunks.
<?php
function my_process_all_posts() {
$page = 1;
$per_page = 100;
do {
$query = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'any',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'ID', // fix the order so no post is skipped
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true, // the total is not used
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
) );
foreach ( $query->posts as $post_id ) {
my_process_single_post( $post_id ); // your per-post processing
}
$page++;
} while ( count( $query->posts ) === $per_page ); // fewer than $per_page means the last page
}
Because the loop stops when it gets fewer than $per_page posts instead of using the total, no_found_rows can be true. If your processing deletes posts or changes them so they no longer match, the page offsets shift and posts get skipped; in that case, re-run the query with paged kept at 1 each time.
What wp_reset_postdata() does
wp_reset_postdata() does not free memory. When you call $query->the_post() in a secondary loop, the global $post is replaced with the secondary query's post. wp_reset_postdata() puts $post back to the current post of the main query, so template tags such as the_title() point to the main post again.
<?php
$related = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 3,
'no_found_rows' => true,
) );
if ( $related->have_posts() ) {
echo '<ul>';
while ( $related->have_posts() ) {
$related->the_post(); // the global $post is replaced here
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
echo '</ul>';
wp_reset_postdata(); // restore $post to the main query's post
}
If you never call the_post() and just loop over $query->posts, as in the batch example above, wp_reset_postdata() is unnecessary. The function for when you replaced the main query itself with query_posts() is wp_reset_query() (and using query_posts() at all is discouraged).
Rewrite the SQL with posts_join, posts_where and posts_orderby
Conditions that parameters cannot express can be added with filters that receive each part of the SQL. Each of these filters receives the WP_Query instance as its second argument, so use a custom query variable as a marker and apply the change only to the query you target.
The following example joins a custom table {prefix}my_post_views (columns post_id and view_count, one row per post) and sorts by view count, highest first.
<?php
class My_View_Count_Query {
public static function init() {
add_filter( 'posts_join', array( __CLASS__, 'filter_join' ), 10, 2 );
add_filter( 'posts_where', array( __CLASS__, 'filter_where' ), 10, 2 );
add_filter( 'posts_orderby', array( __CLASS__, 'filter_orderby' ), 10, 2 );
}
public static function filter_join( $join, $query ) {
if ( ! $query->get( 'my_order_by_views' ) ) {
return $join;
}
global $wpdb;
$join .= " LEFT JOIN {$wpdb->prefix}my_post_views AS pv ON pv.post_id = {$wpdb->posts}.ID";
return $join;
}
public static function filter_where( $where, $query ) {
if ( ! $query->get( 'my_order_by_views' ) ) {
return $where;
}
$min_views = absint( $query->get( 'my_min_views' ) );
if ( $min_views > 0 ) {
global $wpdb;
$where .= $wpdb->prepare( ' AND pv.view_count >= %d', $min_views );
}
return $where;
}
public static function filter_orderby( $orderby, $query ) {
if ( ! $query->get( 'my_order_by_views' ) ) {
return $orderby;
}
// treat posts with no recorded views as 0
return 'COALESCE(pv.view_count, 0) DESC, ' . $orderby;
}
}
My_View_Count_Query::init();
// Usage
$popular = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 10,
'no_found_rows' => true,
'my_order_by_views' => true, // custom query variable (the marker)
'my_min_views' => 100, // only posts viewed 100 times or more
) );
Custom keys passed to WP_Query are kept as query variables, so you can read them inside the filters with $query->get(). Keep these points in mind:
- If you add the filters without checking a marker, they affect every
WP_Query, including admin list screens and the main query. get_posts()setssuppress_filterstotrueby default, soposts_joinandposts_whereare not called. UseWP_Querywith these filters, or pass'suppress_filters' => falsetoget_posts().- If the joined table has several rows per post, the same post is returned more than once. In that case, set
{$wpdb->posts}.IDwith theposts_groupbyfilter, or join a pre-aggregated table. - When you put outside values into SQL, sanitize them first with
$wpdb->prepare()orabsint(), as in the example.
Check the generated SQL
The SQL that WP_Query ran is stored in $query->request. You can read it without enabling SAVEQUERIES.
<?php
// check it temporarily in a development environment
error_log( $popular->request );
SAVEQUERIES records every query run on the page, with its duration, in $wpdb->queries; enable it by adding define( 'SAVEQUERIES', true ); to wp-config.php. Recording adds overhead, so do not leave it on in production. To see which queries are slow on screen, the Query Monitor plugin is also handy.
Summary
tax_queryandmeta_querylet you nestrelationto build conditions such as "A or B, and C".LIKEinmeta_queryadds%on both sides automatically. Do not add%to the value.- Sorting by
meta_value_numis ignored withoutmeta_keyormeta_query. Use named clauses to sort by several meta fields. - Use
no_found_rowsfor lists without pagination andfields => 'ids'when IDs are enough. wp_reset_postdata()restores the$postreplaced bythe_post(); it has nothing to do with freeing memory.- Limit SQL filters to target queries with a custom query variable, and check the generated SQL with
$query->request.
How to package filters like these in your own plugin is explained in WordPress plugin development basics.
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)