Grafana dashboard variables let one dashboard select an environment, service, or instance without duplicating every panel. A query variable obtains options from a data source; a custom variable can hold a deliberately fixed list. Neither choice is universally better. Use query-backed options when discovery should follow the source, and a maintained static list when the allowed choices are intentionally stable and small.

A variable is substituted into a query before the data source executes it. That makes its formatting part of query correctness. For Prometheus multi-value selections, use a regular-expression label matcher such as environment=~"$environment". Grafana’s Prometheus interpolation handles selected values as regular-expression alternatives with the required escaping. An exact matcher treats the resulting combined text as one label value and therefore does not implement the intended multi-selection. The variable editor’s label_values helper discovers options; it is not a PromQL function to paste into a panel expression.

Check the resulting expression

There are two relevant syntactic layers: the regular expression and the PromQL string containing it. A literal dot in production.eu must not become a wildcard that also matches productionXeu. Let the data source’s interpolation perform its documented formatting, and inspect the expression actually sent when using an explicit formatter. The raw formatter bypasses that data-source formatting; it is not a universal fix for difficult variable values.

The All option also has semantics. With no custom All value, this example expands the available, filtered options. Its menu includes production.eu and staging, so All covers those two environments. A custom expression such as .* would instead match whatever values the backend regex admits. Decide whether “all” means the menu’s current choices or a broader query, and test that decision with deliberately excluded data.

When it breaks

Query variables are not guaranteed to stay fresh forever. Their refresh setting determines when Grafana updates the option query, and time-dependent options may need refresh on time-range changes. Chained variables can still produce an empty downstream selection when the data contains no matching combination. Distinguish a legitimately empty result from a stale selection or a broken query before presenting “No data” as a service outage.

Variable filters are not authorization controls. A menu that hides an environment does not remove that environment from the data source or stop another permitted query from requesting it. Enforce access at the appropriate Grafana and backend boundaries. Shared dashboard URLs also carry selections rather than permissions; multiple values use repeated var-environment parameters, not an invented comma-separated permission list.

Check it quickly

Save the code as grafana-variables.py. With Python and Docker available, run python3 grafana-variables.py --serve. It provisions Grafana 12.1.0 and Prometheus 3.5.1 in disposable containers on a dedicated bridge network, exposes Grafana only on loopback, and mounts no host directories. Open the printed dashboard URL. Press Control-C afterward to remove both containers and their network. Running without --serve executes backend checks and immediately cleans up.

import json
import secrets
import subprocess
import sys
import time
import uuid
from http.client import HTTPException
from threading import Event
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import urlopen

prefix = "xbcatm-variable-" + uuid.uuid4().hex[:10]
network, prom, grafana = [prefix + suffix for suffix in ("-net", "-prom", "-grafana")]


def command(*args):
    try:
        result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=30)
    except subprocess.TimeoutExpired:
        raise RuntimeError("Docker " + args[0] + " timed out") from None
    if result.returncode:
        raise RuntimeError(result.stderr.strip())
    return result.stdout.strip()


def get(path):
    with urlopen(origin + path, timeout=5) as response:
        return json.load(response)


def eventually(check):
    for attempt in range(200):
        try:
            value = check()
            if value:
                return value
        except (HTTPError, URLError, TimeoutError, ConnectionError, HTTPException):
            pass
        time.sleep(0.1)
    raise AssertionError("Local lab did not become ready")


