wp-includes/class-wp-query.php:18Query 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.
The WordPress Query class.
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 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; ?>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();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.
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.
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).WP_Query and simply returns the array of posts. It defaults to 'ignore_sticky_posts' => true and 'no_found_rows' => true, and it suppresses most query filters by default (suppress_filters is true). Prefer it for plain "give me these posts" retrieval where you will foreach the array yourself.pre_get_posts action 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; hook pre_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' ) );
}
} );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.
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's user_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 ) );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' ) );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 ) ) );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) or OR, the logical join between clauses. Omit it when there is only one clause.Each clause array accepts:
taxonomy (string): taxonomy name.field (string): what terms refers to: term_id (default), slug, name, or term_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, or NOT EXISTS (post has any term, or no term, in the taxonomy; terms is 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',
),
),
) );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 of post_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' ),
) );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 form parent-slug/child-slug.post_parent (int): return children of this ID; 0 returns 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; add ignore_sticky_posts if that matters.post__not_in (array): exclude these post IDs. Cannot be combined with post__in in 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 ),
) );has_password (bool or null): true for only password-protected posts, false for 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 (string or array): which post type(s) to query. Default post, but the default becomes any when tax_query is present. Common values: post, page, attachment, revision, nav_menu_item, any registered custom post type, or any (every type except revisions and types registered with exclude_from_search true).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' ),
) );post_status (string or array): which statuses to include. Default is publish; logged-in users also get their readable private posts, 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, or any (everything except inherit, trash, auto-draft, and statuses registered with exclude_from_search true).// 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_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 takes value (int) and compare (one of =, !=, >, >=, <, <=; default =).// Posts with at least 25 comments.
$q = new WP_Query( array(
'comment_count' => array(
'value' => 25,
'compare' => '>=',
),
) );posts_per_page (int): posts per page. -1 returns everything (and makes offset ignored). Defaults to the "Blog pages show at most" setting. In feed context WordPress substitutes the posts_per_rss option; use the post_limits filter if you need to override that.posts_per_archive_page (int): overrides posts_per_page on 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 with get_query_var( 'paged' ).page (int): the page number on a static front page. Also holds the sub-page of a single post split with the nextpage quicktag, so use get_query_var( 'page' ) in a static-front-page template.offset (int): skip this many posts. Warning: a set offset overrides paged and breaks normal pagination; if you need both, compute the offset yourself per page. Ignored when posts_per_page is -1.nopaging (bool): true disables paging and returns all posts. Default false.no_found_rows (bool): true skips counting the total number of matching rows. found_posts and max_num_pages become useless, but the query is cheaper. Ideal for widgets and blocks that never paginate.ignore_sticky_posts (bool): default false, 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 some post__in queries). Set true to 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 (string or array): DESC (default) or ASC. Ignored per-key when orderby is an associative array (each key carries its own direction there).orderby (string or array): what to sort by. Default date. 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 when s is 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 in meta_key. The comparison is alphabetical, so numbers sort as strings (1, 10, 2) unless you set meta_type or use meta_value_num. With meta_type set (for example DATETIME), the matching alias such as meta_value_datetime also works.meta_value_num: numeric sort on the meta_key value.post__in: keep the exact ID order you passed in post__in. order has no effect.post_name__in: keep the slug order passed in post_name__in. order has no effect.post_parent__in: keep the parent-ID order passed in post_parent__in. order has 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',
),
) );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 the start_of_week option).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 (also dayofweek, dayofweek_iso, dayofyear).after (string or array): only posts after this date. Takes a strtotime()-compatible string or an array with year, month, day keys.before (string or array): only posts before this date, same formats.inclusive (bool): whether after/before boundaries match exactly. Default false.compare (string): comparison operator for the fixed components, e.g. =, >, <=, BETWEEN, IN.column (string): which date column to test. Default post_date; also post_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.
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 in orderby.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 for IN, NOT IN, BETWEEN, NOT BETWEEN. Omit it for EXISTS and NOT 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. DATE works with BETWEEN only when values are stored and compared as YYYY-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',
),
),
) );perm (string): intersect post_status with the current user's capabilities. 'readable' keeps only statuses the user can actually read (so private posts show only to users with read_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',
) );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 with post_type => 'attachment', which also needs post_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.
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 matches update_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,
) );fields (string): shape of the returned results. 'all' (default) returns full WP_Post objects. 'ids' returns a flat array of post IDs. 'id=>parent' returns objects containing only ID and post_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
}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.
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' ),
),
),
) );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',
),
),
),
) );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.
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.
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.
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.
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.
Calling $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.
A secondary 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.
They do different things. 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() ).
It removes the limit, so the query returns every matching row and WordPress instantiates a 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.
By default the query calculates the total number of matching rows so that pagination can be rendered, which on large tables is often slower than fetching the posts themselves. When a loop does not paginate, set '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.
Each meta clause adds a join against 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.
get_postspre_get_postsWP_Term_QueryWP_User_QueryWP_Comment_QueryEvery hook that fires from inside WP_Query, in the order it appears in the class, grouped by the method that fires it.
$queryarraypublic$query_varsarraypublic$tax_queryWP_Tax_Query|nullpublic$meta_queryWP_Meta_Querypublic$date_queryWP_Date_Querypublic$queried_objectWP_Term|WP_Post_Type|WP_Post|WP_User|nullpublic$queried_object_idintpublic$requeststringpublic$postsWP_Post[]|int[]public$post_countintpublic$current_postintpublic$before_loopboolpublic$in_the_loopboolpublic$postWP_Post|nullpublic$commentsWP_Comment[]public$comment_countintpublic$current_commentintpublic$commentWP_Commentpublic$found_postsintpublic$max_num_pagesintpublic$max_num_comment_pagesintpublic$is_singleboolpublic$is_previewboolpublic$is_pageboolpublic$is_archiveboolpublic$is_dateboolpublic$is_yearboolpublic$is_monthboolpublic$is_dayboolpublic$is_timeboolpublic$is_authorboolpublic$is_categoryboolpublic$is_tagboolpublic$is_taxboolpublic$is_searchboolpublic$is_feedboolpublic$is_comment_feedboolpublic$is_trackbackboolpublic$is_homeboolpublic$is_privacy_policyboolpublic$is_404boolpublic$is_embedboolpublic$is_pagedboolpublic$is_adminboolpublic$is_attachmentboolpublic$is_singularboolpublic$is_robotsboolpublic$is_faviconboolpublic$is_posts_pageboolpublic$is_post_type_archiveboolpublic$query_vars_hashbool|stringprivate$query_vars_changedprivate$thumbnails_cachedboolpublic$allow_query_attachment_by_filenameboolprotected$stopwordsarrayprivate$compat_fieldsprivate$compat_methodsprivate#[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. *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 sourcesrc/wp-includes/class-wp-query.php, and regenerated for each WordPress release so it tracks the code rather than a snapshot of it.