Why your own query?
The main query gives a template the posts for its address. Sometimes a page needs a second list: the latest three posts in a sidebar, related posts under an article, upcoming events. For that you make your own query with WP_Query.
A first query
<?php
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 3,
'orderby' => 'date',
'order' => 'DESC',
] );
echo "found: ", $query->found_posts, "\n";
while ( $query->have_posts() ) {
$query->the_post();
echo get_the_title(), "\n";
}
wp_reset_postdata();found: 1
Hello world!Compare with the main Loop: you call have_posts() and the_post() on your object, and you finish with wp_reset_postdata(), which puts the global post back to what the main query had set. Skipping the reset is a classic source of strange bugs later in the template.
Common arguments
Make a few more posts first, with categories and a tag:
wp term create category News
wp term create category Recipes
wp post create --post_title="Opening day" --post_status=publish --post_category=News --post_date="2026-02-01 09:00:00"
wp post create --post_title="Sourdough basics" --post_status=publish --post_category=Recipes --tags_input=bread --post_date="2026-02-10 09:00:00"
wp post create --post_title="Rye bread" --post_status=publish --post_category=Recipes --tags_input=bread --post_date="2026-02-20 09:00:00"Success: Created category 2.
Success: Created category 3.
Success: Created post 4.
Success: Created post 5.
Success: Created post 6.Now different selections:
<?php
function titles( array $args ): string {
$query = new WP_Query( $args + [ 'fields' => 'ids', 'no_found_rows' => true ] );
return implode( ', ', array_map( 'get_the_title', $query->posts ) );
}
echo "recipes: ", titles( [ 'category_name' => 'recipes', 'orderby' => 'date', 'order' => 'ASC' ] ), "\n";
echo "tag bread: ", titles( [ 'tag' => 'bread', 'orderby' => 'title', 'order' => 'ASC' ] ), "\n";
echo "search 'rye': ", titles( [ 's' => 'rye' ] ), "\n";
echo "one, by date: ", titles( [ 'posts_per_page' => 1 ] ), "\n";
echo "after 5 Feb: ", titles( [ 'date_query' => [ [ 'after' => '2026-02-05' ] ], 'orderby' => 'date', 'order' => 'ASC' ] ), "\n";
echo "not in recipes: ", titles( [ 'category__not_in' => [ get_cat_ID( 'Recipes' ) ], 'orderby' => 'date', 'order' => 'ASC' ] ), "\n";recipes: Sourdough basics, Rye bread
tag bread: Rye bread, Sourdough basics
search 'rye': Rye bread
one, by date: Rye bread
after 5 Feb: Sourdough basics, Rye bread
not in recipes: Hello world!, Opening dayTwo arguments in the helper are worth knowing. 'fields' => 'ids' returns only IDs, which is faster when you need no more. 'no_found_rows' => true skips counting all matches, saving a query when you do not paginate.
| Argument | Selects |
|---|---|
post_type |
the content type, or an array of types |
posts_per_page, paged, offset |
how many, which page, how many to skip |
category_name, cat, tag |
by category slug or ID, or by tag slug |
author, s, p, name |
by author ID, search words, a post ID or a slug |
orderby, order |
date, title, modified, rand, meta_value, menu_order... with ASC or DESC |
post__in, post__not_in |
specific IDs, or exclude IDs |
Taxonomy and meta queries
For anything beyond the simple arguments, use tax_query (conditions on categories, tags or custom taxonomies) and meta_query (conditions on custom fields, see Custom fields and post meta).
<?php
$query = new WP_Query( [
'fields' => 'ids',
'no_found_rows' => true,
'tax_query' => [
'relation' => 'AND',
[ 'taxonomy' => 'category', 'field' => 'slug', 'terms' => 'recipes' ],
[ 'taxonomy' => 'post_tag', 'field' => 'slug', 'terms' => 'bread' ],
],
'orderby' => 'title',
'order' => 'ASC',
] );
echo implode( ', ', array_map( 'get_the_title', $query->posts ) ), "\n";Rye bread, Sourdough basicsEach condition is an array; relation says whether all (AND) or any (OR) must match.
get_posts() for simple lists
When you only need an array of posts and no pagination, get_posts() is shorter. It takes the same arguments and does not touch the main Loop, so no reset is needed:
<?php
foreach ( get_posts( [ 'numberposts' => 2, 'category_name' => 'recipes', 'orderby' => 'date', 'order' => 'ASC' ] ) as $post ) {
echo $post->post_title, " (", $post->post_date, ")\n";
}Sourdough basics (2026-02-10 09:00:00)
Rye bread (2026-02-20 09:00:00)Changing the main query: pre_get_posts
To change what a normal page shows (for example, 12 recipes per page on the category archive), do not run a second query. Change the main one before it runs, with the pre_get_posts hook:
<?php
add_action( 'pre_get_posts', function ( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_category( 'recipes' ) ) {
$query->set( 'posts_per_page', 1 );
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
} );wp eval 'echo "mu-plugin loaded\n";'mu-plugin loadedAlways start with the two guards: is_admin() (do not change dashboard lists) and is_main_query() (do not change every query on the site). The category archive now shows one recipe per page, sorted by title, and pagination still works because it is the same query:
GET /category/recipes/Rye breadGET /category/recipes/page/2/Sourdough basicsCommon mistakes
query_posts(), which replaces the main query and breaks pagination.- No
wp_reset_postdata()after your loop. posts_per_page => -1on a big site: you load every post into memory.- Querying inside a loop for every post (N+1 queries). Fetch what you need once.
- Skipping the guards in
pre_get_posts, which changes admin screens and other queries.
Exercise
Write a function that returns the titles of the three most recently modified posts in the Recipes category, newest first.
Show one possible answer
function recent_recipe_titles(): array {
$posts = get_posts( [
'numberposts' => 3,
'category_name' => 'recipes',
'orderby' => 'modified',
'order' => 'DESC',
] );
return wp_list_pluck( $posts, 'post_title' );
}wp_list_pluck() pulls one field out of each object in an array.
Key points
- new WP_Query( $args ) runs a query and gives you its own Loop; use wp_reset_postdata() afterwards.
- Arguments such as post_type, posts_per_page, category_name, orderby, tax_query and meta_query select and sort the posts.
- get_posts() returns an array of posts for simple cases, without changing the main Loop.
- To change what a normal page shows, use the pre_get_posts hook on the main query, not query_posts().