The Complete WP_Query Guide: Every Argument, Real Examples & Performance Tips

What Is WP_Query?

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();
}
PHP

You 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.

WP_Query vs get_posts() vs query_posts()

Before diving into arguments, it helps to know when to use WP_Query over its alternatives:

FunctionReturnsUse whenAvoid when
WP_QueryObject with full loop methodsCustom loops in templates or pluginsYou just need a flat array of posts
get_posts()Array of WP_Post objectsSimple lists, widget data, REST responsesYou need pagination or loop methods
query_posts()Modifies the main query globallyNever — use pre_get_posts insteadAlways

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.

Post Type & Status Arguments

$query = new WP_Query( [
    'post_type'   => 'product',          // string or array: [ 'post', 'page', 'product' ]
    'post_status' => 'publish',          // 'publish' | 'draft' | 'private' | 'any' | array
] );
PHP

Common 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.

Pagination Arguments

$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
] );
PHP

Performance 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.

Taxonomy Arguments

Simple single-taxonomy query:

$query = new WP_Query( [
    'post_type'      => 'post',
    'posts_per_page' => 10,
    'category_name'  => 'tutorials',   // slug, not ID
] );
PHP

For 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',
        ],
    ],
] );
PHP

The 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 Query Arguments

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',
] );
PHP

Available 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).

Orderby Arguments

// 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
    ],
] );
PHP

Common 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).

Date Arguments

// 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

Search Arguments

$query = new WP_Query( [
    'post_type' => 'post',
    's'         => sanitize_text_field( $search_term ),   // always sanitize user input
] );
PHP

WordPress’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.

Author & User Arguments

$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
] );
PHP

Performance Best Practices

WP_Query is fast when used correctly and slow when abused. Here are the habits that matter most:

  • Always use no_found_rows => true in non-paginated queries. This eliminates the COUNT(*) SQL call.
  • Limit posts_per_page. Never use -1 on a post type with thousands of entries. Paginate instead.
  • Avoid 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.
  • Use fields => 'ids' when you only need post IDs — this avoids loading full WP_Post objects and their metadata.
  • Cache results with wp_cache_set() / wp_cache_get() for queries that run on every page load.
  • Prefer 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
] );
PHP

Modifying the Main Query with pre_get_posts

For 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 ] );
    }
} );
PHP

This is more efficient than creating a secondary WP_Query on archive templates because WordPress only runs one database query instead of two.

Full Reference: All WP_Query Arguments

Here’s a condensed reference of every argument group:

GroupKey arguments
Post type / statuspost_type, post_status, post__in, post__not_in, post_name__in
Paginationposts_per_page, paged, offset, no_found_rows, nopaging
Orderorderby, order, meta_key (for meta_value orderby)
Authorauthor, author__in, author__not_in, author_name
Category / tagcat, category_name, tag, tag_id, tag__in
Taxonomytax_query (nested array)
Meta / custom fieldsmeta_query, meta_key, meta_value, meta_compare, meta_type
Datedate_query, year, month, day, before, after
Searchs, exact, sentence
Permissionperm (readable | editable)
Return formatfields (all | ids | id=>parent)
Cacheupdate_post_term_cache, update_post_meta_cache, cache_results

Frequently Asked Questions

What is the difference between WP_Query and get_posts?

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.

Why should I avoid query_posts()?

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.

Do I always need wp_reset_postdata()?

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.

How do I debug a WP_Query that returns no results?

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.

About Me

Gemini_Generated_Image_6ed8rn6ed8rn6ed8

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

Learn More