Changing the main query with pre_get_posts

Adjust archives, search and the blog page before WordPress runs the query, instead of running a second one.

  • Intermediate
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Modify the main query
  • Target only the queries you mean to change
  • Avoid query_posts
  • Fix pagination problems caused by custom queries in templates

Before you start

Every front-end request already runs one query, the main query, before the template loads. When you want an archive to show twelve events instead of ten, or search to skip pages, the right move is to change that query before it runs. The pre_get_posts action lets you do exactly that, with no extra database work.

Why not just write a new query in the template?

A common first attempt looks like this, inside archive-myplugin_event.php:

PHP
<?php

// Avoid: runs a second query and breaks pagination.
$events = new WP_Query(
    array(
        'post_type'      => 'myplugin_event',
        'posts_per_page' => 12,
        'paged'          => get_query_var( 'paged' ),
    )
);

This has three problems:

  1. WordPress already ran the main query for this archive. Your query is a second one, and the first was wasted.
  2. Pagination breaks. WordPress decides whether /events/page/4/ exists based on the main query. If the main query shows ten per page and yours shows twelve, page numbers no longer agree, and the last pages can return a 404.
  3. Everything else still sees the main query: the page title, conditional tags such as is_post_type_archive(), SEO plugins and caching layers.

Changing the main query fixes all three, because there is only one query and everyone agrees on it.

How pre_get_posts works

WordPress builds a WP_Query object from the request, then fires pre_get_posts just before running its SQL. Your callback receives that object and can change its query variables with $query->set().

The hook fires for every WP_Query, not only the main one: admin list screens, widgets, menus, REST requests and your own custom queries. So every callback must first check that it has the query it means to change.

Put this in your plugin (or in the theme's functions.php if the change is purely presentational, such as posts per page for a theme layout):

PHP
<?php

add_action( 'pre_get_posts', 'myplugin_events_archive_query' );

/**
 * Show 12 upcoming events per page on the event archive, soonest first.
 *
 * @param WP_Query $query The query about to run.
 */
function myplugin_events_archive_query( WP_Query $query ): void {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    if ( ! $query->is_post_type_archive( 'myplugin_event' ) ) {
        return;
    }

    $query->set( 'posts_per_page', 12 );
    $query->set(
        'meta_query',
        array(
            'event_date' => array(
                'key'     => 'myplugin_event_date',
                'value'   => wp_date( 'Y-m-d' ),
                'compare' => '>=',
                'type'    => 'DATE',
            ),
        )
    );
    $query->set( 'orderby', 'event_date' );
    $query->set( 'order', 'ASC' );
}

The guard clauses matter:

CheckWhy
is_admin()Leaves the admin list screens alone, so editors still see every event
$query->is_main_query()Leaves widgets, menus and your own secondary queries alone
$query->is_post_type_archive( 'myplugin_event' )Targets one kind of page

Note that the conditionals are called on the query object: $query->is_search(), $query->is_home(), $query->is_tax(). Inside pre_get_posts, the global versions such as is_search() describe the main query, which may not be the query you received.

The callback receives the object itself, so you do not return anything. Changes made with $query->set() take effect immediately.

The template stays simple. It is the normal Loop:

PHP
<?php
if ( have_posts() ) :
    while ( have_posts() ) :
        the_post();
        get_template_part( 'template-parts/content', 'event' );
    endwhile;

    the_posts_pagination();
endif;

Common recipes

Search only some post types

PHP
<?php

add_action( 'pre_get_posts', 'myplugin_limit_search' );

function myplugin_limit_search( WP_Query $query ): void {
    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {
        return;
    }

    $query->set( 'post_type', array( 'post', 'myplugin_event' ) );
}

Hide a category from the blog page

PHP
<?php

add_action( 'pre_get_posts', 'myplugin_hide_announcements' );

function myplugin_hide_announcements( WP_Query $query ): void {
    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() ) {
        return;
    }

    $term = get_term_by( 'slug', 'internal-announcements', 'category' );

    if ( $term ) {
        $query->set( 'category__not_in', array( $term->term_id ) );
    }
}

is_home() is the posts page (the blog), which is not always the front page. Looking up the term by slug avoids hard-coding an ID that differs between your local site and production.

Add events to a taxonomy archive

If a shared taxonomy such as post_tag is attached to events, its archive only lists normal posts by default. You can widen it:

PHP
<?php

add_action( 'pre_get_posts', 'myplugin_tags_include_events' );

function myplugin_tags_include_events( WP_Query $query ): void {
    if ( is_admin() || ! $query->is_main_query() || ! $query->is_tag() ) {
        return;
    }

    $query->set( 'post_type', array( 'post', 'myplugin_event' ) );
}

Respect what is already set

Another plugin may have changed the same query. When you add a meta or tax condition, merge it with any existing one instead of overwriting:

PHP
<?php

$meta_query   = (array) $query->get( 'meta_query' );
$meta_query[] = array(
    'key'     => 'myplugin_featured',
    'compare' => 'NOT EXISTS',
);
$query->set( 'meta_query', $meta_query );

$query->get() returns an empty string when the variable is not set, so the (array) cast turns that into an empty array.

Never use query_posts()

query_posts() looks convenient: call it in a template and the Loop shows different posts. Do not use it.

  • It replaces the main query after it has already run, so the database does the work twice.
  • It overwrites the global query, which confuses conditional tags, pagination and anything else that reads the main query later on the page.
  • You must remember to call wp_reset_query() afterwards, and forgetting it causes hard-to-find bugs elsewhere on the page.

The Code Reference itself warns against it. Use this rule instead:

You want toUse
Change what the page's main list showspre_get_posts
Show an extra, separate list (related events, a sidebar)A new WP_Query, then wp_reset_postdata()
Get an array of posts to process in PHPget_posts()

Block themes and the Query Loop block

Block themes use HTML templates and the Query Loop block instead of PHP template files. A Query Loop block set to inherit the query from the template displays the main query, so pre_get_posts changes apply to it exactly as they apply to a classic theme's Loop. A Query Loop block with its own custom settings runs a separate query, and its settings come from the block. Changing those blocks from code belongs to the Gutenberg development course.

Debugging pre_get_posts

If a change does not show up, or shows up in the wrong place:

  • Check the guard clauses first. A missing is_main_query() check is the usual reason a widget suddenly shows twelve events.
  • Install Query Monitor locally. It lists every query on the page, which one is the main query, and the SQL each produced.
  • Make sure your callback is registered early. pre_get_posts fires before the template loads, so registering the action from inside a template file is too late.
  • After changing rewrite-related settings such as a post type's archive slug, flush permalinks by saving Settings > Permalinks.

Be careful running another query inside a pre_get_posts callback. get_posts() and new WP_Query() both fire pre_get_posts again. The is_main_query() guard stops your callback from acting on that inner query; without it you can recurse until PHP runs out of memory. Where you can, use a cheaper lookup such as get_term_by(), as in the category recipe above.

Summary

  • pre_get_posts lets you change the main query before it runs, so there is one query and pagination stays correct.
  • The hook fires for every WP_Query; guard with is_admin(), $query->is_main_query() and a conditional on $query.
  • Change variables with $query->set() and merge with existing meta or tax queries rather than overwriting them.
  • Never use query_posts(); use pre_get_posts for the main list and a separate WP_Query for extra lists.
  • Block themes' Query Loop blocks that inherit the template query are affected by pre_get_posts in the same way.

Further reading