The Difference Between pre_get_posts and template_redirect (And When Each Betrays You)

If you’ve spent any time bending WordPress to your will, you’ve likely met both pre_get_posts and template_redirect. They sit in different corners of the request lifecycle, yet they’re often dragged into the same conversation—usually when a developer is trying to alter what appears on a page. The trouble is, they don’t do the same job. Not even close. And using one where the other belongs leads to silent failures, wasted queries, and layouts that feel like they’re fighting back.

I’m Jonas Venn, and I build WordPress systems that have to hold up under real content loads, not just demo data. I’ve watched both of these hooks betray developers who trusted them too much. Let’s walk through what each hook actually does, where they live in the request cycle, and the specific moments you’ll regret picking the wrong one.

Close-up of a WordPress code editor on a dark screen

Where These Hooks Sit in the WordPress Request Lifecycle

Before you can use either hook safely, you need to know when they fire. The WordPress request lifecycle is a sequence of events that starts with the index.php entry point and ends with a fully rendered template. pre_get_posts fires early, right after the main query object is set up but before any posts are fetched from the database. template_redirect fires much later, after the main query has run, after WordPress has determined which template file to load, but just before that template is actually rendered.

This timing difference is everything. pre_get_posts lets you modify the SQL query that fetches posts. template_redirect lets you hijack the entire page output. If you try to change what posts appear using template_redirect, you’re already too late—the query ran, the database round-trip happened, and you’re now patching a wound with a bandage that doesn’t stick.

pre_get_posts: The Query Modifier That Demands Precision

pre_get_posts is an action hook that gives you access to the WP_Query object before it hits the database. You use it to alter query variables—posts_per_page, post_type, category__in, meta_query, and so on. The hook passes the query object by reference, so your changes directly shape what WordPress retrieves.

Here’s a typical, clean example. Say you want to show only future events on the main blog archive, but you don’t want to touch the admin side or secondary queries:

add_action( 'pre_get_posts', 'jooom_modify_main_query' );
function jooom_modify_main_query( $query ) {
    if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {
        $query->set( 'meta_key', 'event_date' );
        $query->set( 'orderby', 'meta_value' );
        $query->set( 'order', 'ASC' );
        $query->set( 'meta_query', array(
            array(
                'key'     => 'event_date',
                'value'   => date( 'Y-m-d' ),
                'compare' => '>=',
                'type'    => 'DATE'
            )
        ) );
    }
}

This is pre_get_posts at its best: surgical, conditional, and aware of its context. The is_main_query() check is not optional. Without it, you’ll corrupt every query on the page—menus, widgets, related posts—and then wonder why your site’s navigation suddenly lists only future events.

When pre_get_posts Betrays You

The betrayal comes when you assume pre_get_posts can solve a problem it was never designed to touch. I’ve seen developers try to use it to redirect users, load alternative templates, or inject content into the loop. None of that works. By the time pre_get_posts fires, WordPress hasn’t decided which template to use. You can’t force a 404, you can’t switch to a custom page template, and you can’t output a different header. The hook is purely about shaping the main query’s SQL.

Another common pitfall: forgetting that pre_get_posts runs on every query unless you guard it. A missing is_main_query() check is the fastest way to break a site. I’ve also seen developers try to use pre_get_posts to alter queries on pages that don’t have a main query in the traditional sense—like a static front page. If your front page is set to a static page, the main query fetches that page, not a list of posts. Tweaking posts_per_page there does nothing visible, and you’ll waste an hour debugging why your custom loop isn’t showing up.

Another subtle betrayal: pre_get_posts runs on every request, including REST API and AJAX calls if they trigger a main query. If you’re not checking ! is_admin() and the request context, you might accidentally alter admin list tables or API responses. The fix is always more conditionals—$query->is_main_query(), ! is_admin(), and specific is_* checks.

Abstract representation of database queries with glowing lines

template_redirect: The Page Hijacker That Thinks It’s Helpful

