Site icon GetPageSpeed

NGINX Variables: Scope, Handlers, and Common Pitfalls

NGINX Variables Explained: Scope, Handlers, and Common Pitfalls

You reach for an NGINX variable, drop it into a set directive or a log format, reload, and it does something you did not expect. The value is empty when you were sure it had data. A change you made to $args shows up in one place but not another. A map you wrote keeps returning its old answer even though the input clearly changed. None of this is random. NGINX variables follow a small set of precise rules, and once you know those rules the surprises stop.

This guide explains how NGINX variables actually work: where their values live, when they are computed, why some are read-only and some are not, and how they behave across internal redirects and subrequests. Most of the interesting behavior is easiest to see rather than describe, so every section below is a runnable configuration you can reload and hit with curl. Those runnable examples lean on a handful of battle-tested NGINX modules (echo, set-misc, lua, array-var) that GetPageSpeed packages for every major distribution, so you can reproduce everything here in minutes.

Following along? Every example below needs those four modules. Install them now so the snippets work as you read (the full per-distro steps, including the RHEL load order, are in Installing the modules used in this guide at the end):

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-array-var

What an NGINX variable really is

An NGINX variable is not a general-purpose scripting variable. Three properties define it:

  1. It is declared at configuration time. A variable such as $foo has to be introduced by a directive like set, map, or geo, or exported by a module. Reference a variable that nothing declares and NGINX refuses to start:
    nginx: [emerg] unknown "foo" variable
    
  2. It holds a string, and only a string. There are no integers, arrays, or booleans at the config level. Every value is a byte string, even when it looks like a number.
  3. Its value lives in a per-request container. The name is global to the configuration, but the storage is allocated per request. Two requests being served at the same time each have their own copy of $foo; they never see each other’s values.

That third point is the one people trip over. The variable name is defined once, globally, but you should think of the actual value as a slot attached to the request currently in flight. Together, these three rules make NGINX variables far more predictable than they first appear.

Here is the smallest possible demonstration. It uses the echo directive from the NGINX echo module to print a value straight back to the client:

location = /hello {
    set $greeting "hello world";
    echo $greeting;
}
$ curl -s http://localhost/hello
hello world

set declared $greeting and gave it a value; echo read it back. Nothing else was needed.

Built-in variables that compute themselves on demand

NGINX ships with two very different kinds of built-in variable, and the difference explains a lot of confusing behavior.

Some built-ins, like $uri and $request_uri, map to a concrete piece of the request. The distinction between those two catches almost everyone: $uri is the normalized, URL-decoded path with the query string stripped, while $request_uri is the raw request target exactly as the client sent it, query string and all.

location = /show {
    echo "uri:         $uri";
    echo "request_uri: $request_uri";
}
$ curl -s 'http://localhost/show?a=1&b=2'
uri:         /show
request_uri: /show?a=1&b=2

The other kind is a whole family of variables with an infinite set of possible names: $arg_XXX (URL arguments), $http_XXX (request headers), $cookie_XXX (cookies). NGINX obviously cannot pre-allocate a storage slot for every conceivable argument name. Instead, these variables have a get handler: a small function that runs each time the variable is read and computes the value on the spot. Reading $arg_name scans the query string for a name= argument, right then, every time.

location = /whoami {
    echo "name is: $arg_name";
}
$ curl -s 'http://localhost/whoami?name=daniel'
name is: daniel
$ curl -s 'http://localhost/whoami'
name is:

Because the value is computed on read, $arg_name always reflects the current query string. That is also why it is not free: every read re-scans the arguments. If you reference the same $arg_ value many times on a hot path, compute it into a plain variable once with set and reuse that.

Read-only versus writable built-ins

Most built-in variables are read-only. $uri is derived from the request; you cannot assign to it. However, a few are deliberately writable, and $args is the important one. Rewriting $args changes the query string NGINX will forward upstream, and it changes what subsequent $arg_XXX reads return:

location = /rewrite-args {
    set $args "name=overwritten";
    echo "arg_name is now: $arg_name";
}
$ curl -s 'http://localhost/rewrite-args?name=original'
arg_name is now: overwritten

The $arg_name get handler re-scanned $args after we overwrote it, so it returned the new value. This is a genuinely useful lever for normalizing or injecting query parameters before a proxy_pass.

Handlers, caching, and why map behaves the way it does

We have seen get handlers that recompute every time ($arg_XXX). However, some variables cache their value in the per-request container after the first read, so the handler runs once and every later read returns the stored result. The map directive is the classic example, and its caching behavior is worth seeing directly because it explains a whole class of “why didn’t my variable update” bugs.

map defines a new variable whose value is derived from another variable. It lives in the http block:

map $arg_tier $rate {
    default   "free";
    gold      "priority";
    platinum  "priority";
}

server {
    # ...
    location = /tier {
        echo "tier=$arg_tier rate=$rate";
    }
}
$ curl -s 'http://localhost/tier?tier=gold'
tier=gold rate=priority
$ curl -s 'http://localhost/tier?tier=unknown'
tier=unknown rate=free

Two things about map are commonly misunderstood:

Lazy-plus-cached is the right default for map, but knowing it is cached is what saves you when a value looks “stuck.”

The three string states: value, empty, and “not found”

At the config level every variable interpolates to a string, so $arg_name with no name= argument prints as nothing. Internally, however, NGINX distinguishes three states: a real value, the empty string, and a special “not found” / not-set state. In plain config you cannot tell “the argument was present but empty” from “the argument was absent” because both render as an empty string.

