WP_Query
- Since
- 1.5.0, 4.5.0
- Source
wp-includes/class-wp-query.php:18
Query WordPress posts with WP_Query, the class behind every post loop: filter by post type, taxonomy, meta, date, and more, with pagination built in. Instantiate it with an argument array to build secondary loops, or use its is_*() conditional methods to identify what the current request is for.
Compatibility
- WordPress
- since 4.5.0
- 6.7.7
- 6.8.8
- 6.9.7
- 7.0.4
- 7.1.0
Present in every tracked release (6.7.7 to 7.1.0).
Usage
WP_Query is the engine behind every post listing in WordPress. The main query that builds each front-end page is a WP_Query instance stored in the global $wp_query, and any theme or plugin can create additional instances to fetch its own sets of posts. You pass an array of query variables to the constructor, the class translates them into a single SQL query against the posts table (joining terms, postmeta, and users as needed), and the results become available through the loop methods.
Before reaching for the class directly, check whether a simpler tool fits. Conditional tags such as is_single(), is_page(), and is_archive() read the main query's flags for you, and template tags like the_title() and the_content() already operate on the current post inside the loop. Instantiate your own WP_Query only when you genuinely need a second, custom set of posts.
The standard loop
The typical pattern: build the query, check have_posts(), then iterate with the_post(), which advances the internal pointer and populates the global $post so template tags work.
$the_query = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 5,
) );
if ( $the_query->have_posts() ) {
echo '<ul>';
while ( $the_query->have_posts() ) {
$the_query->the_post();
echo '<li>' . esc_html( get_the_title() ) . '</li>';
}
echo '</ul>';
} else {
// No posts matched.
}
wp_reset_postdata();The same loop in template style, convenient inside theme files:
<?php $the_query = new WP_Query( $args ); ?>
<?php if ( $the_query->have_posts() ) : ?>
<?php while ( $the_query->have_posts() ) : $the_query->the_post(); ?>
<h2><?php the_title(); ?></h2>
<?php endwhile; ?>
<?php wp_reset_postdata(); ?>
<?php else : ?>
<p><?php esc_html_e( 'Nothing found.', 'textdomain' ); ?></p>
<?php endif; ?>Restoring post data
Calling the_post() on a secondary query overwrites the global $post. Once your custom loop finishes, call wp_reset_postdata() so template tags refer to the main query's current post again. This matters most inside single templates and inside shortcodes or blocks that run mid-loop.
You do not need wp_reset_query() for a secondary WP_Query; that function exists to undo query_posts(), which replaces the global $wp_query itself and should be avoided entirely. When running queries in admin screens or AJAX handlers, get_posts() is often the safer choice because it never touches the loop globals in the first place.
Multiple independent loops on one page follow the same rule, reset after each:
$recent = new WP_Query( array( 'posts_per_page' => 3 ) );
while ( $recent->have_posts() ) {
$recent->the_post();
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata();
$featured = new WP_Query( array( 'category_name' => 'featured' ) );
while ( $featured->have_posts() ) {
$featured->the_post();
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata();Interacting via query() and the accessors
The constructor is a thin wrapper: if you pass arguments, it hands them to the [query()](/reference/classes/wp_query/query) method, which parses them and immediately fetches posts. You can also build an empty instance and run it later, or rerun it with different arguments:
$q = new WP_Query();
$q->query( array( 'post_type' => 'book', 'posts_per_page' => 10 ) );Use [get()](/reference/classes/wp_query/get) and [set()](/reference/classes/wp_query/set) to read or change individual query vars rather than poking at the $query_vars array directly, and get_query_var() for the main query. After the query runs, $q->posts holds the results, $q->post_count the number fetched for this page, $q->found_posts the total matches, and $q->max_num_pages the page count.
Checking what kind of request this is
Besides fetching posts, WP_Query classifies each request. After parsing, exactly the flags matching the request are true: $is_home, $is_front_page (via the method), $is_single, $is_page, $is_singular, $is_archive, $is_category, $is_tag, $is_tax, $is_author, $is_date (with $is_year, $is_month, $is_day, $is_time), $is_search, $is_feed, $is_404, $is_attachment, $is_paged, $is_post_type_archive, $is_privacy_policy, $is_embed, and a few more. The global conditional tags (is_single(), is_archive(), is_search(), and friends) simply proxy to the main query's methods, which is why they are unreliable before the main query has run (use them at wp or later, or inside pre_get_posts call the methods on the query object you are handed).
add_action( 'pre_get_posts', function ( $query ) {
if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {
$query->set( 'post_type', array( 'post', 'docs' ) );
}
} );For archive-style requests, [get_queried_object()](/reference/classes/wp_query/get_queried_object) returns the thing the archive is about: a WP_Term on category, tag, and taxonomy archives, a WP_User on author archives, a WP_Post on singular views, a WP_Post_Type on post type archives. [get_queried_object_id()](/reference/classes/wp_query/get_queried_object_id) returns its ID.
WP_Query vs get_posts() vs pre_get_posts
new WP_Query( $args )is the right tool for a secondary loop you will iterate with have_posts() and the_post(), and when you need the object itself (pagination totals, conditional flags, the generated SQL in$request).- get_posts() wraps
WP_Queryand simply returns the array of posts. It defaults to'ignore_sticky_posts' => trueand'no_found_rows' => true, and it suppresses most query filters by default (suppress_filtersis true). Prefer it for plain "give me these posts" retrieval where you will foreach the array yourself. - The
pre_get_postsaction is for changing a query that WordPress is already going to run, above all the main query. Never build a second query to "replace" the main one on an archive; hookpre_get_posts, check$query->is_main_query()and! is_admin(), then adjust vars with$query->set(). This keeps pagination, canonical URLs, and template selection consistent.
add_action( 'pre_get_posts', function ( $query ) {
if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {
$query->set( 'posts_per_page', 6 );
$query->set( 'post_type', array( 'post', 'portfolio' ) );
}
} );Query parameters
Everything below is passed as keys in the array given to the constructor (or to [query()](/reference/classes/wp_query/query)). Unspecified keys fall back to defaults: post_type of post, post_status of publish (plus private for logged-in users who can read them), the blog's "posts per page" setting for posts_per_page, and ordering by post_date descending. A query string form ('cat=4&posts_per_page=5') is also accepted, but the array form is clearer and required for the nested structures (tax_query, meta_query, date_query).
Parameters from different groups combine with AND semantics: every condition you add further narrows the result set. See the Combining parameters section for multi-taxonomy and meta-plus-taxonomy patterns.
Author parameters
Restrict results to posts by particular authors.
author(int or string): one author ID, a comma-separated list of IDs, or a negative ID to exclude that author.author_name(string): the author'suser_nicename(the URL-safe login slug), not the display name.author__in(array): author IDs to include.author__not_in(array): author IDs to exclude.
author__in and author__not_in cannot be used together in one query.
// Posts by author 123.
$q = new WP_Query( array( 'author' => 123 ) );
// Posts by nicename.
$q = new WP_Query( array( 'author_name' => 'rami' ) );
// Several authors, or everyone except two.
$q = new WP_Query( array( 'author__in' => array( 2, 6 ) ) );
$q = new WP_Query( array( 'author__not_in' => array( 2, 6 ) ) );
// Everyone except one author.
$q = new WP_Query( array( 'author' => -12 ) );Category parameters
Restrict results by category. These apply to the built-in category taxonomy; for custom taxonomies use tax_query.
cat(int or string): category ID; a comma-separated list means "in any of these"; negative IDs exclude. Includes posts in child categories.category_name(string): category slug (despite the name). A comma-separated list matches any of the slugs; joining slugs with+requires all of them. Includes children.category__in(array): category IDs, posts in any of them. Does not include child categories.category__not_in(array): category IDs to exclude (children of these categories are not excluded).category__and(array): category IDs, posts must be in all of them. Does not include children.
// In category 4, including its children.
$q = new WP_Query( array( 'cat' => 4 ) );
// By slug, either of two categories.
$q = new WP_Query( array( 'category_name' => 'staff,news' ) );
// Must be in both categories.
$q = new WP_Query( array( 'category__and' => array( 2, 6 ) ) );
// Exclude several categories.
$q = new WP_Query( array( 'cat' => '-12,-34,-56' ) );Tag parameters
Restrict results by tag (the built-in post_tag taxonomy).
tag(string): tag slug; comma-separated for "any of",+-joined for "all of".tag_id(int): tag ID.tag__in(array): tag IDs, any of them.tag__not_in(array): tag IDs to exclude.tag__and(array): tag IDs, all required.tag_slug__in(array): tag slugs, any of them.tag_slug__and(array): tag slugs, all required.
// One tag by slug.
$q = new WP_Query( array( 'tag' => 'cooking' ) );
// Any of these tags.
$q = new WP_Query( array( 'tag' => 'bread,baking' ) );
// All of these tags.
$q = new WP_Query( array( 'tag' => 'bread+baking+recipe' ) );
// Tagged with both IDs 37 and 47.
$q = new WP_Query( array( 'tag__and' => array( 37, 47 ) ) );Taxonomy parameters
tax_query is the general mechanism for querying any taxonomy, including category, post_tag, post_format, and custom taxonomies. It is parsed by [WP_Tax_Query](/reference/classes/wp_tax_query). The value is always an array of clause arrays, even for a single clause.
Top-level key:
relation(string):AND(default) orOR, the logical join between clauses. Omit it when there is only one clause.
Each clause array accepts:
taxonomy(string): taxonomy name.field(string): whattermsrefers to:term_id(default),slug,name, orterm_taxonomy_id.terms(int, string, or array): the term or terms to match.include_children(bool): for hierarchical taxonomies, whether descendant terms also match. Default true.operator(string):IN(default),NOT IN,AND(post must have every listed term),EXISTS, orNOT EXISTS(post has any term, or no term, in the taxonomy;termsis ignored for these two).
// Posts with the 'bob' term in a custom 'people' taxonomy.
$q = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'people',
'field' => 'slug',
'terms' => 'bob',
),
),
) );Two clauses joined with a relation:
// Action or comedy genre, but never these three actors.
$q = new WP_Query( array(
'post_type' => 'movie',
'tax_query' => array(
'relation' => 'AND',
array(
'taxonomy' => 'movie_genre',
'field' => 'slug',
'terms' => array( 'action', 'comedy' ),
),
array(
'taxonomy' => 'actor',
'field' => 'term_id',
'terms' => array( 103, 115, 206 ),
'operator' => 'NOT IN',
),
),
) );Note that setting tax_query changes the default post_type from post to any, so set post_type explicitly when you care. Nesting of clause groups is covered under Combining parameters.
The old shorthand of using a taxonomy slug directly as a query var ('people' => 'bob') has been deprecated since WordPress 3.1; write a tax_query instead. The registered taxonomy's query_var still works for URL-driven queries, but new code should not rely on it for programmatic queries.
An EXISTS example, every post that has at least one term in a taxonomy:
$q = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'people',
'operator' => 'EXISTS',
),
),
) );Search parameters
s(string): keyword search across post titles, excerpts, and content. Prefix a word with a hyphen to exclude posts containing it ('pillow -sofa'finds pillow posts that never mention sofa).search_columns(array): limit which columns are searched; any ofpost_title,post_excerpt,post_content. Empty (the default) searches all three. Available since WordPress 6.2.exact(bool): require the search string to match a whole field value rather than a substring. Rarely useful on its own.sentence(bool): treat the search string as one phrase instead of splitting it into terms.
When a search query has no explicit orderby, results are ranked by relevance: full-phrase matches first, then titles containing every term, then titles containing any term, then content matches.
// Title-only search.
$q = new WP_Query( array(
's' => 'block themes',
'search_columns' => array( 'post_title' ),
) );Post and page parameters
Target specific posts or pages directly. Remember the default post_type is post; page-oriented vars like page_id and pagename imply pages, but post__in and friends respect whatever post_type you set.
p(int): a single post ID.name(string): a single post slug.page_id(int): a single page ID.pagename(string): a page slug; for a child page use the path formparent-slug/child-slug.post_parent(int): return children of this ID;0returns only top-level items.post_parent__in(array): posts whose parent is any of these IDs.post_parent__not_in(array): posts whose parent is none of these IDs.post__in(array): only these post IDs. An empty array does not mean "no posts"; it is treated as no constraint, so guard against empty input before querying. Sticky posts can still be prepended on home-context queries; addignore_sticky_postsif that matters.post__not_in(array): exclude these post IDs. Cannot be combined withpost__inin the same query.post_name__in(array): only posts with these slugs.title(string): a single exact post title.
post__in and post__not_in want real arrays of integers. A string like '1,2,3' wrapped in an array is one useless element, not three IDs:
// Wrong: one string element.
$q = new WP_Query( array( 'post__not_in' => array( '1,2,3' ) ) );
// Right: an array of integers.
$q = new WP_Query( array( 'post__not_in' => array( 1, 2, 3 ) ) );// A single post, a single page, children of a page.
$q = new WP_Query( array( 'p' => 7 ) );
$q = new WP_Query( array( 'pagename' => 'contact-us/canada' ) );
$q = new WP_Query( array( 'post_parent' => 93 ) );
// Exactly these pages.
$q = new WP_Query( array(
'post_type' => 'page',
'post__in' => array( 2, 5, 12, 14, 20 ),
) );Password parameters
has_password(bool or null):truefor only password-protected posts,falsefor only unprotected posts,null(default) for both.post_password(string): only posts protected with this exact password.
// Everything without a password.
$q = new WP_Query( array( 'has_password' => false ) );
// Posts using one specific password.
$q = new WP_Query( array( 'post_password' => 'zxcvbn' ) );Post type parameters
post_type(string or array): which post type(s) to query. Defaultpost, but the default becomesanywhentax_queryis present. Common values:post,page,attachment,revision,nav_menu_item, any registered custom post type, orany(every type except revisions and types registered withexclude_from_searchtrue).
Attachments have a default post_status of inherit, not publish, so querying post_type => 'attachment' returns nothing unless you also set post_status to inherit or any.
// Several types at once, including custom ones.
$q = new WP_Query( array(
'post_type' => array( 'post', 'page', 'movie', 'book' ),
) );Status parameters
post_status(string or array): which statuses to include. Default ispublish; logged-in users also get their readableprivateposts, public custom statuses are included, and in admin or AJAX context the protected statuses (future,draft,pending) are added. Values:publish,pending,draft,auto-draft,future,private,inherit(revisions and attachments),trash, any registered custom status, orany(everything exceptinherit,trash,auto-draft, and statuses registered withexclude_from_searchtrue).
// Drafts and scheduled posts.
$q = new WP_Query( array(
'post_status' => array( 'draft', 'pending', 'future' ),
) );
// All attachments.
$q = new WP_Query( array(
'post_type' => 'attachment',
'post_status' => 'any',
) );Comment parameters
comment_count(int or array): filter posts by how many approved comments they have. As an integer it means an exact match. As an array it takesvalue(int) andcompare(one of=,!=,>,>=,<,<=; default=).
// Posts with at least 25 comments.
$q = new WP_Query( array(
'comment_count' => array(
'value' => 25,
'compare' => '>=',
),
) );Pagination parameters
posts_per_page(int): posts per page.-1returns everything (and makesoffsetignored). Defaults to the "Blog pages show at most" setting. In feed context WordPress substitutes theposts_per_rssoption; use thepost_limitsfilter if you need to override that.posts_per_archive_page(int): overridesposts_per_pageon pages where is_archive() or is_search() is true.paged(int): which page of results, as used by "older posts" links. Pull the live value withget_query_var( 'paged' ).page(int): the page number on a static front page. Also holds the sub-page of a single post split with thenextpagequicktag, so useget_query_var( 'page' )in a static-front-page template.offset(int): skip this many posts. Warning: a set offset overridespagedand breaks normal pagination; if you need both, compute the offset yourself per page. Ignored whenposts_per_pageis-1.nopaging(bool):truedisables paging and returns all posts. Defaultfalse.no_found_rows(bool):trueskips counting the total number of matching rows.found_postsandmax_num_pagesbecome useless, but the query is cheaper. Ideal for widgets and blocks that never paginate.ignore_sticky_posts(bool): defaultfalse, meaning sticky posts are moved to the front of the first page of home-context queries (and this can also affect secondary queries whose vars leave them classified as a home query, including somepost__inqueries). Settrueto leave stickies in natural order. Note that stickies excluded from a filtered query can still be prepended unless this is set.
// Correct pagination for a custom loop.
$paged = get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1;
$q = new WP_Query( array(
'posts_per_page' => 5,
'paged' => $paged,
) );
// Everything, no paging.
$q = new WP_Query( array( 'posts_per_page' => -1 ) );Sticky-post recipes:
// Only the newest sticky post; nothing if there are no stickies.
$sticky = get_option( 'sticky_posts' );
$q = new WP_Query( array(
'posts_per_page' => 1,
'post__in' => $sticky,
'ignore_sticky_posts' => 1,
) );
// A category listing with stickies in natural date order.
$q = new WP_Query( array(
'cat' => 6,
'ignore_sticky_posts' => 1,
) );
// Exclude stickies entirely, with working pagination.
$q = new WP_Query( array(
'cat' => 3,
'ignore_sticky_posts' => 1,
'post__not_in' => get_option( 'sticky_posts' ),
'paged' => max( 1, get_query_var( 'paged' ) ),
) );Order and orderby parameters
order(string or array):DESC(default) orASC. Ignored per-key whenorderbyis an associative array (each key carries its own direction there).orderby(string or array): what to sort by. Defaultdate. Pass a single value, a space-separated list ('menu_order title'), or an associative array of value-to-direction pairs.
Accepted orderby values:
none: no ORDER BY clause at all.ID: post ID (note the capitalization).author: author ID.title: post title.name: post slug.type: post type.date: publish date (the default).modified: last modified date.parent: parent post ID.rand: random order. Expensive on large tables; avoid on high-traffic pages.comment_count: number of comments.relevance: search ranking; the default whensis set (phrase match, then all terms in title, then any term in title, then content).menu_order: the manual "Order" field on pages and attachments, usable by any post type; all posts default to 0.meta_value: sort by the value of the custom field named inmeta_key. The comparison is alphabetical, so numbers sort as strings (1, 10, 2) unless you setmeta_typeor usemeta_value_num. Withmeta_typeset (for exampleDATETIME), the matching alias such asmeta_value_datetimealso works.meta_value_num: numeric sort on themeta_keyvalue.post__in: keep the exact ID order you passed inpost__in.orderhas no effect.post_name__in: keep the slug order passed inpost_name__in.orderhas no effect.post_parent__in: keep the parent-ID order passed inpost_parent__in.orderhas no effect.
// Title, Z to A.
$q = new WP_Query( array( 'orderby' => 'title', 'order' => 'DESC' ) );
// menu_order first, title as tiebreaker.
$q = new WP_Query( array( 'orderby' => 'menu_order title', 'order' => 'ASC' ) );
// Independent directions per key.
$q = new WP_Query( array(
'orderby' => array( 'title' => 'DESC', 'menu_order' => 'ASC' ),
) );
// Numeric custom field sort.
$q = new WP_Query( array(
'post_type' => 'product',
'meta_key' => 'price',
'orderby' => 'meta_value_num',
'order' => 'ASC',
) );To sort by more than one custom field, give the meta_query clauses names and reference those names in the orderby array (see named clauses under Custom field parameters):
$q = new WP_Query( array(
'meta_query' => array(
'relation' => 'AND',
'state_clause' => array(
'key' => 'state',
'value' => 'Wisconsin',
),
'city_clause' => array(
'key' => 'city',
'compare' => 'EXISTS',
),
),
'orderby' => array(
'city_clause' => 'ASC',
'state_clause' => 'DESC',
),
) );Date parameters
Simple date vars match one fixed period:
year(int): four-digit year.monthnum(int): month, 1 to 12.w(int): week of the year, 0 to 53 (MySQL WEEK semantics, influenced by thestart_of_weekoption).day(int): day of the month, 1 to 31.hour(int): 0 to 23.minute(int): 0 to 60.second(int): 0 to 60.m(int): combined year and month, e.g.202607.
date_query (array) is the flexible form, parsed by [WP_Date_Query](/reference/classes/wp_date_query). Like tax_query and meta_query it is an array of clause arrays, with an optional top-level relation of AND (default) or OR. Each clause accepts:
year,month,week,day,hour,minute,second(int): fixed components, as above (alsodayofweek,dayofweek_iso,dayofyear).after(string or array): only posts after this date. Takes a strtotime()-compatible string or an array withyear,month,daykeys.before(string or array): only posts before this date, same formats.inclusive(bool): whetherafter/beforeboundaries match exactly. Default false.compare(string): comparison operator for the fixed components, e.g.=,>,<=,BETWEEN,IN.column(string): which date column to test. Defaultpost_date; alsopost_date_gmt,post_modified,post_modified_gmt.
// Posts from December 12, 2012.
$q = new WP_Query( array(
'date_query' => array(
array(
'year' => 2012,
'month' => 12,
'day' => 12,
),
),
) );
// Business hours on weekdays only.
$q = new WP_Query( array(
'date_query' => array(
array( 'hour' => 9, 'compare' => '>=' ),
array( 'hour' => 17, 'compare' => '<=' ),
array( 'dayofweek' => array( 2, 6 ), 'compare' => 'BETWEEN' ),
),
'posts_per_page' => -1,
) );
// A date range.
$q = new WP_Query( array(
'date_query' => array(
array(
'after' => 'January 1st, 2026',
'before' => array(
'year' => 2026,
'month' => 2,
'day' => 28,
),
'inclusive' => true,
),
),
) );Boundary gotcha: a date-only string in before resolves to midnight (00:00:00) of that day, so that day's posts are excluded even with inclusive true. Include a time ('2026-02-28 23:59:59') or use the array form, which inclusive adjusts correctly.
Clauses can target different columns, for example published over a year ago but edited recently:
$q = new WP_Query( array(
'date_query' => array(
array(
'column' => 'post_date_gmt',
'before' => '1 year ago',
),
array(
'column' => 'post_modified_gmt',
'after' => '1 month ago',
),
),
) );date_query clauses can be nested with inner relation keys, the same shape as nested tax_query groups.
Custom field (post meta) parameters
The simple form matches one condition:
meta_key(string): custom field key.meta_value(string): custom field value (string comparison).meta_value_num(number): custom field value compared numerically.meta_compare(string): operator for the simple form:=(default),!=,>,>=,<,<=,LIKE,NOT LIKE,IN,NOT IN,BETWEEN,NOT BETWEEN,NOT EXISTS,REGEXP,NOT REGEXP,RLIKE.meta_type(string): cast type used when this key participates inorderby.
meta_query (array) is the full mechanism, parsed by [WP_Meta_Query](/reference/classes/wp_meta_query). It is an array of clause arrays with an optional top-level relation of AND (default) or OR. Each clause accepts:
key(string): custom field key.value(string or array): value to compare. Must be an array only forIN,NOT IN,BETWEEN,NOT BETWEEN. Omit it forEXISTSandNOT EXISTS.compare(string):=(default),!=,>,>=,<,<=,LIKE,NOT LIKE,IN,NOT IN,BETWEEN,NOT BETWEEN,EXISTS,NOT EXISTS,REGEXP,NOT REGEXP,RLIKE.type(string): cast for the comparison:CHAR(default),NUMERIC,BINARY,DATE,DATETIME,DECIMAL,SIGNED,TIME,UNSIGNED.DATEworks withBETWEENonly when values are stored and compared asYYYY-MM-DD.
// Simple form: key and value together.
$q = new WP_Query( array(
'meta_key' => 'color',
'meta_value' => 'blue',
) );
// Numeric comparison needs meta_value_num or a type cast;
// as strings, '99' sorts greater than '100'.
$q = new WP_Query( array(
'post_type' => 'product',
'meta_key' => 'price',
'meta_value' => '22',
'meta_compare' => '<=',
) );meta_query always takes an array of arrays, even for a single clause:
$q = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
array(
'key' => 'color',
'value' => 'blue',
'compare' => 'NOT LIKE',
),
),
) );Multiple clauses with a relation:
// color NOT LIKE blue OR price BETWEEN 20 and 100.
$q = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'color',
'value' => 'blue',
'compare' => 'NOT LIKE',
),
array(
'key' => 'price',
'value' => array( 20, 100 ),
'type' => 'NUMERIC',
'compare' => 'BETWEEN',
),
),
) );Named clauses: give each clause a string key instead of a numeric index, and those names become valid orderby targets. This is the only way to order by multiple meta fields (see the Order and orderby section for a full example).
EXISTS and NOT EXISTS test for the key's presence regardless of value, useful for "has this field been set at all" queries:
$q = new WP_Query( array(
'meta_query' => array(
array(
'key' => 'featured_image_alt',
'compare' => 'NOT EXISTS',
),
),
) );Permission parameters
perm(string): intersectpost_statuswith the current user's capabilities.'readable'keeps only statuses the user can actually read (soprivateposts show only to users withread_private_posts);'editable'keeps statuses the user can edit.
// Public posts plus private ones the user is allowed to see.
$q = new WP_Query( array(
'post_status' => array( 'publish', 'private' ),
'perm' => 'readable',
) );Mime type parameters
post_mime_type(string or array): restrict attachments by MIME type. Accepts full types (image/gif), wildcards (image,image/*), or an array of types. Only meaningful withpost_type => 'attachment', which also needspost_status => 'inherit'.
// All GIF attachments.
$q = new WP_Query( array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => 'image/gif',
) );There is no "not this MIME type" operator; to exclude types, build the allowed list yourself, for example by diffing get_allowed_mime_types() against the types you want removed and passing the remainder as an array.
Caching parameters
cache_results(bool): whether to cache the fetched posts. Default true. Since WordPress 6.1 the query itself is also cached (the resulting IDs are stored in the object cache keyed by the query), so repeated identical queries can skip the database; setting this to false opts a query out.update_post_meta_cache(bool): whether to prime the postmeta cache for the results. Default true.update_post_term_cache(bool): whether to prime the term cache for the results. Default true.lazy_load_term_meta(bool): whether term meta for the results should be lazily loaded on first access. Default matchesupdate_post_term_cache.
Leave these alone in normal code; priming caches is what prevents the classic N+1 query problem inside loops. Turn the meta and term caches off only when you know the loop touches neither, for example a bare list of titles and permalinks:
$q = new WP_Query( array(
'posts_per_page' => 50,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
) );Return fields parameter
fields(string): shape of the returned results.'all'(default) returns fullWP_Postobjects.'ids'returns a flat array of post IDs.'id=>parent'returns objects containing onlyIDandpost_parent. Any other value falls back to'all'.
// Just the IDs, cheap and cache-friendly.
$q = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 100,
'fields' => 'ids',
) );
$ids = $q->posts; // array of integersWith 'ids' or 'id=>parent' there is nothing for the loop methods to set up, so iterate $q->posts directly instead of using have_posts(). ID-only queries pair naturally with the caching parameters above: skipping full objects, meta, and terms makes large scans far cheaper.
// Map every page to its parent, e.g. to build a tree.
$q = new WP_Query( array(
'post_type' => 'page',
'posts_per_page' => -1,
'fields' => 'id=>parent',
) );
foreach ( $q->posts as $row ) {
// $row->ID, $row->post_parent
}Combining parameters
All top-level parameters AND together: post_type plus cat plus s returns posts of that type, in that category, matching that search. The nested query structures then let you express OR logic and grouping inside each dimension.
Multiple taxonomies
One tax_query can span any number of taxonomies. The top-level relation joins the clauses:
// In the 'quotes' category OR having the quote post format.
$q = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'OR',
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'quotes' ),
),
array(
'taxonomy' => 'post_format',
'field' => 'slug',
'terms' => array( 'post-format-quote' ),
),
),
) );Nested tax_query groups
A clause position can hold a whole sub-group with its own relation, letting you mix AND and OR:
// 'quotes' category OR (quote format AND 'wisdom' category).
$q = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
'relation' => 'OR',
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'quotes' ),
),
array(
'relation' => 'AND',
array(
'taxonomy' => 'post_format',
'field' => 'slug',
'terms' => array( 'post-format-quote' ),
),
array(
'taxonomy' => 'category',
'field' => 'slug',
'terms' => array( 'wisdom' ),
),
),
),
) );meta_query and date_query nest the same way: replace a clause with an array that has its own relation and inner clauses.
// color = orange OR (color = red AND size = small).
$q = new WP_Query( array(
'post_type' => 'product',
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'color',
'value' => 'orange',
),
array(
'relation' => 'AND',
array(
'key' => 'color',
'value' => 'red',
),
array(
'key' => 'size',
'value' => 'small',
),
),
),
) );Taxonomy and meta together
tax_query and meta_query coexist in one query and AND together, each keeping its own internal relation:
// Products in the 'outdoor' category priced 50 or less, cheapest first.
$q = new WP_Query( array(
'post_type' => 'product',
'tax_query' => array(
array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'outdoor',
),
),
'meta_query' => array(
'price_clause' => array(
'key' => 'price',
'value' => 50,
'type' => 'NUMERIC',
'compare' => '<=',
),
),
'orderby' => array( 'price_clause' => 'ASC' ),
) );Every taxonomy clause and meta clause adds a JOIN, so deeply combined queries get expensive. Keep clause counts sensible, add no_found_rows when you do not paginate, and consider caching the result of genuinely heavy queries.
Properties and methods
Everything the query learned is exposed on the object. The Properties table further down this page lists them all; the ones you will reach for most are $posts (the results), $post_count and $found_posts (this page's count vs. total matches), $max_num_pages, $query_vars (the parsed vars), $request (the generated SQL), and the $is_* flags that classify the query ($is_home, $is_single, $is_archive, and the rest). Read the flags through their method counterparts ([is_home()](/reference/classes/wp_query/is_home), [is_singular()](/reference/classes/wp_query/is_singular), and so on) rather than the raw properties, and prefer [get()](/reference/classes/wp_query/get)/[set()](/reference/classes/wp_query/set) over editing $query_vars directly.
The Methods table below covers the full API: the loop methods ([have_posts()](/reference/classes/wp_query/have_posts), [the_post()](/reference/classes/wp_query/the_post), [rewind_posts()](/reference/classes/wp_query/rewind_posts)), the comment loop equivalents, the conditional methods mirroring each $is_* flag, [get_queried_object()](/reference/classes/wp_query/get_queried_object) for the term, author, or post an archive represents, and [is_main_query()](/reference/classes/wp_query/is_main_query), the check that belongs in every pre_get_posts callback.
Code examples
Every example is editable and runs in a real WordPress booted in your browser by WordPress Playground. Press Run, then edit the code: clicking away re-runs it. Nothing is sent anywhere until you do.
Query by a hierarchical taxonomy term
tax_query selects on terms, and include_children means a parent term also returns posts in its children.
$q = new WP_Query( array(
'post_type' => 'post',
'tax_query' => array(
array(
'taxonomy' => 'topic',
'field' => 'slug',
'terms' => 'engineering',
'include_children' => true,
),
),
) );
echo "matched {$q->found_posts} post(s) under Engineering\n\n";
while ( $q->have_posts() ) {
$q->the_post();
echo '- ', get_the_title(), "\n";
}
wp_reset_postdata();Set include_children to false to match only posts on the parent term itself.
Filter by a custom field with meta_query
meta_query selects on post meta and, unlike meta_key alone, supports comparisons and multiple clauses.
$priced = new WP_Query( array(
'post_type' => 'post',
'meta_query' => array(
array( 'key' => 'price', 'compare' => 'EXISTS' ),
),
) );
echo "matched {$priced->found_posts} post(s)\n\n";
while ( $priced->have_posts() ) {
$priced->the_post();
echo get_the_title(), ' costs ', get_post_meta( get_the_ID(), 'price', true ), "\n";
}
wp_reset_postdata();Every clause joins wp_postmeta, so keep the clause count low on large sites.
Common problems and fixes · 6
- Why does my custom WP_Query break the rest of the page?
- Why is pagination not working in my secondary loop?
- Should I use a new WP_Query or the pre_get_posts hook?
- What does posts_per_page => -1 actually cost?
- How do I stop WP_Query running the extra count query?
- Why is my meta_query slow?
Why does my custom WP_Query break the rest of the page?
$query->the_post() overwrites the global $post and the global post data that template tags read from. When your loop ends, the rest of the page is still looking at the last post your query returned, so the title, permalink and content of the surrounding template come out wrong. Call wp_reset_postdata() immediately after the loop to restore the globals to the main query's current post. This is only needed when you used the_post(); a loop that reads $query->posts directly never touches the globals.Why is pagination not working in my secondary loop?
WP_Query does not read the page number for you. Pass it explicitly with 'paged' => get_query_var( 'paged' ) on most archives, and get_query_var( 'page' ) when the loop is on a static front page, because WordPress uses a different query var there. If page 2 gives you a 404 instead of results, the problem is upstream of the query: the main query is 404ing before your loop runs, which usually means the URL is a static page rather than an archive.Should I use a new WP_Query or the pre_get_posts hook?
new WP_Query runs an additional database query and leaves the main one alone, which is what you want for a sidebar, a related-posts block, or anything secondary. pre_get_posts modifies the query WordPress was already going to run, which is what you want to change an archive, a search page, or the number of posts on the blog index. Replacing the main query with a second WP_Query in a template is the common mistake: it doubles the queries and breaks pagination and body classes. Inside pre_get_posts, always guard with if ( ! is_admin() && $query->is_main_query() ).What does posts_per_page => -1 actually cost?
WP_Post object for each one and primes the meta and term caches for all of them. On a site with a few hundred posts that is unnoticeable; on one with tens of thousands it is a memory exhaustion waiting for the day the content grows. Set a real ceiling you are willing to render. If you genuinely need everything, ask for 'fields' => 'ids' so no post objects are built.How do I stop WP_Query running the extra count query?
'no_found_rows' => true. Related switches are 'update_post_meta_cache' and 'update_post_term_cache', which you can set to false when you know you will not read meta or terms from the results.Why is my meta_query slow?
wp_postmeta, and while meta_key is indexed, meta_value is a longtext that is not usefully indexed. Filtering thousands of posts by a meta value therefore scans. If the value is something you filter or archive by rather than merely display, model it as a taxonomy instead: term queries hit indexed integer columns and stay fast as the table grows. Where meta is the right model, keep the clause count low and make the meta_key as selective as possible.Alternatives and related functions
get_posts- When you want an array of posts and nothing else, with no loop, no globals to reset and no pagination.
pre_get_posts- When the goal is to change what the page is already showing, rather than to run a second query alongside it.
WP_Term_Query- When you are querying categories, tags or custom taxonomy terms rather than posts.
WP_User_Query- When you are querying users, including by role, meta or the posts they have authored.
WP_Comment_Query- When you are querying comments, which have their own table and their own set of arguments.
Hooks and filters fired · 54
Every hook that fires from inside WP_Query, in the order it appears in the class, grouped by the method that fires it.
WP_Query::parse_query()
- do_action( parse_query )action line 1154
WP_Query::parse_tax_query()
- do_action( parse_tax_query )action line 1410
WP_Query::parse_search()
- apply_filters( post_search_columns )filter line 1473
- apply_filters( wp_query_search_exclusion_prefix )filter line 1489
WP_Query::set_404()
- do_action( set_404 )action line 1848
WP_Query::get_posts()
- do_action( pre_get_posts )action line 1910
- apply_filters( wp_allow_query_attachment_by_filename )filter line 1925
- apply_filters( posts_search )filter line 2291
- apply_filters( posts_search_orderby )filter line 2566
- apply_filters( posts_where )filter line 2788
- apply_filters( posts_join )filter line 2798
- apply_filters( comment_feed_join )filter line 2839
- apply_filters( comment_feed_where )filter line 2849
- apply_filters( comment_feed_groupby )filter line 2859
- apply_filters( comment_feed_orderby )filter line 2869
- apply_filters( comment_feed_limits )filter line 2879
- apply_filters( posts_where_paged )filter line 2936
- apply_filters( posts_groupby )filter line 2946
- apply_filters( posts_join_paged )filter line 2958
- apply_filters( posts_orderby )filter line 2968
- apply_filters( posts_distinct )filter line 2978
- apply_filters( post_limits )filter line 2988
- apply_filters( posts_fields )filter line 2998
- apply_filters( posts_clauses )filter line 3021
- do_action( posts_selection )action line 3041
- apply_filters( posts_where_request )filter line 3058
- apply_filters( posts_groupby_request )filter line 3070
- apply_filters( posts_join_request )filter line 3082
- apply_filters( posts_orderby_request )filter line 3094
- apply_filters( posts_distinct_request )filter line 3106
- apply_filters( posts_fields_request )filter line 3118
- apply_filters( post_limits_request )filter line 3130
- apply_filters( posts_clauses_request )filter line 3155
- apply_filters( posts_request )filter line 3208
- apply_filters( posts_pre_query )filter line 3227
- apply_filters( split_the_query )filter line 3399
- apply_filters( posts_request_ids )filter line 3421
- apply_filters( posts_results )filter line 3467
- apply_filters( comment_feed_join )filter line 3472
- apply_filters( comment_feed_where )filter line 3475
- apply_filters( comment_feed_groupby )filter line 3478
- apply_filters( comment_feed_orderby )filter line 3482
- apply_filters( comment_feed_limits )filter line 3486
- apply_filters( the_preview )filter line 3561
- apply_filters( the_posts )filter line 3624
WP_Query::set_found_posts()
- apply_filters( found_posts_query )filter line 3695
- apply_filters( found_posts )filter line 3718
WP_Query::the_post()
- do_action( loop_start )action line 3793
WP_Query::have_posts()
- do_action( loop_end )action line 3838
- do_action( loop_no_results )action line 3852
WP_Query::the_comment()
- do_action( comment_loop_start )action line 3904
WP_Query::setup_postdata()
- do_action( the_post )action line 4860
WP_Query::generate_postdata()
- apply_filters( content_pagination )filter line 4950
Properties · 58
$queryarraypublic- Query vars set by the user.
$query_varsarraypublic- Query vars, after parsing.
$tax_queryWP_Tax_Query|nullpublic- Taxonomy query, as passed to get_tax_sql().
$meta_queryWP_Meta_Querypublic- Metadata query container.
$date_queryWP_Date_Querypublic- Date query container.
$queried_objectWP_Term|WP_Post_Type|WP_Post|WP_User|nullpublic- Holds the data for a single object that is queried.
$queried_object_idintpublic- The ID of the queried object.
$requeststringpublic- SQL for the database query.
$postsWP_Post[]|int[]public- Array of post objects or post IDs.
$post_countintpublic- The number of posts for the current query.
$current_postintpublic- Index of the current item in the loop.
$before_loopboolpublic- Whether the caller is before the loop.
$in_the_loopboolpublic- Whether the loop has started and the caller is in the loop.
$postWP_Post|nullpublic- The current post.
$commentsWP_Comment[]public- The list of comments for current post.
$comment_countintpublic- The number of comments for the posts.
$current_commentintpublic- The index of the comment in the comment loop.
$commentWP_Commentpublic- Current comment object.
$found_postsintpublic- The number of found posts for the current query.
$max_num_pagesintpublic- The number of pages.
$max_num_comment_pagesintpublic- The number of comment pages.
$is_singleboolpublic- Signifies whether the current query is for a single post.
$is_previewboolpublic- Signifies whether the current query is for a preview.
$is_pageboolpublic- Signifies whether the current query is for a page.
$is_archiveboolpublic- Signifies whether the current query is for an archive.
$is_dateboolpublic- Signifies whether the current query is for a date archive.
$is_yearboolpublic- Signifies whether the current query is for a year archive.
$is_monthboolpublic- Signifies whether the current query is for a month archive.
$is_dayboolpublic- Signifies whether the current query is for a day archive.
$is_timeboolpublic- Signifies whether the current query is for a specific time.
$is_authorboolpublic- Signifies whether the current query is for an author archive.
$is_categoryboolpublic- Signifies whether the current query is for a category archive.
$is_tagboolpublic- Signifies whether the current query is for a tag archive.
$is_taxboolpublic- Signifies whether the current query is for a taxonomy archive.
$is_searchboolpublic- Signifies whether the current query is for a search.
$is_feedboolpublic- Signifies whether the current query is for a feed.
$is_comment_feedboolpublic- Signifies whether the current query is for a comment feed.
$is_trackbackboolpublic- Signifies whether the current query is for trackback endpoint call.
$is_homeboolpublic- Signifies whether the current query is for the site homepage.
$is_privacy_policyboolpublic- Signifies whether the current query is for the Privacy Policy page.
$is_404boolpublic- Signifies whether the current query couldn't find anything.
$is_embedboolpublic- Signifies whether the current query is for an embed.
$is_pagedboolpublic- Signifies whether the current query is for a paged result and not for the first page.
$is_adminboolpublic- Signifies whether the current query is for an administrative interface page.
$is_attachmentboolpublic- Signifies whether the current query is for an attachment page.
$is_singularboolpublic- Signifies whether the current query is for an existing single post of any post type (post, attachment, page, custom post types).
$is_robotsboolpublic- Signifies whether the current query is for the robots.txt file.
$is_faviconboolpublic- Signifies whether the current query is for the favicon.ico file.
$is_posts_pageboolpublic- Signifies whether the current query is for the page_for_posts page.
$is_post_type_archiveboolpublic- Signifies whether the current query is for a post type archive.
$query_vars_hashbool|stringprivate- Stores the ->query_vars state like md5(serialize( $this->query_vars ) ) so we know whether we have to re-parse because something has changed
$query_vars_changedboolprivate- Whether query vars have changed since the initial parse_query() call. Used to catch modifications to query vars made via pre_get_posts hooks.
$thumbnails_cachedboolpublic- Set if post thumbnails are cached
$allow_query_attachment_by_filenameboolprotected- Controls whether an attachment query should include filenames or not.
$stopwordsarrayprivate- Cached list of search stopwords.
$compat_fieldsprivate$compat_methodsprivate$query_cache_keystringprivate- The cache key generated by the query.
Methods · 68
- init_query_flags()Resets query flags to false.
- init()Initiates object properties and sets default values.
- parse_query_vars()Reparses the query vars.
- fill_query_vars()Fills in the query variables, which do not exist within the parameter.
- parse_query()Parses a query string and sets query type booleans.
- parse_tax_query()Parses various taxonomy related query vars.
- parse_search()Generates SQL for the WHERE clause based on passed search terms.
- parse_search_terms()Checks if the terms are suitable for searching.
- get_search_stopwords()Retrieves stopwords used when parsing search terms.
- parse_search_order()Generates SQL for the ORDER BY condition based on passed search terms.
- parse_orderby()Converts the given orderby alias (if allowed) to a properly-prefixed value.
- parse_order()Parse an 'order' query variable and cast it to ASC or DESC as necessary.
- set_404()Sets the 404 property and saves whether query is feed.
- get()Retrieves the value of a query variable.
- set()Sets the value of a query variable.
- get_posts()Retrieves an array of posts based on query variables.
- set_found_posts()Sets up the amount of found posts and the number of pages (if limit clause was used) for the current query.
- next_post()Sets up the next post and iterate current post index.
- the_post()Sets up the current post.
- have_posts()Determines whether there are more posts available in the loop.
- rewind_posts()Rewinds the posts and resets post index.
- next_comment()Iterates current comment index and returns WP_Comment object.
- the_comment()Sets up the current comment.
- have_comments()Determines whether there are more comments available.
- rewind_comments()Rewinds the comments, resets the comment index and comment to first.
- query()Sets up the WordPress query by parsing query string.
- get_queried_object()Retrieves the currently queried object.
- get_queried_object_id()Retrieves the ID of the currently queried object.
- __construct()Constructor.
- __get()Makes private properties readable for backward compatibility.
- __isset()Makes private properties checkable for backward compatibility.
- __call()Makes private/protected methods readable for backward compatibility.
- is_archive()Determines whether the query is for an existing archive page.
- is_post_type_archive()Determines whether the query is for an existing post type archive page.
- is_attachment()Determines whether the query is for an existing attachment page.
- is_author()Determines whether the query is for an existing author archive page.
- is_category()Determines whether the query is for an existing category archive page.
- is_tag()Determines whether the query is for an existing tag archive page.
- is_tax()Determines whether the query is for an existing custom taxonomy archive page.
- is_comments_popup()Determines whether the current URL is within the comments popup window.
- is_date()Determines whether the query is for an existing date archive.
- is_day()Determines whether the query is for an existing day archive.
- is_feed()Determines whether the query is for a feed.
- is_comment_feed()Determines whether the query is for a comments feed.
- is_front_page()Determines whether the query is for the front page of the site.
- is_home()Determines whether the query is for the blog homepage.
- is_privacy_policy()Determines whether the query is for the Privacy Policy page.
- is_month()Determines whether the query is for an existing month archive.
- is_page()Determines whether the query is for an existing single page.
- is_paged()Determines whether the query is for a paged result and not for the first page.
- is_preview()Determines whether the query is for a post or page preview.
- is_robots()Determines whether the query is for the robots.txt file.
- is_favicon()Determines whether the query is for the favicon.ico file.
- is_search()Determines whether the query is for a search.
- is_single()Determines whether the query is for an existing single post.
- is_singular()Determines whether the query is for an existing single post of any post type (post, attachment, page, custom post types).
- is_time()Determines whether the query is for a specific time.
- is_trackback()Determines whether the query is for a trackback endpoint call.
- is_year()Determines whether the query is for an existing year archive.
- is_404()Determines whether the query is a 404 (returns no results).
- is_embed()Determines whether the query is for an embedded post.
- is_main_query()Determines whether the query is the main query.
- setup_postdata()Sets up global post data.
- generate_postdata()Generates post data.
- generate_cache_key()Generates cache key.
- reset_postdata()After looping through a nested query, this function restores the $post global to the current post in this query.
- lazyload_term_meta()Lazyloads term meta for posts in the loop.
- lazyload_comment_meta()Lazyloads comment meta for comments in the loop.
Source code
#[AllowDynamicProperties]class WP_Query { /** * Query vars set by the user. * * @since 1.5.0 * @var array */ public $query; /** * Query vars, after parsing. * * @since 1.5.0 * @var array */ public $query_vars = array(); /** * Taxonomy query, as passed to get_tax_sql(). * * @since 3.1.0 * @var WP_Tax_Query|null A taxonomy query instance. */ public $tax_query; /** * Metadata query container. * * @since 3.2.0 * @var WP_Meta_Query A meta query instance. */ public $meta_query = false; /** * Date query container. * * @since 3.7.0 * @var WP_Date_Query A date query instance. */ public $date_query = false; /** * Holds the data for a single object that is queried. * * Holds the contents of a post, page, category, attachment. * * @since 1.5.0 * @var WP_Term|WP_Post_Type|WP_Post|WP_User|null */ public $queried_object; /** * The ID of the queried object. * * @since 1.5.0 * @var int */ public $queried_object_id; /** * SQL for the database query. * * @since 2.0.1 * @var string */ public $request; /** * Array of post objects or post IDs. * * @since 1.5.0 * @var WP_Post[]|int[] */ public $posts; /** * The number of posts for the current query. *Changelog
Introduced in 1.5.0. One change between 6.7.7 and 7.1.0.
Signature, return type and hooks compared across 5 parsed releases.
is_sitemap() added.verified against source$comments_popup property.from the docblockAbout this page
- Parsed data
- Generated from the wordpress-develop 6.8.8 tag, from
src/wp-includes/class-wp-query.php, and regenerated for each WordPress release so it tracks the code rather than a snapshot of it. - Corrections
- Something wrong on this page? Report it and it gets fixed in the next regeneration.