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_value and meta_value_num only work when a meta condition is set through meta_key or meta_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_key drops 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' with OR in 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() sets suppress_filters to true by default, so posts_join and posts_where are not called. Use WP_Query with these filters, or pass 'suppress_filters' => false to get_posts().
  • If the joined table has several rows per post, the same post is returned more than once. In that case, set {$wpdb->posts}.ID with the posts_groupby filter, or join a pre-aggregated table.
  • When you put outside values into SQL, sanitize them first with $wpdb->prepare() or absint(), 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_query and meta_query let you nest relation to build conditions such as "A or B, and C".
  • LIKE in meta_query adds % on both sides automatically. Do not add % to the value.
  • Sorting by meta_value_num is ignored without meta_key or meta_query. Use named clauses to sort by several meta fields.
  • Use no_found_rows for lists without pagination and fields => 'ids' when IDs are enough.
  • wp_reset_postdata() restores the $post replaced by the_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)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