Modify the WordPress Main Query with pre_get_posts: 5 Common Patterns

WordPress builds the main query — the one that populates the Loop on archive pages, home pages, search results, and taxonomy archives — before the template file loads. By the time your theme’s archive.php runs, the query is already constructed and executed. pre_get_posts is the action hook that fires after the query object is created but before the database query runs, giving you a chance to modify any query parameter: posts per page, post type, order, meta and tax queries, date ranges, and more. It is the correct tool for changing how WordPress queries posts globally, without replacing the main query with a custom WP_Query instance. The two most important rules are: always check $query->is_main_query() to avoid modifying widget queries, REST API queries, and other secondary queries running on the same request; and always check ! is_admin() to avoid breaking admin list tables. This article covers the five most common real-world use cases.

Problem: You need to change how WordPress queries posts on archive, category, tag, or home pages — showing more posts, filtering by a custom field, or excluding a category — without replacing the main query with a custom WP_Query call.

Solution: Hook into pre_get_posts, check that it is the main query and not the admin, and call $query->set() to modify any query parameter before it executes.

<?php
add_action( 'pre_get_posts', 'modify_main_query' );

function modify_main_query( $query ) {
    // Guard: only modify the main query on the front end
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    // 1. Show 12 posts per page on all archive pages
    if ( $query->is_archive() || $query->is_home() ) {
        $query->set( 'posts_per_page', 12 );
    }

    // 2. Exclude a specific category from the blog homepage
    if ( $query->is_home() ) {
        $query->set( 'category__not_in', [ get_cat_ID( 'news' ) ] );
    }

    // 3. Include custom post types in tag archive pages
    if ( $query->is_tag() ) {
        $query->set( 'post_type', [ 'post', 'portfolio', 'resource' ] );
    }

    // 4. Order a CPT archive by a custom field (ACF numeric field)
    if ( $query->is_post_type_archive( 'event' ) ) {
        $query->set( 'orderby',  'meta_value_num' );
        $query->set( 'meta_key', 'event_date_timestamp' );
        $query->set( 'order',    'ASC' );
    }

    // 5. Filter a CPT archive by a taxonomy term from a URL query var
    if ( $query->is_post_type_archive( 'product' ) ) {
        $category = sanitize_key( get_query_var( 'product_cat', '' ) );
        if ( $category ) {
            $query->set( 'tax_query', [ [
                'taxonomy' => 'product_cat',
                'field'    => 'slug',
                'terms'    => $category,
            ] ] );
        }
    }
}

NOTE: pre_get_posts modifies the query object by reference — you do not need to return anything from the callback. Also, $query->set() called inside pre_get_posts affects only that specific query instance. Calling it outside of the hook (e.g., directly on the global $wp_query) after the query has already run has no effect on the results already fetched.