WordPress hands you hooks that feel like surgical tools. Two of the sharpest are pre_get_posts and template_redirect. They sit at different points in the request lifecycle, and if you confuse them, you don’t get a syntax error — you get a site that quietly does the wrong thing. I’ve watched developers reach for template_redirect to alter a main query, then lose an afternoon trying to figure out why their pagination shattered. I’ve also seen pre_get_posts used to force a 404, which is like driving a screw with a hammer. This article maps out exactly where each hook lives, what it owns, and the moments each one will turn on you if you’re not paying attention.
What pre_get_posts Actually Does
pre_get_posts fires after the query object is created but before the database gets hit. It gives you access to the WP_Query instance — usually the main query — and lets you tweak query variables before SQL runs. This is the hook you use when you need to change which posts appear on an archive, a search results page, or the blog index. You’re not redirecting; you’re reshaping the data WordPress fetches.
A classic example: you want to exclude a category from the main blog loop. You drop into functions.php and write something like this:
add_action('pre_get_posts', function($query) {
if ( ! $query->is_main_query() || is_admin() ) return;
if ( $query->is_home() ) {
$query->set('cat', '-12');
}
});
No new WP_Query, no custom page template with a secondary loop. You’ve altered the main query before it runs, so pagination, sticky posts, and all template tags stay intact. That’s the power of pre_get_posts: it modifies intent without breaking the machinery that depends on it.
Where pre_get_posts Lives in the Request Lifecycle
WordPress processes a request in a specific order: it parses the URL into query variables, instantiates WP_Query, then fires pre_get_posts before the SQL is sent to the database. After the query runs, the template loader decides which file to render. pre_get_posts is purely a query modifier. It doesn’t know about templates, headers, or HTTP status codes. It only knows about the query vars you’re about to send to MySQL.
This is why pre_get_posts is the right tool when you need to change what content is fetched — not where it’s displayed. If you want to alter the number of posts per page on a custom post type archive, you do it here. If you want to exclude sticky posts from a category archive, you do it here. The hook is precise, and its precision is its strength.
When pre_get_posts Betrays You
The betrayal comes when you ask it to do something it wasn’t built for. I’ve seen developers try to use pre_get_posts to redirect users, load a different template, or send a 404 header. None of that works because the hook fires too early. The template loader hasn’t run yet. Headers haven’t been sent. You’re still in query land.
Another common failure: forgetting to scope your modifications. If you don’t check $query->is_main_query() or is_admin(), you’ll accidentally modify every query on the page — menus, widgets, related post blocks. Suddenly your sidebar shows the wrong posts, or your admin screens break. The fix is always the same: add strict conditional checks before touching $query.
There’s also the silent pagination killer. You change posts_per_page on an archive, but forget that the pagination functions rely on the original query’s found_posts calculation. If your pre_get_posts logic introduces a mismatch, you get 404s on page 2. The solution is often to pair pre_get_posts with a found_posts filter, or to double-check that your modifications don’t break the SQL LIMIT and SQL_CALC_FOUND_ROWS relationship.

What template_redirect Actually Does
template_redirect fires much later. By the time this hook runs, WordPress has already parsed the request, run the main query, and decided which template file to load. The hook gives you a chance to override that decision before any output is sent. You can force a different template, perform a wp_redirect(), or send a 404 header and load your own error page.
This is the hook for context-based routing. Say you have a custom post type “event” and you want to redirect users to a registration page if the event is sold out. You’d hook into template_redirect, check the post meta for the event, and call wp_redirect() if needed. You’re not changing the query — you’re reacting to its results.
Another classic use: enforcing authentication. If a user isn’t logged in and tries to access a members-only page, template_redirect lets you intercept the request and redirect to a login screen. You can also use it to load alternative templates based on user roles, device detection, or A/B testing parameters. The hook gives you control over the final presentation layer.
When template_redirect Betrays You
The most painful betrayal happens when you try to modify the main query from template_redirect. By the time this hook fires, the main query has already executed. If you call new WP_Query() or modify $wp_query directly, you’re creating a secondary query or altering an already-completed object. Pagination breaks. Performance tanks. Template tags like have_posts() behave unpredictably because they’re still referencing the original query.
I’ve seen sites where a developer added a query_posts() call inside template_redirect to “fix” the number of posts on an archive. That’s a double betrayal: query_posts() itself is a problematic function that replaces the main query and should almost never be used, and placing it inside template_redirect compounds the damage. The correct approach is always to use pre_get_posts for query modifications.
Another subtle trap: performing redirects without checking the request type. If you call wp_redirect() on an AJAX request, a REST API call, or an admin-ajax action, you’ll break functionality. Always wrap your template_redirect logic in a check like if ( ! is_admin() && ! wp_doing_ajax() ) unless you specifically intend to affect those contexts.

