How to Use pre_get_posts to Modify WordPress Queries (Without query_posts)

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.
Basic Usage
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.
Changing Posts Per Page on Specific Pages
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.
Excluding Posts or Categories
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 ] );
}
} );PHPIncluding Custom Post Types in Search
By 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.
Filtering by Custom Taxonomy on Archives
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',
],
] );
}
} );PHPChanging the Sort Order
Say 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.
Modifying Author Archives
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' );
}
} );PHPUsing pre_get_posts in a Plugin (Class-Based)
If 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();PHPWhat pre_get_posts Can’t Do
There are a few things worth knowing upfront so you don’t spend time debugging something that won’t work here:
- It doesn’t affect secondary queries. If a widget or shortcode runs its own
WP_Query,pre_get_postsfires for that too — but youris_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). - It can’t change the queried object. If WordPress has already determined it’s on a category archive, you can’t use
pre_get_poststo make it behave like a tag archive. The conditional tags likeis_category()are already set by the time this hook fires. - It’s not the right place for redirects. Use
template_redirectfor that.
pre_get_posts vs a Secondary WP_Query
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.
Debugging pre_get_posts
If your changes aren’t taking effect, run through this checklist:
- Check that
is_admin()andis_main_query()guards are in place and aren’t filtering out the request you’re targeting. - Confirm the conditional tag is correct.
is_home()is the blog index, not the front page — if you have a static front page set, useis_front_page()for that. - Add a quick
var_dump( $query->query_vars )just before yourreturnto confirm the modified values are set. - Install Query Monitor — it shows the exact arguments of every query that ran on the page.
Frequently Asked Questions
What is the difference between pre_get_posts and query_posts?
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.
Does pre_get_posts affect admin pages?
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.
Can I use pre_get_posts to add a meta query to the main loop?
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.
Will pre_get_posts break pagination?
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.
Can I have multiple pre_get_posts hooks?
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.