The Main Query
$wp_query is what every template tag reads, which is why you change the main query's arguments rather than replace the object.
add_action( 'pre_get_posts', function ( WP_Query $query ) { if ( ! is_admin() && $query->is_main_query() ) { /* ... */ } } );Every front-end request runs exactly one query before a template is chosen. wp() calls WP::main(), which parses the request into query vars and hands them to a WP_Query object stored in two globals: $wp_the_query, which permanently holds the main query, and $wp_query, which is what everything else reads.
Why the globals matter
The Loop functions — have_posts(), the_post(), get_query_var() — and every conditional tag — is_single(), is_archive(), is_404() — are thin wrappers around $wp_query. So is pagination, which reads max_num_pages and found_posts. Replace that object and all of them start describing something else, while the HTTP status and the template already chosen still describe the original request.
Filter, do not replace
That is precisely what query_posts() does, and why it should not appear in new code: it builds a second query and assigns it over $wp_query. To change what the main query fetches, change its arguments before it runs, on pre_get_posts. To fetch something extra alongside it, create a separate WP_Query and call wp_reset_postdata() when you are done.
Identifying it
is_main_query() (WordPress 3.3) compares the current query against $wp_the_query. Inside a hook that hands you a WP_Query object, call $query->is_main_query() on that object instead — the global function reads state that the hook may be running ahead of.
Example
<?php
/**
* Plugin Name: Acme Query Tuning
*/
add_action( 'pre_get_posts', 'acme_tune_main_query' );
function acme_tune_main_query( WP_Query $query ) {
// Two guards, both required. is_admin() keeps this off the admin list
// tables; is_main_query() keeps it off every widget and menu query.
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Call conditionals on the object. The global is_search() reads
// $wp_query, which is not finished being set up at this point.
if ( $query->is_search() ) {
$query->set( 'post_type', array( 'post', 'acme_doc' ) );
$query->set( 'posts_per_page', 20 );
}
if ( $query->is_post_type_archive( 'acme_doc' ) ) {
$query->set( 'orderby', 'menu_order title' );
$query->set( 'order', 'ASC' );
$query->set( 'posts_per_page', -1 ); // small, fixed set
}
// Hide a category from the blog home without touching the archive pages.
if ( $query->is_home() ) {
$query->set( 'category__not_in', array( get_cat_ID( 'asides' ) ) );
}
}When to use it
- Changing posts per page or ordering on one archive without affecting the admin
- Excluding a category from the blog home while keeping it on its own archive
- Replacing inherited query_posts() calls that broke pagination
- Writing a posts_where or posts_clauses filter that only touches the front-end main query
More examples
The two globals, and what query_posts breaks
Worth running mentally before touching any archive: found_posts and max_num_pages are read straight off the global, which is why replacing it produces pagination links to pages that render the wrong thing. If you inherit code calling query_posts(), the fix is almost always to move the arguments into pre_get_posts.
<?php
global $wp_query, $wp_the_query;
// Same object, two handles. $wp_the_query keeps the main query no matter
// what; $wp_query is the one template tags read, and the one query_posts()
// overwrites.
var_dump( $wp_query === $wp_the_query ); // true on an untouched request
echo (int) $wp_query->found_posts; // total rows matched, before paging
echo (int) $wp_query->max_num_pages; // what the pagination functions read
// Wrong. This discards the main query, so is_archive(), the paged links and
// the 404 status that were already decided no longer match what is on screen.
// query_posts( 'post_type=acme_doc&posts_per_page=5' );A second list alongside the main one
The correct shape for a related-posts or sidebar list. The wp_reset_postdata() call is not optional decoration: without it, everything after this block on the page — comments, the edit link, the next/previous links — refers to the last post of the secondary loop.
<?php
// In a template, below the main Loop. This is what to use when you want extra
// posts rather than different ones.
$related = new WP_Query(
array(
'post_type' => 'acme_doc',
'posts_per_page' => 5,
'post__not_in' => array( get_the_ID() ),
'no_found_rows' => true, // skip the row count: no pagination here
'ignore_sticky_posts' => true,
)
);
if ( $related->have_posts() ) {
echo '<ul>';
while ( $related->have_posts() ) {
$related->the_post();
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
}
echo '</ul>';
}
// the_post() overwrote the global $post. This puts it back.
wp_reset_postdata();Guarding a SQL filter
Filters like posts_clauses, posts_where and posts_join run for every query on the page, so an unguarded one silently rewrites the SQL for menus and widgets too. When the object is offered as an argument, calling its is_main_query() method is both cheaper and more accurate than the global.
<?php
// Some filters hand you the WP_Query object as a second argument. Use it
// rather than the global function, and remember to declare the arg count.
add_filter(
'posts_clauses',
static function ( array $clauses, WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {
return $clauses;
}
global $wpdb;
// esc_like() only neutralises the LIKE wildcards % and _; the value
// still has to go through prepare() before it reaches SQL. And
// get_search_query() applies esc_attr() unless you pass false, so the
// unfiltered call would compare post titles against ' entities.
$like = '%' . $wpdb->esc_like( get_search_query( false ) ) . '%';
// Push exact title matches to the top of a search result set.
$clauses['orderby'] = $wpdb->prepare( "{$wpdb->posts}.post_title LIKE %s DESC, ", $like ) . $clauses['orderby'];
return $clauses;
},
10,
2
);
Discussion