template_redirect fires after WordPress has determined which template to load, but before any output is sent to the browser. At this point, the main query is done. The global $wp_query object is fully populated. WordPress knows whether it’s a single post, a page, an archive, a 404, or a search result. This hook is your last chance to say, “No, I don’t want that template. I want something else entirely.”

Common uses include forcing a 404 when a post doesn’t meet certain criteria, redirecting users based on custom conditions, or loading a completely different template file for a specific URL pattern. For example, if you have a custom post type “portfolio” and you want to serve a special template only when a query string is present, template_redirect is your hook:

add_action( 'template_redirect', 'jooom_custom_portfolio_redirect' );
function jooom_custom_portfolio_redirect() {
    if ( is_singular( 'portfolio' ) && isset( $_GET['view'] ) && 'detailed' === $_GET['view'] ) {
        include get_stylesheet_directory() . '/portfolio-detailed.php';
        exit;
    }
}

This works because the main query already fetched the portfolio post. We’re just swapping the presentation layer. If you tried this with pre_get_posts, you’d be modifying a query that hasn’t even run yet, with no guarantee the template would match your intent.

When template_redirect Betrays You

The biggest betrayal is performance blindness. Because template_redirect fires after the main query, any database work that query did is already done. If you realize at this stage that you need different posts—say, you want to show related items instead of the main content—you’re stuck. You can run a secondary query with new WP_Query(), but now you’ve doubled your database load. The main query still ran, fetched posts you’re not using, and consumed server resources. On a high-traffic site, that’s a slow, painful death.

Another trap: using template_redirect to modify the main loop’s content. You can’t. The loop will iterate over whatever the main query returned. If you want to change that, you need to either override the global $wp_query (messy and risky) or use a different approach entirely—like a custom page template with a secondary query, or better yet, pre_get_posts to alter the main query before it runs.

I’ve also seen template_redirect used for conditional redirects that should have been handled earlier. For instance, if you need to redirect a user based on whether a post exists in a certain category, you can check that in template_redirect, but the main query already ran. A more efficient path is to hook into parse_request or parse_query and short-circuit the process before the database gets hit. template_redirect is convenient, but it’s not free.

A developer staring at multiple monitors with WordPress admin panels open

Direct Comparison: When to Use Which

Let’s make this concrete. Here’s a side-by-side of common tasks and which hook you should reach for:

  • Change the number of posts on an archive page: pre_get_posts. Alter posts_per_page before the query runs. template_redirect can’t do this without a second query.
  • Exclude a category from the blog index: pre_get_posts. Use category__not_in on the main query. If you wait until template_redirect, the excluded posts are already fetched and counted toward pagination—your page counts will be wrong.
  • Redirect a user based on post meta: template_redirect. You need the post data to decide, and that data is available only after the query. But consider if you can redirect earlier using parse_request to save the query cost.
  • Load a custom template for a specific URL pattern: template_redirect. Use template_include filter if you just want to swap the file; use template_redirect if you need to run logic before deciding.
  • Alter the sort order of search results: pre_get_posts. Modify the orderby and order parameters on the search query. template_redirect can’t reorder already-fetched results without a new query.
  • Display a custom 404 page based on the requested URL: template_redirect. Check the global $wp_query->is_404() and load your template. But if you want to prevent the 404 by finding matching content, hook earlier—like parse_request—to avoid the 404 status header being sent.

Notice a pattern? pre_get_posts is for shaping what data the main query fetches. template_redirect is for deciding how to present the page after the data is fetched. Cross those lines, and you’ll create bugs that are hard to trace because they involve state that’s already been set.

Real-World Scenario: Fixing a “Nothing Found” Archive

Let’s look at a practical case that ties into a common WordPress headache. Suppose you have a custom post type “Projects” with an archive at /projects/. You’ve set has_archive => true when registering the post type, but visiting the URL shows “Nothing Found.” You might be tempted to jump into template_redirect and force a custom query. Don’t.

The “Nothing Found” message usually means the main query returned zero posts, often because of a conflict with the default query parameters or a plugin modifying queries incorrectly. The right fix is to use pre_get_posts to ensure the main query includes your custom post type on that archive page:

