Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 27 of 45

Custom queries with WP_Query

Get exactly the posts you want with WP_Query and get_posts(): filter by taxonomy, meta and date, and change the main query with pre_get_posts.

15 minClassic templates and queriesUpdated 23 October 2026

By the end of this lessonRun your own queries for lists such as related or featured posts, and change the main query without breaking pagination.

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
<?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();
result
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:

bash
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"
result
Success: Created category 2.
Success: Created category 3.
Success: Created post 4.
Success: Created post 5.
Success: Created post 6.

Now different selections:

php
<?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";
result
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 day

Two 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
<?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";
result
Rye bread, Sourdough basics

Each 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
<?php
foreach ( get_posts( [ 'numberposts' => 2, 'category_name' => 'recipes', 'orderby' => 'date', 'order' => 'ASC' ] ) as $post ) {
	echo $post->post_title, " (", $post->post_date, ")\n";
}
result
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
<?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' );
	}
} );
bash
wp eval 'echo "mu-plugin loaded\n";'
result
mu-plugin loaded

Always 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:

http
GET /category/recipes/
result
Rye bread
http
GET /category/recipes/page/2/
result
Sourdough basics

Common mistakes

  • query_posts(), which replaces the main query and breaks pagination.
  • No wp_reset_postdata() after your loop.
  • posts_per_page => -1 on 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
php
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().

Check yourself

1. What must you call after a loop that uses your own WP_Query?
2. Which hook should you use to change the number of posts on the blog page?
3. What does 'posts_per_page' => -1 mean?
4. Which argument fetches only posts of the 'event' type?