
WP_Query is WordPress’s built-in class for querying the database and retrieving posts. Every page you visit on a WordPress site runs a query behind the scenes — WP_Query is what powers it. The difference is that you can instantiate your own version anywhere: in templates, shortcodes, widgets, REST API callbacks, and plugin code.
At its simplest, it looks like this:
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 10,
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
// your template code here
}
wp_reset_postdata();
}PHPYou pass an array of arguments, call have_posts() to loop, and always end with wp_reset_postdata() to restore the global $post object. That last step is easy to forget and causes subtle bugs — keep it habitual.
Before diving into arguments, it helps to know when to use WP_Query over its alternatives:
| Function | Returns | Use when | Avoid when |
|---|---|---|---|
WP_Query | Object with full loop methods | Custom loops in templates or plugins | You just need a flat array of posts |
get_posts() | Array of WP_Post objects | Simple lists, widget data, REST responses | You need pagination or loop methods |
query_posts() | Modifies the main query globally | Never — use pre_get_posts instead | Always |
get_posts() actually wraps WP_Query internally. The practical difference: get_posts() sets suppress_filters => true by default, which means plugins that hook into query filters (like WPML or SEO plugins) are bypassed. Use WP_Query when filters matter; use get_posts() when you want raw speed and a simple array back.
$query = new WP_Query( [
'post_type' => 'product', // string or array: [ 'post', 'page', 'product' ]
'post_status' => 'publish', // 'publish' | 'draft' | 'private' | 'any' | array
] );PHPCommon gotcha: querying 'post_status' => 'any' includes trashed posts. If you’re building an admin screen that should exclude trash, use [ 'publish', 'draft', 'pending', 'private' ] explicitly.
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 12, // -1 to get all (avoid on large datasets)
'paged' => get_query_var( 'paged' ) ?: 1,
'offset' => 0, // skips N posts — mutually exclusive with 'paged'
'no_found_rows' => true, // skips COUNT() query — use when you don't need pagination
] );PHPPerformance note: no_found_rows => true is one of the most impactful flags you can set. By default WP_Query runs an extra SQL_CALC_FOUND_ROWS query to count total results for pagination. If you’re rendering a widget or sidebar list where you don’t need page numbers, this flag eliminates that query entirely.
Simple single-taxonomy query:
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 10,
'category_name' => 'tutorials', // slug, not ID
] );PHPFor custom taxonomies or complex conditions, use tax_query:
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'shirts', 'hoodies' ],
'operator' => 'IN', // IN | NOT IN | AND | EXISTS | NOT EXISTS
],
[
'taxonomy' => 'product_tag',
'field' => 'slug',
'terms' => 'sale',
'operator' => 'IN',
],
],
] );PHPThe field argument accepts 'slug', 'name', or 'term_id'. Use 'term_id' when you have the ID — it skips an extra lookup and is slightly faster. Use 'slug' in hardcoded queries so the query still works if term IDs change across environments.
Meta queries let you filter by custom field values. They’re powerful but expensive — always index the meta key if you’re querying it frequently.
$query = new WP_Query( [
'post_type' => 'event',
'posts_per_page' => 10,
'meta_query' => [
'relation' => 'AND',
'start_date_clause' => [ // named clause — lets you use it in orderby
'key' => 'event_start_date',
'value' => date( 'Ymd' ),
'compare' => '>=',
'type' => 'DATE',
],
[
'key' => 'event_status',
'value' => 'cancelled',
'compare' => '!=',
],
],
'orderby' => 'start_date_clause', // order by the named clause
'order' => 'ASC',
] );PHPAvailable compare operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP.
Available type values: NUMERIC, BINARY, CHAR, DATE, DATETIME, DECIMAL, SIGNED, TIME, UNSIGNED. Always set the correct type — without it, numeric comparisons like >= fall back to string comparison and produce wrong results (e.g. “9” > “10” as strings).
// Single orderby
$query = new WP_Query( [
'post_type' => 'post',
'orderby' => 'date',
'order' => 'DESC', // ASC | DESC
] );
// Multiple orderby (WordPress 4.0+)
$query = new WP_Query( [
'post_type' => 'product',
'meta_key' => 'price', // required when orderby is 'meta_value_num'
'orderby' => [
'meta_value_num' => 'ASC', // price low to high
'title' => 'ASC', // then alphabetically
],
] );PHPCommon orderby values: date, modified, title, name (slug), ID, rand, menu_order, meta_value, meta_value_num, comment_count, relevance (search only), post__in (preserves order of a hardcoded ID array).
// Posts from a specific month
$query = new WP_Query( [
'post_type' => 'post',
'date_query' => [
[
'year' => 2026,
'month' => 1,
],
],
] );
// Posts published in the last 30 days
$query = new WP_Query( [
'post_type' => 'post',
'date_query' => [
[
'after' => '30 days ago',
'inclusive' => true,
],
],
] );PHP$query = new WP_Query( [
'post_type' => 'post',
's' => sanitize_text_field( $search_term ), // always sanitize user input
] );PHPWordPress’s built-in search only checks post title and content. If you need to search custom fields or taxonomy terms, look at extending the query via filters or use a dedicated search plugin like SearchWP.
$query = new WP_Query( [
'post_type' => 'post',
'author' => 5, // single user ID
'author__in' => [ 3, 5, 12 ], // multiple user IDs
'author__not_in' => [ 1 ], // exclude admin
'author_name' => 'kamal', // by user_login slug
] );PHPWP_Query is fast when used correctly and slow when abused. Here are the habits that matter most:
no_found_rows => true in non-paginated queries. This eliminates the COUNT(*) SQL call.posts_per_page. Never use -1 on a post type with thousands of entries. Paginate instead.meta_query on unindexed keys. Add a database index to wp_postmeta.meta_value for keys you filter on frequently, or store data in a dedicated table instead.fields => 'ids' when you only need post IDs — this avoids loading full WP_Post objects and their metadata.wp_cache_set() / wp_cache_get() for queries that run on every page load.tax_query over meta_query for categorisation. Taxonomy tables are indexed and purpose-built for filtering; meta tables are not.// Lightweight ID-only query
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 100,
'fields' => 'ids', // returns array of integers only
'no_found_rows' => true,
'update_post_term_cache' => false, // skip loading term cache
'update_post_meta_cache' => false, // skip loading meta cache
] );PHPFor archive pages, category pages, and search results, you should modify the main query rather than creating a secondary one. Do this in functions.php or a plugin using the pre_get_posts action:
add_action( 'pre_get_posts', function( WP_Query $query ) {
// Only modify the main query on the front end, not in the admin
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Show 24 products per page on shop/category archives
if ( $query->is_post_type_archive( 'product' ) || $query->is_tax( 'product_cat' ) ) {
$query->set( 'posts_per_page', 24 );
}
// Exclude a specific category from the blog index
if ( $query->is_home() ) {
$query->set( 'category__not_in', [ 5 ] );
}
} );PHPThis is more efficient than creating a secondary WP_Query on archive templates because WordPress only runs one database query instead of two.
Here’s a condensed reference of every argument group:
| Group | Key arguments |
|---|---|
| Post type / status | post_type, post_status, post__in, post__not_in, post_name__in |
| Pagination | posts_per_page, paged, offset, no_found_rows, nopaging |
| Order | orderby, order, meta_key (for meta_value orderby) |
| Author | author, author__in, author__not_in, author_name |
| Category / tag | cat, category_name, tag, tag_id, tag__in |
| Taxonomy | tax_query (nested array) |
| Meta / custom fields | meta_query, meta_key, meta_value, meta_compare, meta_type |
| Date | date_query, year, month, day, before, after |
| Search | s, exact, sentence |
| Permission | perm (readable | editable) |
| Return format | fields (all | ids | id=>parent) |
| Cache | update_post_term_cache, update_post_meta_cache, cache_results |
get_posts() is a wrapper around WP_Query that returns a flat array of post objects. It disables filters by default (suppress_filters => true) and is slightly simpler for basic use. WP_Query gives you the full loop API, pagination data, and respects all query filters. Use get_posts() for simple lists; use WP_Query when you need pagination, the loop, or filter compatibility.
query_posts() replaces the main query entirely and breaks pagination, conditional tags, and plugin integrations. Use pre_get_posts to modify the main query, and WP_Query for secondary queries.
Yes, whenever you call $query->the_post() inside your loop. This function sets the global $post variable to the current post. Without wp_reset_postdata() after your loop, the global $post stays as the last post from your custom query, which breaks any subsequent template code that relies on it.
Add var_dump( $query->request ) after instantiating the query to see the raw SQL. You can also check $query->found_posts and $query->post_count. The Query Monitor plugin is invaluable here — it shows every query run on the page with its arguments and execution time.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More