Introduction

Sooner or later you need an archive to behave differently: show more posts per page, exclude a category from the blog, change the sort order, or include a custom post type in the main feed. The wrong way is to build a new WP_Query in the template. The right way is the pre_get_posts hook.

pre_get_posts lets you adjust the main query before it runs, so pagination, the template hierarchy, and conditional tags all keep working. This article shows exactly how to use it safely, with guards that stop it from affecting the admin or secondary queries.

You will learn:

  • What pre_get_posts is and when it fires
  • The guards every callback needs: is_main_query and is_admin
  • Practical recipes for posts per page, ordering, and including post types
  • The mistakes that make pre_get_posts misbehave, and how to avoid them

This builds on WP_Query and conditional tags. If you understand those, pre_get_posts will feel natural.

What Is pre_get_posts?

pre_get_posts is an action that fires after WordPress builds a query but before it runs the database request. WordPress passes the WP_Query object to your callback by reference, so any change you make with $query->set() affects the query that actually runs.

Crucially, it fires for every query: the main one, every secondary WP_Query, and queries in the admin. That power is why guards are essential, which we cover next.

<?php
add_action( 'pre_get_posts', function( $query ) {
    // Change query arguments here with $query->set().
} );

The Two Guards You Always Need

Because pre_get_posts runs for every query, an unguarded callback will change things you did not intend, including the admin post list. Two checks keep it targeted.

  • $query->is_main_query() – True only for the main query WordPress built from the URL. Without this, your change hits every secondary WP_Query on the page too.
  • ! is_admin() – Excludes the admin dashboard. Without this, you can break the post list and search inside wp-admin.
<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    // Safe to modify the front-end main query here.
} );

Returning early when either guard fails is the cleanest pattern. Everything after the guard is known to be the front-end main query.

Using Conditional Tags Inside the Hook

To target a specific context, call conditional tags as methods on the passed query object, not the global functions. $query->is_home() checks the query being modified, which is the correct thing to test here.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_home() ) {
        $query->set( 'posts_per_page', 6 );
    }
} );

Why methods rather than the global is_home()? Inside pre_get_posts the global conditionals are not always reliable yet, and you want to test the specific query passed in. Using the object methods avoids subtle timing bugs.

Recipe: Change Posts Per Page on an Archive

Show a different number of posts on category archives without touching the reading settings or the template.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_category() ) {
        $query->set( 'posts_per_page', 12 );
    }
} );

Because this changes the main query, pagination still works. WordPress recalculates max_num_pages from the new count, and the_posts_pagination() in the template keeps functioning.

Recipe: Exclude a Category From the Blog

Keep an "asides" or "internal" category out of the main blog listing while leaving it accessible on its own archive.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_home() ) {
        $query->set( 'category__not_in', array( 15 ) );
    }
} );

Category 15 disappears from the home blog feed but its own category archive still shows those posts, because the condition only targets the home query.

Recipe: Include a Custom Post Type in the Main Feed

By default the blog shows only the post type. To mix a custom type, such as portfolio, into the main feed, set post_type to include both.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_home() ) {
        $query->set( 'post_type', array( 'post', 'portfolio' ) );
    }
} );

Recipe: Change the Order of Search Results

Sort search results by date instead of relevance, only on the search page.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_search() ) {
        $query->set( 'orderby', 'date' );
        $query->set( 'order', 'DESC' );
    }
} );

Recipe: Adjust an Author Archive

Use modify-by-context to change any archive. Here, show more posts on author archives and order alphabetically.

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( $query->is_author() ) {
        $query->set( 'posts_per_page', 20 );
        $query->set( 'orderby', 'title' );
        $query->set( 'order', 'ASC' );
    }
} );

Watch Out for Feeds and REST Requests

pre_get_posts fires for more than just normal page views. RSS feeds and front-end REST API requests also build a main query, so an unguarded change can alter your feed or API output in ways you did not expect.

