2026年7月1日
2026年7月1日
WP_Queryの高度な使い方|tax_query・meta_queryの複合条件、並び替え、高速化、SQLフィルター
WP_Query は、WordPress で投稿を取り出すときの中心になるクラスです。基本の post_type や posts_per_page だけでなく、タクソノミーとカスタムフィールドの複合条件、複数の項目での並び替え、件数計算やキャッシュの省略による高速化、SQL を直接書き換えるフィルターまで用意されています。
この記事では、それぞれの使い方と、誤解されやすい点(LIKE のワイルドカード、meta_value_num と meta_key の関係、wp_reset_postdata() の役割など)を、WordPress 本体のコード(class-wp-query.php・class-wp-meta-query.php)で確かめた内容に沿って説明します(2026年10月時点)。
tax_query で複数のタクソノミー条件を組み合わせる
tax_query は、カテゴリー・タグ・カスタムタクソノミーの条件を配列で並べ、relation(AND または OR)でつなぎます。条件の配列の中にさらに relation 付きの配列を入れると、入れ子のグループを作れます。
<?php
// 例1: カテゴリー「technology」に属し、かつタグ「wordpress」か「php」が付いた投稿
$query = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'category',
'field' => 'slug', // 'term_id'(既定)/ 'name' / 'slug' / 'term_taxonomy_id'
'terms' => 'technology',
),
array(
'taxonomy' => 'post_tag',
'field' => 'slug',
'terms' => array( 'wordpress', 'php' ),
'operator' => 'IN', // 'IN'(既定)/ 'NOT IN' / 'AND' / 'EXISTS' / 'NOT EXISTS'
),
),
) );
// 例2: (カテゴリー「news」または「tutorial」)かつ タグ「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',
),
),
) );
// 例3: 指定したカテゴリーに属さない投稿
$query3 = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'uncategorized', 'test' ),
'operator' => 'NOT IN',
),
),
) );
relation はその階層の条件どうしのつなぎ方だけを決めます。上の階層と下の階層で別の relation を使えるので、「A または B、かつ C」のような条件は例2のように入れ子で書きます。
meta_query でカスタムフィールドを絞り込む
meta_query は、カスタムフィールド(投稿メタ)の条件を指定します。主な指定は次のとおりです。
| キー | 指定できる値 |
|---|---|
compare |
=、!=、>、>=、<、<=、LIKE、NOT LIKE、IN、NOT IN、BETWEEN、NOT BETWEEN、REGEXP、NOT REGEXP、RLIKE、EXISTS、NOT EXISTS |
type |
NUMERIC、BINARY、CHAR(既定)、DATE、DATETIME、DECIMAL、SIGNED、TIME、UNSIGNED |
メタの値はデータベースでは文字列として保存されているため、数値や日付として比べるときは type を指定します。指定しないと CHAR として比べるので、'10' が '9' より小さいと判定されるといったずれが起きます。
<?php
// 例1: 価格が 1000〜5000 の商品(数値として比較)
$query = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
array(
'key' => '_price',
'value' => array( 1000, 5000 ), // BETWEEN は [最小値, 最大値]
'type' => 'NUMERIC',
'compare' => 'BETWEEN',
),
),
) );
// 例2: メタの値に「WordPress」を含む投稿(部分一致)
$query2 = new WP_Query( array(
'post_type' => 'post',
'meta_query' => array(
array(
'key' => '_seo_title',
'value' => 'WordPress', // % を付けずに渡す
'compare' => 'LIKE',
),
),
) );
// 例3: _featured があり、_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',
),
),
) );
// 例4: 2026年中のイベントを開催日の昇順で
$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', // 上の句の名前で並び替える
'order' => 'ASC',
) );
LIKE は自動で前後に % が付く
compare が LIKE または NOT LIKE のとき、WordPress は値を $wpdb->esc_like() でエスケープしたうえで、前後に % を付けて SQL を組み立てます。つまり例2は LIKE '%WordPress%' という部分一致になります。
このため、値に自分で % を付ける必要はありません。付けた % はエスケープされて文字としての「%」を探す条件になり、意図どおりに一致しなくなります。前方一致('WordPress%')のように % の位置を自分で決めたい場合は、LIKE ではなく REGEXP(例: '^WordPress')を使います。
日付の比較は保存形式に合わせる
type に DATE を指定すると、値は CAST(meta_value AS DATE) で比べられます。メタの値が 2026-03-15 のような YYYY-MM-DD 形式で保存されていれば例4のとおり動きます。ACF の日付フィールドのように 20260315 形式で保存するものもあるので、実際の保存形式を確認してから条件を書いてください。
並び替え(orderby)を複数の項目で行う
orderby に配列を渡すと、複数の項目で順に並び替えられます。
<?php
// 例1: 価格の安い順、同じ価格ならタイトル順
$query = new WP_Query( array(
'post_type' => 'product',
'meta_key' => '_price',
'orderby' => array(
'meta_value_num' => 'ASC',
'title' => 'ASC',
),
) );
// 例2: 名前付きの meta_query 句で、2つのメタを使って並び替える
$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', // 評価の高い順
'price_clause' => 'ASC', // 同じ評価なら安い順
),
) );
並び替えに使うメタの扱いで、注意する点が2つあります。
meta_valueとmeta_value_numは、meta_keyかmeta_queryでメタの条件が指定されているときだけ有効です。指定が無い場合、エラーにはならず、その並び替え指定が無視されます(ほかの指定も残らなければ投稿日順になります)。並びが変わらないときは、まずここを確認してください。meta_keyを指定すると、そのメタを持たない投稿は結果から外れます。メタが無い投稿も含めたい場合は、名前付き句の側で'compare' => 'EXISTS'と'NOT EXISTS'をORでつなぐなどの工夫が要ります。
orderby にはほかに none、ID、author、title、name、type、date、modified、parent、rand、comment_count、menu_order、post__in(post__in で渡したIDの順)などを指定できます。relevance(検索の関連度順)は、検索語 s を指定したときだけ意味を持ちます。rand は件数が多いと重く、後述のクエリキャッシュも効かないので、多用は避けます。
パフォーマンスを上げるパラメータ
一覧の件数やページ送りが要らない場面、IDだけ欲しい場面では、次のパラメータで処理を減らせます。
| パラメータ | 効果 | 使わないほうがよい場面 |
|---|---|---|
'no_found_rows' => true |
総件数の計算(SQL_CALC_FOUND_ROWS と SELECT FOUND_ROWS())を省く |
ページ送りを表示する一覧 |
'update_post_meta_cache' => false |
取得した投稿のメタをまとめて読み込む処理を省く | ループ内で get_post_meta() を使う |
'update_post_term_cache' => false |
取得した投稿のタームをまとめて読み込む処理を省く | ループ内でカテゴリーやタグを表示する |
'fields' => 'ids' |
投稿オブジェクトを作らず、IDの配列だけを返す | タイトルや本文を表示する |
<?php
// サイドバーの「新着5件」のように、ページ送りが不要な一覧
$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,
) );
// IDだけが欲しい場合($ids_query->posts が ID の配列になる)
$ids_query = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 100,
'no_found_rows' => true,
'fields' => 'ids',
) );
no_found_rows を true にすると、found_posts と max_num_pages が計算されず 0 のままになります。そのため the_posts_pagination() などのページ送りは表示されません。
WordPress 6.1 以降、cache_results(既定は true)が有効なら、WP_Query は投稿IDを取り出すクエリの結果をオブジェクトキャッシュに保存します。Redis などの永続オブジェクトキャッシュを入れているサイトでは、同じ条件のクエリが次のリクエストでもキャッシュから返ります。並び順に rand を含むクエリはキャッシュの対象外です。
大量の投稿を少しずつ処理する
全投稿に何かの処理をするときは、一度に全件を読み込まず、件数を区切って順に取り出します。
<?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', // 並びを固定して取りこぼしを防ぐ
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true, // 総件数は使わない
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
) );
foreach ( $query->posts as $post_id ) {
my_process_single_post( $post_id ); // 1件ごとの処理(自作)
}
$page++;
} while ( count( $query->posts ) === $per_page ); // 取得数が満たなければ最後のページ
}
総件数を使わず、「取得した件数が $per_page に満たなければ終わり」と判定しているので、no_found_rows を true にできます。処理の中で投稿を削除したり条件から外れる変更をしたりすると、ページの位置がずれて取りこぼしが出るため、その場合は毎回 paged を 1 のまま取り直す書き方にします。
wp_reset_postdata() の役割
wp_reset_postdata() は、メモリを解放する関数ではありません。サブクエリのループで $query->the_post() を呼ぶと、グローバル変数 $post がサブクエリの投稿に書き換わります。wp_reset_postdata() は、この $post をメインクエリの現在の投稿に戻し、the_title() などのテンプレートタグがメインの投稿を指す状態に戻します。
<?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(); // ここでグローバルの $post が書き換わる
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
echo '</ul>';
wp_reset_postdata(); // $post をメインクエリの投稿に戻す
}
前の節の一括処理のように the_post() を呼ばず、$query->posts を直接回すだけなら、wp_reset_postdata() は不要です。また、query_posts() でメインクエリ自体を差し替えたときに使うのは wp_reset_query() です(query_posts() 自体、使わないことが推奨されています)。
posts_join・posts_where・posts_orderby で SQL を書き換える
パラメータだけでは表せない条件は、SQL の各部分を受け取るフィルターで書き換えます。どのフィルターも2番目の引数で WP_Query のインスタンスを受け取れるので、独自のクエリ変数を目印にして、対象のクエリにだけ効くようにします。
次の例は、閲覧数を保存した独自テーブル {prefix}my_post_views(post_id・view_count の列を持ち、1投稿1行)を結合して、閲覧数の多い順に並べます。
<?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;
}
// 閲覧数の記録が無い投稿は 0 として扱う
return 'COALESCE(pv.view_count, 0) DESC, ' . $orderby;
}
}
My_View_Count_Query::init();
// 使用例
$popular = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 10,
'no_found_rows' => true,
'my_order_by_views' => true, // 独自のクエリ変数(目印)
'my_min_views' => 100, // 100回以上閲覧された投稿だけ
) );
WP_Query に渡した独自のキーはクエリ変数として保持されるので、フィルターの中で $query->get() で読めます。書くときの注意は次のとおりです。
- 目印を確かめずにフィルターを付けると、管理画面の一覧やメインクエリを含むすべての
WP_Queryに効いてしまいます。 get_posts()は既定でsuppress_filtersをtrueにするため、posts_joinやposts_whereが呼ばれません。このフィルターを使うときはWP_Queryを使うか、get_posts()に'suppress_filters' => falseを渡します。- 結合するテーブルが1投稿に複数行を持つ場合は、同じ投稿が重複して返ります。その場合は
posts_groupbyフィルターで{$wpdb->posts}.IDを指定するか、集計済みのテーブルを結合します。 - 外から受け取った値を SQL に入れるときは、例のように
$wpdb->prepare()かabsint()などで整えてから使います。
生成された SQL を確認する
WP_Query が実行した SQL は、$query->request に入っています。SAVEQUERIES を有効にしなくても読めます。
<?php
// 開発環境で一時的に確認する
error_log( $popular->request );
SAVEQUERIES は、ページ内で実行されたすべてのクエリと所要時間を $wpdb->queries に記録する設定で、wp-config.php に define( 'SAVEQUERIES', true ); を書いて有効にします。記録の分だけ処理が重くなるので、本番環境では有効にしたままにしないでください。どのクエリが遅いかを画面で確かめたいときは、Query Monitor プラグインも便利です。
まとめ
tax_query・meta_queryはrelationを入れ子にして、「A または B、かつ C」のような条件を作れる。meta_queryのLIKEは前後に%が自動で付く。値に%を付けない。meta_value_numでの並び替えは、meta_keyかmeta_queryが無いと無視される。複数のメタで並べるときは名前付きの句を使う。- ページ送りが要らない一覧では
no_found_rows、IDだけで足りる処理ではfields => 'ids'を使う。 wp_reset_postdata()はthe_post()で書き換わった$postを戻す関数で、メモリの解放とは関係ない。- SQL フィルターは独自のクエリ変数で対象を絞り、生成された SQL は
$query->requestで確認する。
フィルターを自作プラグインにまとめる方法は、WordPressプラグイン開発の基礎で説明しています。