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.

Syntaxget_query_var( string $query_var, mixed $default_value = '' ): mixed

A 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

Example · php
<?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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.