add_action( 'pre_get_posts', 'jooom_fix_project_archive' );
function jooom_fix_project_archive( $query ) {
    if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive( 'project' ) ) {
        $query->set( 'posts_per_page', 12 );
        $query->set( 'orderby', 'date' );
        $query->set( 'order', 'DESC' );
    }
}

If you’re still seeing “Nothing Found” after that, the problem is likely deeper—maybe the post type isn’t registered correctly, or a plugin is unsetting your query vars. I’ve written about diagnosing that exact situation in What to Fix First When a New WordPress Site Says Nothing Found. The point here is that pre_get_posts is your first line of defense for query-related issues. template_redirect won’t help you if the main query is empty; you’d just be loading a template that has no posts to display.

Performance Considerations That Most Guides Skip

Every time you use template_redirect to run a secondary query, you’re adding database load. On a page that already has a main query, plus maybe a few widget queries, adding another full WP_Query can push your server over the edge during traffic spikes. I’ve seen sites where a well-meaning developer used template_redirect to create a “custom loop” for a specific category, resulting in two full queries where one would have sufficed. The fix was to move the logic to pre_get_posts and let the main query handle everything.

But pre_get_posts isn’t always the performance hero. If you add complex meta queries or tax queries to the main query, you can slow down every page load on your site—even pages that don’t display the results. That’s because pre_get_posts modifies the query before it runs, and if your conditionals aren’t tight enough, you’ll burden unrelated pages with expensive database operations. Always use the most specific conditional tags possible: $query->is_post_type_archive() instead of just is_archive(), for example.

When Both Hooks Betray You: The Case of the Missing Conditional

There’s a scenario where neither hook works as expected, and it’s maddening until you understand why. Imagine you want to modify the query for a page that uses a custom page template. You hook into pre_get_posts and check is_page_template( 'custom-template.php' ). It doesn’t fire. Why? Because pre_get_posts runs before the template is selected. The conditional tag is_page_template() relies on the template being chosen, which happens later. So your check fails, and your modifications never apply.

You might then try template_redirect, but by then the query is done. You can run a new query, but you’ve lost the efficiency of modifying the main query. The solution is to use a different conditional in pre_get_posts—like checking the page slug or ID—or to override the main query entirely with query_posts() (which is generally discouraged but sometimes necessary). This is the kind of edge case that separates a builder from a copier: understanding the exact sequence of WordPress core actions and knowing which hook gives you the control you need.

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. The hook’s purpose is to modify query variables. If you try to call wp_redirect() inside a pre_get_posts callback, you’ll likely cause errors because output may have already started, or you’ll interfere with the query setup. Use template_redirect or an earlier hook like parse_request for redirects.

Why does my pre_get_posts modification affect admin pages?

Because you forgot to include ! is_admin() in your conditional check. pre_get_posts runs on every query, including those in the WordPress admin area. Without that check, your changes will alter post lists, media library queries, and other admin screens. Always start your pre_get_posts callback with if ( is_admin() ) return; unless you specifically intend to modify admin queries.

Is it safe to run a new WP_Query inside template_redirect?

It’s safe in the sense that it won’t break the page, but it’s inefficient. The main query has already run and fetched posts. If you run a new WP_Query, you’re adding a second database request. For small sites, this might not matter. For sites with heavy traffic or large databases, it can cause noticeable slowdowns. Whenever possible, restructure your logic to modify the main query via pre_get_posts instead.

What’s the difference between template_redirect and template_include?

template_redirect is an action hook that fires before the template is loaded; it’s meant for performing logic like redirects or custom conditions. template_include is a filter hook that lets you change which template file is loaded. If you just need to swap the template file, use template_include. If you need to run additional logic (like checking user permissions) before deciding on the template, use template_redirect.

Understanding these two hooks isn’t about memorizing their definitions. It’s about knowing the exact moment in WordPress’s execution where your code will run, and what state the system is in at that moment. Get that right, and you’ll stop fighting your own site.