
If you’ve ever tried to change what posts appear on a category archive or search results page, you’ve probably run into query_posts(). And if you’ve read anything about it, you’ve probably been told not to use it. That advice is correct — but the reason matters. query_posts() replaces the main query mid-execution, which breaks pagination and throws off anything else that depends on query state. The right tool is pre_get_posts.
pre_get_posts is an action hook that fires before WordPress runs its main database query. You get direct access to the WP_Query object and can change its parameters right there — no secondary query, no pagination headaches.
You hook into pre_get_posts from your theme’s functions.php or a plugin file. The callback receives the WP_Query object by reference, so changes you make stick without needing to return anything.
add_action( 'pre_get_posts', function( WP_Query $query ) {
// Always check these two conditions first
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Your modifications go here
if ( $query->is_home() ) {
$query->set( 'posts_per_page', 6 );
}
} );PHPThose two guards at the top are not optional. Without is_admin(), your changes will affect admin screens like the post list table. Without is_main_query(), they’ll also affect any secondary WP_Query instances running on the same page — like a recent posts widget. Both checks together mean your code only runs on the front-end main query.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// 12 posts on category archives
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 12 );
return;
}
// 24 posts on the portfolio archive
if ( $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'posts_per_page', 24 );
return;
}
// 5 results on search pages
if ( $query->is_search() ) {
$query->set( 'posts_per_page', 5 );
return;
}
} );PHPEach early return after a match keeps the conditions clean and avoids accidentally applying multiple rules to a single request.
A common need on blog homepages is hiding certain categories — news, announcements, or anything you don’t want in the main feed.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Exclude category ID 7 from the blog homepage
if ( $query->is_home() ) {
$query->set( 'category__not_in', [ 7 ] );
}
} );PHPYou can also exclude specific posts by ID:
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'post__not_in', [ 42, 57, 103 ] );
}
} );PHPBy default, WordPress search only covers the post post type. If you register a custom post type and want it to show up in search results, pre_get_posts is where you add it.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_search() ) {
$query->set( 'post_type', [ 'post', 'portfolio', 'product' ] );
}
} );PHPPass an array to include multiple post types. Note that including product here means WooCommerce products appear in your theme’s native search results — which you may or may not want depending on your setup.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// On the portfolio archive, only show published, client-approved work
if ( $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'tax_query', [
[
'taxonomy' => 'portfolio_status',
'field' => 'slug',
'terms' => 'approved',
],
] );
}
} );PHPSay you want a custom post type archive sorted alphabetically instead of by date:
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive( 'speaker' ) ) {
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
} );PHPYou can set any orderby value that WP_Query accepts — date, title, menu_order, rand, meta_value_num, and so on. For a full list see the WP_Query complete guide.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Only show posts (not pages or CPTs) on author archives
if ( $query->is_author() ) {
$query->set( 'post_type', 'post' );
}
} );PHPIf you’re building a plugin rather than editing functions.php, put this inside a class. The pattern is the same — the hook registration just moves into your constructor. See the WordPress actions and filters guide for the full class-based hook pattern.
class My_Plugin_Query_Modifier {
public function __construct() {
add_action( 'pre_get_posts', [ $this, 'modify_queries' ] );
}
public function modify_queries( WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive( 'product' ) ) {
$query->set( 'posts_per_page', 24 );
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
}
}
new My_Plugin_Query_Modifier();PHPThere are a few things worth knowing upfront so you don’t spend time debugging something that won’t work here:
WP_Query, pre_get_posts fires for that too — but your is_main_query() check correctly filters those out. If you need to modify a specific secondary query, you’d need a different approach (like passing arguments directly to that query).pre_get_posts to make it behave like a tag archive. The conditional tags like is_category() are already set by the time this hook fires.template_redirect for that.This comes up a lot. The short answer: if you’re changing what appears in the main loop on an archive or taxonomy page, use pre_get_posts. If you need a completely separate list of posts somewhere on the page — like a sidebar or a “related posts” section — use a secondary WP_Query or get_posts.
The reason pre_get_posts is preferred for the main loop is performance. Creating a secondary WP_Query in a template when you only needed to adjust the main query runs two database queries where one would have done the job.
If your changes aren’t taking effect, run through this checklist:
is_admin() and is_main_query() guards are in place and aren’t filtering out the request you’re targeting.is_home() is the blog index, not the front page — if you have a static front page set, use is_front_page() for that.var_dump( $query->query_vars ) just before your return to confirm the modified values are set.query_posts() replaces the main query after it’s already been set up, which breaks pagination and corrupts the global query state. pre_get_posts modifies the query object before the database call is made, so pagination and conditional tags continue to work correctly. Never use query_posts() in production code.
It fires on admin pages too, which is why the is_admin() check is essential. Without it, your changes could affect the posts list in the admin panel and cause confusing behaviour for editors.
Yes. Use $query->set( 'meta_query', [ ... ] ) exactly as you would when constructing a WP_Query directly. The same argument structure applies. For meta query syntax and examples, see the WP_Query complete guide.
No — that’s actually the main reason to use it instead of query_posts(). Because it modifies the query before execution, WordPress calculates the correct total number of pages based on your modified arguments. Pagination works as expected.
Yes. You can register multiple callbacks on the same hook with different priorities. Each one runs in order. Keep them focused — one callback per concern is easier to debug than one large callback that handles every case.

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