If a change should only apply to normal browsing, narrow the guard further:

<?php
add_action( 'pre_get_posts', function( $query ) {
    if ( is_admin() || ! $query->is_main_query() || $query->is_feed() ) {
        return;
    }

    // Only normal front-end page views reach here.
    if ( $query->is_home() ) {
        $query->set( 'posts_per_page', 6 );
    }
} );

Add $query->is_feed() to the bail-out when a change would distort the feed. For REST-driven listings, test the behaviour on the relevant endpoint, since the same main-query rules apply there.

Reading and Setting Query Vars

Two methods do most of the work inside the hook:

  • $query->get( 'key' ) – Read the current value of a query argument.
  • $query->set( 'key', $value ) – Set or change a query argument.

You can read a value to make a decision, then set a new one. For example, read the existing post type before deciding whether to add another.

Best Practices

  • Always guard with is_admin() and $query->is_main_query() at the top, and return early if either fails.
  • Use conditional tags as methods on the passed query object, such as $query->is_home().
  • Use pre_get_posts to change the main query; never use query_posts() or a new WP_Query in the template for this.
  • Make each change as narrow as possible so it only affects the intended context.
  • Keep callbacks in a plugin or the theme functions file, not scattered through templates.

Common Mistakes

Forgetting the is_main_query() guard

Without it, your change applies to every secondary query on the page, including menus, related posts, and widgets. The symptoms are bizarre and hard to trace. Always guard.

Forgetting the is_admin() guard

pre_get_posts fires in the dashboard too. An unguarded callback can change the admin post list, search, and counts. Exclude admin unless you specifically intend to change it.

Using global conditional functions instead of object methods

Calling the global is_home() inside the hook can be unreliable due to timing. Use $query->is_home() to test the query you were handed.

Trying to modify a secondary query here

pre_get_posts is for the main query. To change a custom WP_Query, just pass the arguments you want when you create it. Do not rely on this hook for that.

Troubleshooting

My change also affects the admin

Add the ! is_admin() guard. The hook runs in wp-admin as well, so front-end-only changes must exclude it.

You are missing the is_main_query() guard, so secondary queries are being modified. Add it and return early when it is false.

Pagination shows the wrong number of pages

If you set posts_per_page in the template with a new query instead of here, pagination breaks. Set it in pre_get_posts so WordPress recalculates max_num_pages for the main query.

The condition never matches

Confirm you are calling the conditional as a method on the query object and that you are testing the right context. Dump $query->get( "post_type" ) or the queried object to see what the main query actually is.

Frequently Asked Questions

When does pre_get_posts run?

It fires after WordPress builds a query object but before the query runs against the database, for the main query and every secondary query, on both the front end and the admin.

Why not just use query_posts()?

query_posts() replaces the main query and breaks pagination, and it runs an extra query. pre_get_posts changes the existing main query in place, so pagination and context stay intact. It is the supported approach.

How do I target only the homepage blog?

Guard with is_admin() and $query->is_main_query(), then check $query->is_home(). That isolates the main blog posts index from every other context.

Can I change a custom post type archive?

Yes. Use $query->is_post_type_archive( "your_type" ) inside the guarded callback and set the arguments you need, such as posts_per_page or orderby.

Does pre_get_posts affect secondary WP_Query loops?

It can, which is why the is_main_query() guard matters. If you want to change only a custom query, set the arguments when you create that WP_Query instead.

Conclusion

pre_get_posts is the correct, supported way to change what WordPress shows on its built-in listings. It edits the main query in place, so pagination, conditional tags, and the template hierarchy all keep working, which is exactly why it beats query_posts() and template-level queries.

The whole technique comes down to a guarded callback: bail out on admin and non-main queries, target a context with the query object methods, and adjust with $query->set(). Master that shape and you can reshape any archive without side effects.

Next, the conditional tags article details the contexts you can target, and the WP_Query reference lists every argument you can set.