DevTools Hub

Search tools

Search for a developer tool

How Environment Variable Expansion Actually Works

Part of the Environment Toolkit

Write DATABASE_URL=${HOST}:5432 in a .env file, load it with plain dotenv, and DATABASE_URL comes back as the literal string ${HOST}:5432 — dollar sign, braces, and all. Not an error, not a warning, just the wrong value silently flowing into your app. Plain dotenv was never designed to expand variable references; that's a separate job, handled by tools like the dotenv-expand package. Here's exactly what that job involves, and where its rules stop being obvious.

Why the plain format doesn't do this

A .env parser's only contract is KEY=value: split on the first =, strip quotes, maybe truncate at an unquoted #. It has no concept of one line referencing another — every value is parsed in isolation, in whatever order the lines happen to appear. Expansion is a second pass, run only after every key is already known, that walks back over those raw values looking for $ patterns to substitute.

The five forms

Expansion recognizes five reference shapes, each with a different fallback rule:

  • ${VAR} or bare $VAR — substitute the value, or empty string if VAR isn't set anywhere in the file.
  • ${VAR:-default} — substitute default if VAR is unset or set to an empty string.
  • ${VAR-default} — substitute default only if VAR is unset. An empty VAR is still "set" here.
  • ${VAR:?message} — fail if VAR is unset or empty.
  • ${VAR?message} — fail only if VAR is completely unset.

That colon is the whole pattern: adding : to - or ? makes the check also cover empty, not just missing. It's one character, and it's exactly the character most people forget is doing anything.

The gotcha: a variable can be "set" and still empty

This is where the distinction stops being academic. Say HOST is declared but left blank — a common pattern for "override this in production, leave it empty in dev":

HOST=
PORT=${HOST:-5432}
PORT2=${HOST-5432}

HOST=""  (set, but empty)

${HOST:-5432}

unset or empty → use default

→ "5432"

status: defaulted

${HOST-5432}

unset only → use default

→ "" (empty)

status: resolved

one character — the leading : — is the entire difference

PORT gets the default it was obviously meant to get, because :- treats an empty HOST the same as a missing one. PORT2 resolves to an empty string instead, because plain - only asked "does a key named HOST exist in this file" — and it does, it just happens to hold nothing. Swap in ? for a required-value check and the same trap applies: ${API_KEY?msg} happily resolves to an empty string when API_KEY is declared-but-blank; only ${API_KEY:?msg} actually catches it and fails.

References chain across any number of hops

A resolved value can itself be referenced by another line, and expansion follows the chain all the way through before anything is considered final:

BASE_URL=https://api.example.com
API_URL=${BASE_URL}/v1
USERS_ENDPOINT=${API_URL}/users

# resolves to:
# BASE_URL=https://api.example.com
# API_URL=https://api.example.com/v1
# USERS_ENDPOINT=https://api.example.com/v1/users

Order in the file doesn't matter — USERS_ENDPOINT could be declared above API_URL and still resolve correctly, because expansion resolves each reference on demand rather than in a single top-to-bottom pass.

Chains can loop, so they have to be caught

Following references on demand creates an obvious failure mode: what if A references B, and B references A right back?

A=${B}
B=${A}

Resolving A means resolving B first, which means resolving A again — and this second call is the one that has to notice it's already in the middle of resolving A and stop, rather than recursing until the stack overflows. Done right, it reports the exact loop it found: Circular reference: A → B → A, and both variables come back marked as unresolved instead of one silently winning and the other silently failing.

Escaping a literal dollar sign

Since $ now means something, a config value that legitimately needs one has to escape it. Doubling it up does the job: PRICE=$$5.00 resolves to $5.00, with the double $$ collapsing to a single literal one rather than being read as the start of a reference.

Try it yourself

Environment Variable Resolver runs every rule above against a real .env file you paste in — all five reference forms, multi-hop chains, circular-reference detection with the full cycle path, and $$ escaping — and shows each variable's resolved value next to the exact status that produced it. .env Validator covers the other half of the same file: the parsing rules plain dotenv does enforce, like unquoted # truncation and duplicate keys. Both run entirely in your browser.

Related tools