To actually observe the difference you need code, and the NGINX Lua module gives you exactly that. In Lua, a missing argument reads as nil, while a present-but-empty argument reads as "":

location = /present {
    content_by_lua_block {
        local v = ngx.var.arg_name
        if v == nil then
            ngx.say("name: NOT PRESENT")
        elseif v == "" then
            ngx.say("name: present but empty")
        else
            ngx.say("name: " .. v)
        end
    }
}
$ curl -s 'http://localhost/present'
name: NOT PRESENT
$ curl -s 'http://localhost/present?name='
name: present but empty
$ curl -s 'http://localhost/present?name=daniel'
name: daniel

That distinction matters whenever “absent” and “empty” should behave differently, for example an API where ?filter= (clear the filter) is not the same request as omitting filter entirely. Plain if ($arg_filter = "") cannot see the difference; Lua can.

Decoding and encoding values with set-misc

A frequent real-world need is to URL-decode a variable, and NGINX has no built-in directive for it. The set-misc module (from the OpenResty ecosystem) fills that gap with directives like set_unescape_uri:

location = /decode {
    set_unescape_uri $clean $arg_path;
    echo "raw:     $arg_path";
    echo "decoded: $clean";
}
$ curl -s 'http://localhost/decode?path=%2Fvar%2Flog%2Fnginx'
raw:     %2Fvar%2Flog%2Fnginx
decoded: /var/log/nginx

set-misc adds a whole toolbox of value manipulation this way: set_escape_uri, set_by_lua, set_quote_sql_str, set_encode_base64, hashing, and more, each one operating on and producing ordinary NGINX variables.

Working with list-valued variables using array-var

Because variables are strings, there is no native way to treat one as a list. The array-var module adds that: it lets you split a delimited string into an internal array variable, transform every element, and join it back into a string, all with plain directives.

location = /tags {
    array_split "," $arg_tags to=$tags;
    array_map "[$array_it]" $tags;
    array_join " " $tags to=$out;
    echo "$out";
}
$ curl -s 'http://localhost/tags?tags=nginx,linux,perf'
[nginx] [linux] [perf]

array_split turned the comma-separated argument into an array, array_map wrapped each element using the $array_it iterator placeholder, and array_join collapsed it back to a string, all in the config, no scripting language required.

Variables across internal redirects and subrequests

Two request-flow situations change how variable values propagate, and both are common sources of confusion.

Internal redirects keep the container. When a request is internally redirected (via rewrite ... last, try_files, error_page, or echo_exec), it stays the same request. The variable container carries over, so a value you set before the redirect is still there afterward. $uri updates to the new URI, but your own variables persist.

Subrequests get their own container. A subrequest (issued by echo_location, auth_request, the SSI include, and similar) is a separate request with a separate variable container. Values you set inside the subrequest do not leak back to the parent:

location = /parent {
    set $note "from-parent";
    echo "before: $note";
    echo_location /child;
    echo "after:  $note";
}

location = /child {
    set $note "from-child";
    echo "child sees: $note";
}
$ curl -s http://localhost/parent
before: from-parent
child sees: from-child
after:  from-parent

The child set $note to from-child, but when control returned to the parent, its container still held from-parent. That isolation is usually what you want. The notable exception is auth_request, which deliberately shares variables in one direction so an auth subrequest can hand values back to the main request; that is a special case, not the general rule.

Common NGINX variables pitfalls, and how to avoid them

A handful of NGINX variables mistakes account for most of the confusion in real configurations:

Installing the modules used in this guide

Every runnable example above depends on one of four NGINX modules that extend what you can do with NGINX variables. GetPageSpeed ships all of them as pre-built dynamic modules, so there is nothing to compile.

RHEL, CentOS, AlmaLinux, Rocky Linux, Amazon Linux

Add the repository once, then install the modules you need:

sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-array-var

On RHEL-based systems, load each module near the top of nginx.conf. The set-misc, lua, and array-var modules build on the NGINX Development Kit, so load ndk_http_module.so first (the nginx-module-ndk package is pulled in automatically as a dependency):

load_module modules/ndk_http_module.so;
load_module modules/ngx_http_echo_module.so;
load_module modules/ngx_http_set_misc_module.so;
load_module modules/ngx_http_lua_module.so;
load_module modules/ngx_http_array_var_module.so;

Debian and Ubuntu

First, set up the GetPageSpeed APT repository, then install:

sudo apt-get update
sudo apt-get install nginx-module-echo nginx-module-set-misc nginx-module-lua nginx-module-array-var

On Debian/Ubuntu, the package handles module loading automatically. No load_module directive is needed.

Module pages with per-distro details:

For more on generating and inspecting values in your config, see our guides to the NGINX echo module and the NGINX var module.

Conclusion

Understanding NGINX variables is one thing; keeping every server’s config correct as it evolves is another. NGINX upgrades, module updates, and everyday edits quietly reintroduce the misconfigurations you just reasoned your way out of. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).

NGINX variables come down to a few firm rules: they are declared at config time, they hold strings, their values live in a per-request container, and each one is computed by a handler that may or may not cache. Built-in families like $arg_XXX recompute on every read; map results are lazy and cached; internal redirects keep the container while subrequests get a fresh one. Once those rules are in view, variables become predictable building blocks rather than a source of surprises, and modules like echo, set-misc, lua, and array-var give you the directives to read, transform, and act on them without leaving your NGINX configuration.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Exit mobile version