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 );
    }
} );
PHP

Those 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;
    }
} );
PHP

Each 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 ] );
    }
} );
PHP

You 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 ] );
    }
} );
PHP

Including 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' ] );
    }
} );
PHP

Pass 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',
            ],
        ] );
    }
} );
PHP

Changing 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' );
    }
} );
PHP

You 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' );
    }
} );
PHP

Using 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();
PHP

What 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_posts fires for that too — but your is_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_posts to make it behave like a tag archive. The conditional tags like is_category() are already set by the time this hook fires.
  • It’s not the right place for redirects. Use template_redirect for 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:

  1. Check that is_admin() and is_main_query() guards are in place and aren’t filtering out the request you’re targeting.
  2. Confirm the conditional tag is correct. is_home() is the blog index, not the front page — if you have a static front page set, use is_front_page() for that.
  3. Add a quick var_dump( $query->query_vars ) just before your return to confirm the modified values are set.
  4. 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.

About Me

Gemini_Generated_Image_6ed8rn6ed8rn6ed8

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.

Learn More