WP_Query in depth

Build custom queries, order and paginate results, and switch off work you do not need with performance flags.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Write custom queries
  • Paginate results efficiently
  • Order results by date, title, meta value or a fixed list
  • Choose between WP_Query and get_posts

Before you start

The main query fetches what the URL asks for. Everything else, such as "related events", "latest three news posts" or "the next workshops on the calendar", needs a query of your own. WP_Query is the class that builds these queries. It turns an array of arguments into safe SQL, caches the results and returns post objects ready for the Loop.

The shape of a custom query

Every custom query follows the same pattern: build arguments, create the query, loop, reset.

PHP
<?php
// wp-content/plugins/myplugin/includes/template-functions.php
function myplugin_latest_events_html( int $count = 3 ): string {
    $query = new WP_Query(
        array(
            'post_type'      => 'myplugin_event',
            'post_status'    => 'publish',
            'posts_per_page' => $count,
            'no_found_rows'  => true,
        )
    );

    if ( ! $query->have_posts() ) {
        return '';
    }

    $html = '<ul class="myplugin-latest">';

    while ( $query->have_posts() ) {
        $query->the_post();
        $html .= sprintf(
            '<li><a href="%s">%s</a></li>',
            esc_url( get_permalink() ),
            esc_html( get_the_title() )
        );
    }

    $html .= '</ul>';

    wp_reset_postdata();

    return $html;
}

$query->the_post() sets the global post so template tags work. wp_reset_postdata() restores the main query's post afterwards. Forgetting it is a classic bug: everything after your list shows the wrong title.

The arguments you will use most

What to fetch

ArgumentExampleMeaning
post_type'myplugin_event' or array( 'post', 'page' )Which post types. Default post
post_status'publish'Which statuses. Default is publish for visitors
posts_per_page10How many per page. -1 means all
p, name42, 'autumn-meetup'One post by ID or slug
post__inarray( 12, 7, 31 )Only these IDs
post__not_inarray( get_the_ID() )Exclude these IDs
post_parent0Children of a post; 0 means top level
author5Posts by a user ID
s'workshop'A keyword search
ignore_sticky_poststrueDo not move sticky posts to the top

Ordering

ArgumentExampleMeaning
orderby'date', 'title', 'modified', 'menu_order', 'rand'The field to sort by
order'DESC' or 'ASC'Direction. Default DESC
orderby with meta_key'meta_value' with 'meta_key' => 'myplugin_event_date'Sort by a meta value as text. Use 'meta_value_num' for numbers such as myplugin_price
orderby'post__in'Keep the order of the post__in array

You can also sort by several fields with an array:

PHP
<?php
$myplugin_upcoming = new WP_Query(
    array(
        'post_type'      => 'myplugin_event',
        'posts_per_page' => 10,
        'meta_key'       => 'myplugin_event_date',
        'orderby'        => array(
            'meta_value' => 'ASC',
            'title'      => 'ASC',
        ),
        'no_found_rows'  => true,
    )
);

This lists events in date order, soonest first, and sorts events on the same day by title. Because myplugin_event_date is stored as Y-m-d, sorting the text also sorts the dates correctly. Setting meta_key also limits the results to events that have a date. This query still includes past events: filtering to upcoming events only needs a meta_query comparison with today's date, which the meta and tax queries lesson covers.

Reading the results

A WP_Query object holds more than posts:

PropertyHolds
$query->postsThe array of WP_Post objects for this page
$query->post_countHow many posts were returned on this page
$query->found_postsHow many posts match in total, across all pages
$query->max_num_pagesHow many pages there are

You do not have to use the Loop. When you only need data, iterate the posts directly and pass each one to functions that accept a post:

PHP
<?php
foreach ( $myplugin_upcoming->posts as $myplugin_event ) {
    echo wp_kses_post( myplugin_render_event_card( $myplugin_event ) );
}

myplugin_render_event_card() is the function from the custom hooks lesson. It escapes everything it builds, but its output passes through a filter that other code can change, so wp_kses_post() keeps the final HTML safe at the point of output. This style does not touch the global post, so no reset is needed.

