Changing WordPress Queries Before They Run
The hook fires for every query on the request, including the ones building the admin menu and the media library. A callback without guards does not customise the blog — it rewrites the whole site.
Every query on the request passes through this hook, and most of them are not the one you mean. pre_get_posts fires before a query runs, giving you the chance to change it. It also fires for the admin post list, the media library, menu lookups and every widget that builds its own query. The two-line guard at the top of the callback is not defensive style — without it the hook does something close to the opposite of what was intended.
The guard
add_action( 'pre_get_posts', 'ti_adjust_main_query' );
function ti_adjust_main_query( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 20 );
}
}
Three things in that snippet are doing specific work:
is_admin()keeps the callback out of the dashboard. Skipping it is how a site ends up showing twenty items per page in the media library and nobody connects the two changes.$query->is_main_query()restricts it to the query WordPress built from the URL. Widgets, related-post blocks and anything else constructing aWP_Queryare secondary, and they fire this hook as well.$query->set(), notquery_posts(). Setting a parameter on the object is the supported route;query_posts()rebuilds the main query from scratch and breaks pagination and conditionals downstream.
Note there is no return value. This is an action and the query object is modified in place, so a callback that carefully builds a new object and returns it changes nothing.
Conditionals belong on the object
is_category() called as a global function answers a question about the current request. $query->is_category() answers it about the query in front of you. Inside this hook the second is what you want, because a secondary query on a category page is not itself a category query.
The distinction bites hardest on the posts index. On a site whose front page lists latest posts, is_home() and is_front_page() are both true. On a site with a static front page, is_home() describes the separate blog page instead. Targeting the wrong one produces a change that works on one site layout and silently does nothing on another.
What you can usefully set
The parameters accepted are the ones WP_Query understands, which is a long list. The ones that come up repeatedly:
| Parameter | Effect |
|---|---|
posts_per_page |
How many items the archive shows |
post_type |
Include custom types in an archive that omits them |
orderby / order |
Change sorting without touching templates |
cat / tag__not_in |
Include or exclude terms |
meta_key / meta_value |
Filter by custom field, at a cost |
Sorting or filtering by meta value is the one to be careful with. It turns a fast query into a join against the meta table, and on a large site the difference is visible in page load, which is the same class of problem covered under speeding up a WordPress site.
Pagination is the usual casualty
Changing posts_per_page on an archive changes how many pages exist, and anything that assumed the old count will now point at pages that are empty. An empty page in the middle of an archive returns a 404, which is the mechanism behind a whole category of reports that look like WordPress 404 errors and are actually a query change nobody linked to them.
Setting posts_per_page to -1 deserves its own warning. It loads every matching post into memory on every request, which is survivable at fifty posts and fatal at five thousand — and the failure mode is a white screen from exhausted memory rather than a slow page.
Where it runs too early
The hook fires before the query executes, which means the results do not exist yet. Code that needs to inspect what was found belongs later, on a hook that runs after the loop is populated. Calling functions that depend on the current post inside this callback returns values from whatever was set previously, which is usually nothing.
It also runs before the template is chosen, so it cannot be used to decide which template file loads. That decision has its own hook, described in template_include.
Verify on four URLs and one admin screen
Load the posts index, a category archive, a single post, and a search results page. Then open the post list in the admin. A callback with correct guards changes exactly the archive you targeted and leaves the other four alone.
If the front end is right and the admin is wrong, the is_admin() guard is missing. If an archive is right but a sidebar widget changed too, the main query guard is missing. Those two symptoms map cleanly onto the two guards, which makes this one of the easier hooks to debug once the mapping is known — unlike a genuine plugin conflict, where the cause is somewhere else entirely.
Frequently asked
- Because the hook runs on admin queries as well as front end ones. Without an is_admin check the same callback sets posts per page for the post list table, the media library and anything else that runs a query.
- On a site whose front page shows latest posts, both are true, which is why the two get confused. is_home targets the posts index wherever it lives; is_front_page targets whatever is set as the front page. Call them on the query object, not as globals.
- A widget running its own WP_Query is a secondary query, and it fires this hook too. The main query guard is what keeps a callback from reaching into every secondary query on the page.