source = {"type": "prometheus", "uid": "lab_prom"}
dashboard = {
    "uid": "variable-lab", "title": "Variable escaping lab", "schemaVersion": 41,
    "version": 1, "timezone": "utc", "refresh": "", "time": {"from": "now-5m", "to": "now"},
    "templating": {"list": [{
        "name": "environment", "label": "Environment", "type": "query", "datasource": source,
        "query": {"query": "label_values(lab_active_workers, environment)", "refId": "VariableQuery"},
        "definition": "label_values(lab_active_workers, environment)",
        "regex": "/^(production[.]eu|staging)$/", "refresh": 1, "sort": 1,
        "multi": True, "includeAll": True, "allValue": "", "options": [],
        "current": {"text": ["production.eu"], "value": ["production.eu"], "selected": True},
    }]},
    "panels": [{
        "id": number, "title": title, "type": "stat", "datasource": source,
        "gridPos": {"h": 7, "w": 12, "x": (number - 1) * 12, "y": 0},
        "targets": [{"refId": "A", "expr": expression, "instant": True, "range": False}],
        "options": {"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False}},
        "fieldConfig": {"defaults": {"decimals": 0}, "overrides": []},
    } for number, title, expression in (
        (1, "Selected workers", 'sum(lab_active_workers{environment=~"$environment"})'),
        (2, "Unescaped control", 'sum(lab_active_workers{environment=~"${environment:raw}"})'),
    )],
}
prom_script = """set -eu
mkdir -p /tmp/www
cat > /tmp/www/metrics.txt <<'METRICS'
# HELP lab_active_workers Synthetic active worker count.
# TYPE lab_active_workers gauge
lab_active_workers{environment="production.eu"} 10
lab_active_workers{environment="staging"} 2
lab_active_workers{environment="productionXeu"} 100
METRICS
cat > /tmp/prom.yml <<'CONFIG'
global:
  scrape_interval: 1s
scrape_configs:
  - job_name: lab
    metrics_path: /metrics.txt
    static_configs:
      - targets: ['127.0.0.1:8080']
CONFIG
/bin/httpd -f -p 127.0.0.1:8080 -h /tmp/www &
exec /bin/prometheus --config.file=/tmp/prom.yml --storage.tsdb.path=/tmp/data
"""
grafana_script = """set -eu
mkdir -p /tmp/data /tmp/provisioning/datasources /tmp/provisioning/dashboards /tmp/dashboards
mkdir -p /tmp/provisioning/plugins /tmp/provisioning/alerting
cat > /tmp/provisioning/datasources/lab.yml <<'DATASOURCE'
apiVersion: 1
datasources:
  - name: Lab Prometheus
    type: prometheus
    uid: lab_prom
    access: proxy
    url: http://metrics:9090
    isDefault: true
    jsonData:
      timeInterval: 1s
DATASOURCE
cat > /tmp/provisioning/dashboards/lab.yml <<'PROVIDER'
apiVersion: 1
providers:
  - name: lab
    type: file
    options:
      path: /tmp/dashboards
PROVIDER
cat > /tmp/dashboards/lab.json <<'DASHBOARD'
""" + json.dumps(dashboard) + """
DASHBOARD
exec /run.sh
"""
try:
    command("network", "create", network)
    command("run", "-d", "--rm", "--name", prom, "--network", network, "--network-alias", "metrics",
            "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-c", prom_script)
    command("run", "-d", "--rm", "--name", grafana, "--network", network,
            "-p", "127.0.0.1::3000", "-e", "GF_PATHS_DATA=/tmp/data",
            "-e", "GF_PATHS_PROVISIONING=/tmp/provisioning",
            "-e", "GF_AUTH_ANONYMOUS_ENABLED=true", "-e", "GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer",
            "-e", "GF_AUTH_DISABLE_LOGIN_FORM=true", "-e", "GF_AUTH_BASIC_ENABLED=false",
            "-e", "GF_SECURITY_ADMIN_PASSWORD=" + secrets.token_urlsafe(24),
            "-e", "GF_ANALYTICS_REPORTING_ENABLED=false", "-e", "GF_ANALYTICS_CHECK_FOR_UPDATES=false",
            "-e", "GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false", "-e", "GF_PLUGINS_PREINSTALL_DISABLED=true",
            "--entrypoint", "/bin/sh", "grafana/grafana:12.1.0", "-c", grafana_script)
    origin = "http://" + command("port", grafana, "3000/tcp").splitlines()[0]
    health = eventually(lambda: get("/api/health"))
    assert health["version"] == "12.1.0" and health["database"] == "ok"
    loaded = eventually(lambda: get("/api/dashboards/uid/variable-lab"))
    assert loaded["dashboard"]["templating"]["list"][0]["multi"] is True
    checks = {}
    for pattern, expected in ((r"production\\.eu", 10), (r"(production\\.eu|staging)", 12), ("production.eu", 110)):
        expression = 'sum(lab_active_workers{environment=~"' + pattern + '"})'
        result = eventually(lambda: get("/api/datasources/proxy/uid/lab_prom/api/v1/query?query=" +
                                         quote(expression, safe=""))["data"]["result"])
        value = float(result[0]["value"][1])
        assert value == expected, (expression, result)
        checks[expression] = value
    print(json.dumps({"app_url": origin + "/d/variable-lab/variable-escaping-lab",
                      "grafana_version": health["version"], "query_checks": checks}), flush=True)
    if "--serve" in sys.argv:
        Event().wait()
except KeyboardInterrupt:
    pass
finally:
    for name in (grafana, prom):
        subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    subprocess.run(["docker", "network", "rm", network], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

The synthetic series hold ten workers for production.eu, two for staging, and one hundred for productionXeu. With only production.eu selected, “Selected workers” must show ten, while the deliberately unescaped control shows one hundred ten. Select both available options, then All: the correct panel must show twelve in each case. The raw control becomes empty because its comma-joined text does not match the intended alternatives.

These three selections were exercised in Chrome 152 against the real Grafana dashboard, and the outgoing query expressions and response values were checked. Repeated URL parameters produced the same multi-selection. This proves interpolation for the provisioned variable and synthetic data, not tenant isolation or freshness under changing discovery. When adapting the dashboard, keep the excluded environment as a regression case and verify the final expression whenever variable type, formatter, refresh policy, or data source changes.

Sources