Pagination

To paginate a custom query, read the current page number and pass it as paged:

PHP
<?php
// wp-content/themes/mytheme/page-all-events.php
get_header();

$myplugin_paged   = max( 1, (int) get_query_var( 'paged' ) );
$myplugin_events = new WP_Query(
    array(
        'post_type'      => 'myplugin_event',
        'posts_per_page' => 12,
        'paged'          => $myplugin_paged,
    )
);

if ( $myplugin_events->have_posts() ) :
    while ( $myplugin_events->have_posts() ) :
        $myplugin_events->the_post();
        get_template_part( 'parts/event', 'card' );
    endwhile;

    echo wp_kses_post(
        paginate_links(
            array(
                'total'   => $myplugin_events->max_num_pages,
                'current' => $myplugin_paged,
            )
        )
    );

    wp_reset_postdata();
endif;

get_footer();

Two details:

  • On a page set as the static front page, WordPress uses the page query variable instead of paged. Use get_query_var( 'page' ) there.
  • Pagination needs the total count, so do not set no_found_rows on a paginated query.

If you are paginating an archive of a post type, you usually do not need a custom query at all. Enable has_archive and change the main query with pre_get_posts, covered in a later lesson. Pagination, conditional tags and templates then work automatically.

Performance flags

By default WP_Query does extra work in case you need it. When you do not, switch it off:

ArgumentDefaultWhat it skips when changed
no_found_rows => truefalseCounting all matching rows for pagination. Use it whenever you do not paginate
update_post_meta_cache => falsetrueLoading all meta for the returned posts
update_post_term_cache => falsetrueLoading all terms for the returned posts
fields => 'ids'All fieldsBuilds WP_Post objects; returns only an array of IDs

Use the cache flags carefully. Turning off the meta cache is only a win if you never call get_post_meta() on those posts. Otherwise each call becomes a separate database query, which is far worse.

fields => 'ids' is ideal when you only need IDs, for example to pass to another function:

PHP
<?php
$myplugin_ids = new WP_Query(
    array(
        'post_type'              => 'myplugin_event',
        'posts_per_page'         => 100,
        'fields'                 => 'ids',
        'no_found_rows'          => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
    )
);

$myplugin_event_ids = $myplugin_ids->posts; // An array of integers.

Habits that keep queries fast

  • Always set a limit. posts_per_page => -1 loads every matching post into memory. It is fine for ten pages, and a time bomb for ten thousand events.
  • Avoid orderby => 'rand'. The database must sort the whole table randomly. Fetch a small set of recent IDs and pick from them in PHP instead.
  • Keep post__not_in small. Long exclusion lists make queries slow. Fetch one extra post and skip the current one in PHP instead.
  • Cache expensive results. A query that runs on every page view and changes rarely can be stored in a transient, covered in the options and transients lesson.

WP_Query or get_posts?

get_posts() is a wrapper that creates a WP_Query and returns the array of posts:

PHP
<?php
$myplugin_recent = get_posts(
    array(
        'post_type'   => 'myplugin_event',
        'numberposts' => 5,
    )
);

Its defaults differ from WP_Query: it sets no_found_rows and ignore_sticky_posts to true and suppress_filters to true, so filters such as posts_where do not run. That makes it a good choice for simple lists of data. Use WP_Query when you need pagination, the Loop, or the query object's properties.

Never use a custom query to replace what the main query already does on a page. Running a second query for the same posts doubles the database work and breaks pagination. Change the main query with pre_get_posts instead.

Summary

  • WP_Query turns an array of arguments into a safe, cached database query.
  • Loop with $query->have_posts() and $query->the_post(), then call wp_reset_postdata().
  • Order with orderby and order, including meta values and post__in.
  • Paginate with paged and max_num_pages; use no_found_rows => true when you do not paginate.
  • Use fields => 'ids' and the cache flags when you need less data, and always set a limit.
  • get_posts() suits simple lists; WP_Query suits pagination and loops.

In the next lesson you will filter queries by custom fields and terms with meta and tax queries.

Further reading