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 ifVARisn't set anywhere in the file.${VAR:-default}— substitutedefaultifVARis unset or set to an empty string.${VAR-default}— substitutedefaultonly ifVARis unset. An emptyVARis still "set" here.${VAR:?message}— fail ifVARis unset or empty.${VAR?message}— fail only ifVARis 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/usersOrder 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.