Side-by-Side: Choosing the Right Hook for the Job
Let’s make this concrete. You’re building a site for a conference. The “Sessions” custom post type has an archive at /sessions. You need to exclude sessions that are marked as “private” from the archive, but only for logged-out users. That’s a query modification — it changes which posts are fetched. You use pre_get_posts.
Now imagine a logged-in user visits a specific session page that’s marked as private. You want to show them the page, but you also want to add a banner saying “This session is private — only visible to attendees.” That’s a template-level change. You could use template_redirect to swap the template, or better yet, use a filter like template_include or simply add conditional logic inside the template file itself. template_redirect is for routing decisions, not content filtering.
Here’s a table that maps common tasks to the correct hook:
| Task | Correct Hook | Why |
|---|---|---|
| Change posts per page on an archive | pre_get_posts |
Must modify query before SQL runs |
| Redirect a user based on post meta | template_redirect |
Query already complete; need to change destination |
| Exclude a category from search results | pre_get_posts |
Alters the search query itself |
| Load a custom 404 template | template_redirect or template_include |
Template selection happens after query |
| Add a custom post type to the main blog loop | pre_get_posts |
Modifies the main query’s post types |
The Hidden Cost of Getting It Wrong
When you use pre_get_posts to do a redirect, you’re forcing WordPress to run a full query — hitting the database, hydrating post objects, updating caches — only to throw that work away with a wp_redirect(). On a high-traffic site, that’s wasted server resources on every request. Worse, if your redirect condition depends on post meta that isn’t loaded until after the query, you’ll need to run an additional query just to check, then redirect. That’s two queries and a redirect for a single page view.
Using template_redirect to modify the main query creates a different kind of waste. You end up with a secondary WP_Query that duplicates the main query’s work, or you hack the global $wp_query object after the fact, which confuses WordPress’s internal state. Plugins that rely on the main query being accurate — SEO plugins, related post plugins, caching layers — will produce incorrect output. Your pagination links will point to pages that don’t exist, or worse, pages that exist but show the wrong content.
I once inherited a site where the developer used template_redirect to set $wp_query->posts manually for a custom archive. The result? The first page looked fine, but page 2 returned a 404 because WordPress’s internal pagination calculation didn’t match the manually injected posts. The fix required rewriting the entire feature to use pre_get_posts and a proper WP_Query setup. It took hours to untangle. If you’re dealing with a similar mess, you might find the debugging approach in What to Fix First When a New WordPress Site Says Nothing Found useful — it walks through the same kind of query-vs-template confusion that causes blank pages and incorrect 404s.
Practical Patterns for Clean Architecture
Here’s how I structure query modifications to avoid these traps. First, all pre_get_posts logic goes into a single function, with early returns for conditions where the hook shouldn’t fire. This keeps the main query modifications centralized and predictable.
add_action('pre_get_posts', function($query) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive('product') ) {
$query->set('posts_per_page', 12);
$query->set('orderby', 'menu_order');
$query->set('order', 'ASC');
}
if ( $query->is_search() ) {
$query->set('post_type', ['post', 'page', 'product']);
}
});
For template_redirect, I follow a similar pattern: one function, early returns, and a clear separation between routing logic and template logic. If I need to load a different template, I use template_include instead — it’s more specific and avoids the temptation to mix concerns.
add_action('template_redirect', function() {
if ( is_admin() || wp_doing_ajax() ) {
return;
}
if ( is_singular('event') ) {
$event_id = get_queried_object_id();
$is_sold_out = get_post_meta($event_id, '_sold_out', true);
if ( $is_sold_out && ! current_user_can('edit_posts') ) {
wp_redirect( home_url('/sold-out/') );
exit;
}
}
});
Notice the exit after wp_redirect(). It’s not optional. Without it, WordPress continues executing the rest of the request, which can lead to unexpected output, broken headers, or security issues. Always exit after a redirect.

When Both Hooks Need to Work Together
There are legitimate cases where you need both hooks in the same feature. Imagine a membership site where free users see a limited set of posts on the blog archive, and premium users see everything. You use pre_get_posts to filter the query based on user role. But you also want to redirect free users who try to access a premium post directly. That’s template_redirect.
The key is to keep the responsibilities separate. pre_get_posts handles the archive filtering — it modifies the query to exclude premium posts for free users. template_redirect handles the single-post access control — it checks the current post’s meta and redirects if the user lacks access. The two hooks don’t interact directly; they each own their part of the request.
This separation makes the code easier to debug. If the archive shows the wrong posts, you know to look at pre_get_posts. If the single post redirect isn’t working, you check template_redirect. When you mush both concerns into one hook, you create a debugging nightmare where changing one thing breaks another.
FAQ
Can I use pre_get_posts to redirect users?
Technically you can call wp_redirect() inside pre_get_posts, but you shouldn’t. The hook fires before headers are sent, so the redirect will work, but you’re forcing WordPress to build a query object that you immediately discard. It’s wasteful and semantically wrong. Use template_redirect for redirects — that’s what it’s designed for.
Why does my pagination break when I modify the query in template_redirect?
By the time template_redirect runs, the main query has already executed and pagination calculations are complete. If you replace the query or modify the posts array, the pagination data no longer matches the actual content. The fix is to move your query modifications to pre_get_posts, where they happen before the database query and pagination are calculated.
What’s the difference between template_redirect and template_include?
template_redirect fires before WordPress determines which template file to load. It’s meant for redirects, 404 handling, and other routing decisions. template_include fires after the template is chosen but before it’s loaded. It lets you override the template file path. If you just need to swap the template file, use template_include — it’s cleaner and runs at the right time.
Does pre_get_posts affect custom WP_Query loops in templates?
Only if you don’t scope it properly. pre_get_posts fires for every WP_Query instance, including secondary queries in sidebars, widgets, and custom loops. Always check $query->is_main_query() to ensure you’re only modifying the primary query. If you need to modify a secondary query, pass the arguments directly when you instantiate WP_Query instead.