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
// 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
| Argument | Example | Meaning |
|---|---|---|
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_page | 10 | How many per page. -1 means all |
p, name | 42, 'autumn-meetup' | One post by ID or slug |
post__in | array( 12, 7, 31 ) | Only these IDs |
post__not_in | array( get_the_ID() ) | Exclude these IDs |
post_parent | 0 | Children of a post; 0 means top level |
author | 5 | Posts by a user ID |
s | 'workshop' | A keyword search |
ignore_sticky_posts | true | Do not move sticky posts to the top |
Ordering
| Argument | Example | Meaning |
|---|---|---|
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
$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:
| Property | Holds |
|---|---|
$query->posts | The array of WP_Post objects for this page |
$query->post_count | How many posts were returned on this page |
$query->found_posts | How many posts match in total, across all pages |
$query->max_num_pages | How 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
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
// 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
pagequery variable instead ofpaged. Useget_query_var( 'page' )there. - Pagination needs the total count, so do not set
no_found_rowson 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:
| Argument | Default | What it skips when changed |
|---|---|---|
no_found_rows => true | false | Counting all matching rows for pagination. Use it whenever you do not paginate |
update_post_meta_cache => false | true | Loading all meta for the returned posts |
update_post_term_cache => false | true | Loading all terms for the returned posts |
fields => 'ids' | All fields | Builds 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
$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 => -1loads 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_insmall. 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
$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_Queryturns an array of arguments into a safe, cached database query.- Loop with
$query->have_posts()and$query->the_post(), then callwp_reset_postdata(). - Order with
orderbyandorder, including meta values andpost__in. - Paginate with
pagedandmax_num_pages; useno_found_rows => truewhen 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_Querysuits pagination and loops.
In the next lesson you will filter queries by custom fields and terms with meta and tax queries.