Query Vars
How a URL becomes the array WP_Query runs on, how to register a custom var, and where get_query_var is the wrong tool.
get_query_var( string $query_var, mixed $default_value = '' ): mixedA query var is one key-value pair in the request that WordPress hands to WP_Query. Everything on the front end — a post slug, a category, a page number, a search term — arrives as query vars, whether it came from a pretty URL or a raw query string.
From URL to array
WP::parse_request() matches the path against the rewrite rules, producing a query string such as index.php?post_type=acme_doc&name=install. It parses that, merges in the permitted keys found in $_GET and $_POST, and stores the result in $wp->query_vars. The request filter gets a final look at that array, and then it is handed to the main WP_Query.
The public list
Only names on WP::$public_query_vars survive that trip. Add yours with the query_vars filter, which receives and returns an array of strings. Without it, a rewrite rule pointing at index.php?my_var=… matches the URL and then silently loses my_var — the rule looks broken when the var is the problem. Registering a var also means anyone can set it by appending ?my_var= to any URL on the site, so sanitize it before it reaches a query.
Reading them
get_query_var() reads the global $wp_query and nothing else. That makes it correct in a template and wrong inside pre_get_posts or against a secondary query, where you want $query->get( 'my_var' ) on the object you were handed.
Example
<?php
/**
* Plugin Name: Acme Store Locator
* Adds a 'city' var, reachable as /shops/city/manchester/ or ?city=manchester.
*/
add_filter(
'query_vars',
static function ( array $public_query_vars ) {
// Anything not on this list is stripped from the request before the
// main query is built.
$public_query_vars[] = 'city';
return $public_query_vars;
}
);
add_action(
'init',
static function () {
add_rewrite_rule(
'^shops/city/([^/]+)/?$',
'index.php?post_type=shop&city=$matches[1]',
'top'
);
}
);
add_action(
'pre_get_posts',
static function ( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Inside a hook, read from the object you were given. get_query_var()
// would read the global, which is not settled yet.
$city = $query->get( 'city' );
if ( '' === $city ) {
return;
}
// The value came off a public URL. Treat it as hostile.
$query->set(
'meta_query',
array(
array(
'key' => 'shop_city',
'value' => sanitize_title( $city ),
),
)
);
}
);When to use it
- Making a custom rewrite rule's captured value actually reach WP_Query
- Filtering an archive by a value taken from the URL, such as a city or a version
- Tracking down why a rewrite rule matches but the extra parameter is empty
- Tagging a secondary query so a SQL filter can identify it
More examples
Reading a var in a template
The everyday use: pull the value the URL carried and render with it. It also shows the paged trap — casting with max( 1, ... ) is the habit worth forming, because the $default_value argument will not save you for any key core pre-fills.
<?php
// archive-shop.php
//
// get_query_var() reads the global $wp_query, which by template time is fully
// parsed. This is the one place it is the right tool.
$city = get_query_var( 'city' );
if ( '' !== $city ) {
printf(
'<h1>%s</h1>',
esc_html( sprintf( __( 'Shops in %s', 'acme' ), $city ) )
);
}
// 'paged' is pre-filled with an empty string by WP_Query, so the second
// argument never fires here. Cast and default by hand instead.
$paged = max( 1, (int) get_query_var( 'paged' ) );
while ( have_posts() ) {
the_post();
// ...
}Normalising vars before the query runs
Use this when you want to clean or extend the request itself rather than react to it later. Because posts_per_page is not a public query var, setting it here is safe in a way that exposing it through the query_vars filter would not be.
<?php
// The 'request' filter receives the query vars after the rewrite match and
// after the public-var filtering, but before WP_Query is given them. It is
// the last point at which the request is still just an array.
add_filter(
'request',
static function ( array $query_vars ) {
if ( isset( $query_vars['city'] ) ) {
$query_vars['city'] = sanitize_title( $query_vars['city'] );
}
// Vars set here are not limited to the public list, so this is a way
// to add query arguments a visitor must not be able to set themselves.
if ( isset( $query_vars['post_type'] ) && 'shop' === $query_vars['post_type'] ) {
$query_vars['posts_per_page'] = 50;
$query_vars['orderby'] = 'title';
}
return $query_vars;
}
);A private var, set only by your own code
Shows the distinction the query_vars filter actually draws: it controls what a URL may set, not what WP_Query will carry. Tagging a query with a private var is the clean way for a posts_where or posts_clauses filter to recognise its own query without matching on fragile argument combinations.
<?php
// Not every var needs registering. Vars you only ever set yourself never
// touch the query_vars filter, and so can never be set from a URL.
$featured = new WP_Query(
array(
'post_type' => 'shop',
'posts_per_page' => 3,
'acme_internal' => 'featured', // arbitrary; nothing reads it but us
'no_found_rows' => true,
)
);
add_filter(
'posts_where',
static function ( $where, WP_Query $query ) {
// get() reaches any var on the object, registered or not.
if ( 'featured' !== $query->get( 'acme_internal' ) ) {
return $where;
}
global $wpdb;
return $where . $wpdb->prepare( " AND {$wpdb->posts}.menu_order < %d ", 10 );
},
10,
2
);
Discussion