You’re building a custom archive page. You need to tweak the main query—maybe change the posts per page, add a meta query, or exclude a category. You reach for pre_get_posts. It’s the standard, the safe bet. But then you add a redirect for a specific template, or you need to fire a 404, and suddenly your query modifications vanish, or worse, they fire on the wrong page entirely. You’ve stumbled into the gap between pre_get_posts and template_redirect.
I’ve debugged this exact scenario on client sites more times than I can count. A developer hooks into pre_get_posts to customize the main query on a custom post type archive. Then they add a template_redirect to enforce some access logic. The redirect fires, but the query is already set—or worse, the query modification runs on the redirected page, mangling the wrong loop. Understanding the execution order isn’t just academic; it’s what keeps your site from serving broken layouts or exposing content you meant to hide.
The WordPress Request Lifecycle in Plain Terms
Before we pit these two hooks against each other, we need to map the terrain. WordPress processes every request through a predictable sequence. The key players are pre_get_posts, which fires inside the WP_Query constructor, and template_redirect, which fires much later, just before the template is chosen.
Here’s the simplified order:
- Query parsing: WordPress translates the URL into query variables.
pre_get_posts: Fires after the query variable object is created but before the actual database query runs. This is where you modify the main query.- Database query: WordPress fetches the posts based on the (possibly modified) query variables.
template_redirect: Fires after the query is run and posts are loaded, but before the template is selected. This is where you decide which template file to use or whether to redirect the user entirely.
The trap is set when you use pre_get_posts to alter a query that template_redirect later invalidates. Or when you use template_redirect to force a 404, but pre_get_posts has already run a heavy, unnecessary database query. The order is fixed, and misunderstanding it leads to wasted resources and logic errors.
When pre_get_posts Is the Right Tool
pre_get_posts is the scalpel for the main query. It fires on every single page load—front end, back end, feeds, you name it. Its sole purpose is to alter the query variables before the SQL is built. Use it when you need to change what posts appear on an archive, search, or blog page without creating a secondary query.
For example, let’s say you want to exclude a category from the main blog page but only on the front end. You’d hook into pre_get_posts, check that you’re on the main query and not in the admin, and then modify the category parameters.
add_action( 'pre_get_posts', 'joo_exclude_category_from_blog' );
function joo_exclude_category_from_blog( $query ) {
if ( ! $query->is_main_query() || is_admin() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'category__not_in', array( 12 ) ); // Exclude category ID 12
}
}
This is clean, efficient, and doesn’t trigger a second query. The database does the heavy lifting in one pass. But the moment you need to make a decision based on the results of that query, pre_get_posts becomes useless. It fires before the query runs, so you can’t know if the query returned zero posts, or if a specific post has a certain meta value. That’s when you’re tempted to jump to template_redirect—and that’s where the trouble often starts.
The template_redirect Overreach
template_redirect is the hook you use when you need to change the template file or perform a hard redirect. It fires after the query is complete and WordPress has determined what type of page it is (404, single, archive, etc.). It’s the perfect place to say, “Actually, this isn’t the page I want; send the user over there instead.”
Here’s a classic misuse: a developer wants to hide a custom post type archive unless the user is logged in. They write a pre_get_posts callback that sets post__not_in to an empty array, but that still runs the query. Then they add a template_redirect to check is_post_type_archive('hidden_cpt') and redirect non-logged-in users. The problem? The query already ran. On a site with thousands of posts, that’s a wasted, potentially expensive database call. The correct approach is to kill the query early, but template_redirect is too late for that.
Another common pitfall: using template_redirect to set a 404 status. You might think, “I’ll check if a certain condition is met, and if not, I’ll set a 404.” But by the time template_redirect fires, WordPress has already decided it’s not a 404. You can force the 404 template to load, but the headers might already be sent, and the main query has already consumed resources. You’re patching a symptom, not the cause.
Where pre_get_posts Fails You
pre_get_posts is not a decision engine. It’s a modifier. You can’t use it to check if a user has permission to see a specific post, because the post hasn’t been fetched yet. You can’t use it to redirect based on the number of results, because the results don’t exist. You can’t even reliably use it to set a 404, because the 404 status is determined later in the process.
Consider a membership site where certain posts are only visible to logged-in users. A naive approach hooks into pre_get_posts and adds a meta query to filter out restricted posts. That works for the loop, but it doesn’t prevent direct access to a single restricted post via its permalink. For that, you need a later hook—but if you use template_redirect to check permissions and then redirect, you’ve already run the main query for that single post. It’s a small waste, but on a high-traffic site, it adds up.
The real betrayal happens when you combine both hooks without understanding their sequence. You modify the query in pre_get_posts, then in template_redirect you check a condition that depends on the query results. If the condition fails, you redirect. But the query already ran with your modifications, and those modifications might have been expensive—like a meta query on a large post type. You’ve just burned server resources for a page that will never be seen.
Building a Smarter Redirect: The posts_request Filter
If you need to redirect based on query results, but you want to avoid the cost of running the full query, there’s a lesser-known hook: posts_request. This filter fires after the SQL is built but before it’s sent to the database. You can intercept the SQL, run a lightweight check, and if the conditions aren’t met, short-circuit the query and redirect.
Here’s an example. Suppose you have a custom post type “event” and you want to redirect to a calendar page if there are no upcoming events. Instead of letting the query run and then checking in template_redirect, you can do this:
add_filter( 'posts_request', 'joo_redirect_if_no_events', 10, 2 );
function joo_redirect_if_no_events( $request, $query ) {
if ( ! $query->is_main_query() || ! $query->is_post_type_archive( 'event' ) ) {
return $request;
}
// Run a lightweight count query
$count = wp_count_posts( 'event' )->publish;
if ( 0 === $count ) {
wp_redirect( home_url( '/calendar/' ) );
exit;
}
return $request;
}
This hook fires right before the database query, so you can still abort with a redirect and avoid the full query cost. But be careful: posts_request gives you the raw SQL string, not a query object. You can’t use $query->set() here; you’d need to manipulate the SQL directly or, as shown, run a separate lightweight query to make your decision.
When to Use Each Hook: A Practical Guide
Let’s break down common scenarios and the right hook for each.
Modifying the Main Query on Archives
Hook: pre_get_posts
Why: You want to change the posts per page, order, or add a meta query for a specific archive. This is the canonical use case. The query hasn’t run yet, so you’re not wasting resources.
add_action( 'pre_get_posts', 'joo_modify_archive_query' );
function joo_modify_archive_query( $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', 'title' );
$query->set( 'order', 'ASC' );
}
}
Redirecting Based on Post Count
Hook: posts_request or template_redirect
Why: If you need to redirect when a query returns no results, template_redirect is the standard place. But if the query is expensive, consider posts_request to abort early. For a simple blog archive, the cost difference is negligible; for a complex meta query on thousands of posts, it’s significant.
Here’s a template_redirect example that redirects a search with no results to a custom “no results” page:
add_action( 'template_redirect', 'joo_redirect_empty_search' );
function joo_redirect_empty_search() {
if ( is_search() && ! have_posts() ) {
wp_redirect( home_url( '/search-tips/' ) );
exit;
}
}
This is acceptable because the search query already ran. But if you’re dealing with a custom query that you know will be empty under certain conditions, intercept earlier.
Forcing a 404 on Invalid Single Posts
Hook: wp or parse_query
Why: If you need to force a 404 on a single post based on its content (e.g., a custom field), you can’t do it in pre_get_posts because the post hasn’t been fetched. template_redirect works but is late. The wp hook fires after the query object is set up but before the template is loaded, giving you access to the queried post. You can then set a 404 status and load the 404 template without running the full template hierarchy.
add_action( 'wp', 'joo_force_404_on_hidden_posts' );
function joo_force_404_on_hidden_posts( $wp ) {
if ( ! is_singular( 'hidden_post_type' ) ) {
return;
}
$post = get_queried_object();
$is_visible = get_post_meta( $post->ID, '_joo_visibility', true );
if ( 'hidden' === $is_visible && ! current_user_can( 'edit_posts' ) ) {
$wp->set_404();
status_header( 404 );
nocache_headers();
include( get_query_template( '404' ) );
exit;
}
}
This approach sets the 404 status properly and loads the 404 template without running the full single post template. It’s more efficient than a template_redirect redirect because it doesn’t trigger an additional HTTP request.
When Both Hooks Collide: A Debugging Story
I once inherited a site where the blog page was mysteriously blank. The theme had a pre_get_posts callback that set posts_per_page to 6 for the blog archive. A plugin, however, used template_redirect to check if the current page was the blog archive and, if so, redirect to a custom page template. The problem? The plugin’s redirect fired after the query was modified, but the custom template didn’t account for the altered posts_per_page. It used a hardcoded value of 10, so when the query returned only 6 posts, the template’s loop logic broke, showing empty spaces and a missing “next page” link.
The fix was to move the posts_per_page modification to the custom template’s own query, or to use a filter that runs later, like found_posts, to adjust pagination. But the real lesson was about ownership: pre_get_posts and template_redirect were fighting over the same query, and neither was clearly in charge. We ended up consolidating all query logic into a single class that used pre_get_posts for modifications and template_include for template overrides, with a shared state object to avoid conflicts.
Building a Shared State Between Hooks
Sometimes you need to pass information from an early hook to a later one. For example, you might detect a condition in pre_get_posts that should later affect the template choice. WordPress doesn’t provide a built-in way to do this, but you can use a static class property or a transient.
Here’s a pattern I’ve used on membership sites:
class JOO_Query_State {
public static $is_restricted = false;
}
add_action( 'pre_get_posts', 'joo_check_restricted_archive' );
function joo_check_restricted_archive( $query ) {
if ( $query->is_post_type_archive( 'premium_content' ) && ! current_user_can( 'read_premium' ) ) {
JOO_Query_State::$is_restricted = true;
// Prevent the query from fetching posts
$query->set( 'post__in', array(0) );
}
}
add_action( 'template_redirect', 'joo_redirect_restricted_archive' );
function joo_redirect_restricted_archive() {
if ( JOO_Query_State::$is_restricted ) {
wp_redirect( home_url( '/upgrade/' ) );
exit;
}
}
This way, the query is neutered early, and the redirect happens at the right time. The static property acts as a bridge between the two hooks without relying on global variables or re-running logic.
Performance Implications You Can’t Ignore
Every hook you use adds execution time. pre_get_posts fires on every page load, so keep your callbacks lean. Avoid complex logic or additional database calls inside it. If you need to check user permissions, use WordPress’s cached functions like current_user_can()—they’re fast.
template_redirect fires later, but it’s still before the template renders. If you’re doing a redirect, use wp_redirect() with an exit to stop further execution. Don’t let the rest of the page load continue after a redirect; it’s a waste of server resources and can cause header-already-sent errors.
When you’re working with large datasets, the difference between aborting at posts_request versus template_redirect can be measured in milliseconds per request. On a site with 100,000 posts and a complex meta query, that’s the difference between a 200ms response and a 2-second timeout.
FAQ
Can I use pre_get_posts to redirect a user?
No. pre_get_posts fires before headers are sent, but it’s not designed for redirects. You can technically call wp_redirect() inside it, but it’s unreliable and can cause errors because the query hasn’t finished setting up. Use template_redirect or an earlier hook like parse_request for redirects.
Why does my pre_get_posts modification not work on a custom page template?
If your custom page template uses a secondary query (like new WP_Query), pre_get_posts won’t affect it unless you pass the query object and set is_main_query incorrectly. pre_get_posts only alters the main query by default. For secondary queries, modify the arguments directly when you instantiate WP_Query.
What’s the earliest hook I can use to force a 404?
The parse_query hook fires after the query variables are parsed but before the database query. You can use it to set is_404 and is_singular to false, effectively forcing a 404 without running the main query. However, this requires deep understanding of the query object and can break things if not done carefully. The wp hook is a safer, slightly later alternative.
Choosing the Right Hook for the Job
The gap between pre_get_posts and template_redirect is where many WordPress projects accumulate technical debt. The fix isn’t to avoid one or the other; it’s to understand the request lifecycle and place your logic at the earliest possible point that has the information you need.
If you’re modifying what posts appear, use pre_get_posts. If you’re deciding which template to load, use template_redirect. If you’re doing both, consider whether you can split the logic or use an intermediate hook like wp or posts_request. And if you find yourself fighting the system, step back and ask whether your architecture is trying to do too much in the query layer. Sometimes the best fix is a custom REST endpoint or a page template that handles its own data fetching.
For more on fixing common WordPress query issues, read What to Fix First When a New WordPress Site Says Nothing Found. It covers the early debugging steps that often reveal hook conflicts like the ones described here.
Remember: every hook is a tool with a specific shape. Use the one that fits the task, not the one that’s closest to hand.


