pre_get_posts

pre_get_posts modifies a query before it runs — the correct way to change what an archive lists.

Syntaxadd_action( 'pre_get_posts', function ( WP_Query $query ) { /* $query->set( 'key', $value ); */ } );

pre_get_posts fires after WordPress has parsed the request into query vars but before the SQL runs. It is the supported place to change what the main query returns: instead of discarding results and querying again, you adjust the arguments and the original query runs once, correctly.

How it works

The hook is fired with do_action_ref_array( 'pre_get_posts', array( &$this ) ), so the WP_Query object arrives by reference and your changes stick without returning anything. Introduced in WordPress 2.0.0. Read and write query vars with the accessors: $query->set( 'posts_per_page', 20 ) and $query->get( 'post_type' ) are the shorthand for writing and reading $query->query_vars['posts_per_page'] directly — the same array, with get() also taking a default value.

The two guards

The hook runs for every query on the request, including menus, widgets and your own secondary queries, and it runs in the admin too. So every callback starts with if ( is_admin() || ! $query->is_main_query() ) { return; }. Use the method $query->is_main_query(), not the global function: the global tests $wp_query, which is not necessarily the object you were handed. The same applies to conditionals — call $query->is_home() and $query->is_post_type_archive( 'book' ) rather than the global tags.

What trips people up

The hook runs before WP_Query is set up, so global conditional tags and template tags are unreliable inside it.

Example

Example · php
<?php
// wp-content/themes/your-theme/functions.php

/**
 * Show 20 books per page on the book archive, ordered by publication year.
 *
 * @param WP_Query $query The query object, passed by reference.
 */
function yourtheme_book_archive_query( WP_Query $query ): void {
	// Guard 1: never touch admin list tables from here.
	// Guard 2: the method, not the global function — the global tests $wp_query.
	if ( is_admin() || ! $query->is_main_query() ) {
		return;
	}

	// Conditionals must be called on $query, not as global template tags:
	// the hook runs before WP_Query is fully set up.
	if ( ! $query->is_post_type_archive( 'book' ) ) {
		return;
	}

	$query->set( 'posts_per_page', 20 );
	$query->set( 'meta_key', 'publication_year' );
	$query->set( 'orderby', 'meta_value_num' );
	$query->set( 'order', 'DESC' );
}
add_action( 'pre_get_posts', 'yourtheme_book_archive_query' );

When to use it

  • Change posts per page on one archive without touching the reading settings
  • Add a custom post type to the blog index and search results
  • Reorder a custom post type archive by a meta field
  • Hide posts from an archive based on the visitor's login state

More examples

set()/get() shorthand and the query_vars longhand

Both forms write the same query_vars array, so you can read either in someone else's plugin. Use set()/get() in your own code — the default-value argument removes the isset() dance, and PHP 8.2's null coalescing on a missing key still leaves you casting the result yourself.

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

		// Shorthand.
		$per_page = $query->get( 'posts_per_page', 10 ); // 10 if unset
		$query->set( 'posts_per_page', 25 );

		// Longhand: the same array the accessors read and write.
		$per_page = $query->query_vars['posts_per_page'] ?? 10;
		$query->query_vars['posts_per_page'] = 25;

		// Prefer the accessors: get() handles the missing-key default,
		// and set() is what every core example and code standard uses.
	}
);

Including a custom post type in the main blog and search results

Registering a post type with 'exclude_from_search' => false only affects search; the blog index still lists 'post' alone. This is the standard fix, and doing it here means pagination, feeds and the found-rows count all stay consistent, which they would not with query_posts().

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

		// The posts page and the search results page.
		if ( $query->is_home() || $query->is_search() ) {
			$query->set( 'post_type', array( 'post', 'case_study' ) );
		}

		// Feed too, but leave the type alone elsewhere.
		if ( $query->is_feed() ) {
			$query->set( 'post_type', array( 'post', 'case_study' ) );
		}
	}
);

Hiding private-to-role content from a public archive

Filtering at query time keeps counts and pagination honest, unlike skipping rows inside the Loop, which leaves gaps and a wrong page count. The trailing comment matters: pre_get_posts controls listings, not direct access, so pair it with a capability check on the singular view.

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

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

		// Logged-out visitors never see resources flagged members_only.
		if ( ! is_user_logged_in() ) {
			$query->set(
				'meta_query',
				array(
					array(
						'key'     => 'members_only',
						'compare' => 'NOT EXISTS',
					),
				)
			);
		}
	}
);

// Note: this filters the listing only. Direct URLs to a single resource
// still resolve, so enforce the same rule in the single template too.

Discussion

  • Be the first to comment on this lesson.