Practical technical guides / Reliability and configuration

Empty Versus Unset: VAR:-default and VAR-default Across Shells, Compose and Config Loaders

The colon decides whether an empty variable counts as missing. Exact rules for :- and - across POSIX sh, Docker Compose, Kubernetes and Python, with a loader you can test.

By Sandeep Belgavi · Published 2026-11-12

The colon decides whether an empty variable counts as missing. Exact rules for :- and - across POSIX sh, Docker Compose, Kubernetes and Python, with a loader you can test.

The empty value that won

A CI job for the catalog service at Belgavi's Book Store started calling the wrong API. The configuration file said api_base: ${API_BASE:-https://catalog.example.test}, the runner's environment had no catalog URL configured, and nobody expected anything but the default. The job's environment did contain API_BASE, though, defined as an empty string by a shared .env template with the line API_BASE=. The configuration loader, an in-house helper, saw that the variable existed and returned its value: nothing. The client then built requests against a relative URL and failed in a confusing way three layers down.

Every part of this was reasonable on its own. The person who wrote the template wanted to document the variable. The person who wrote the configuration used the most common default syntax there is. The person who wrote the loader implemented "use the variable if it is set, otherwise the default". The problem is that ${VAR:-default} does not mean that, and two closely related operators, with and without the colon, mean different things that many loaders blur.

The same gap was fixed in Gemini CLI, merged on 11 November 2026 (UTC): pull request 30250, "apply default in ${VAR:-default} when variable is set but empty", changes its settings resolver so an empty value falls back to the default, matching shell semantics. It was merged after the v0.69.0-preview.0 release and was not in a release when this guide was written. The pull request describes the same trigger: empty variables are common in CI runners and in .env files with lines such as API_BASE=. This guide sets out the exact rules, compares where they hold, and shows how to test your own loader.

Unset and empty are different states

A variable can be in three states: unset (not in the environment at all), set to the empty string, or set to a non-empty value. Shell parameter expansion distinguishes them, and the colon is what decides whether empty counts as missing.

The POSIX Shell Command Language defines ${parameter:-word} as: if the parameter is unset or null, substitute the expansion of word; otherwise substitute the value. Without the colon, ${parameter-word} substitutes word only when the parameter is unset; a parameter set to the empty string expands to the empty string. The same pairing applies to the other operators: :? and ? for required values, :+ and + for alternatives, and := and = for assigning a default.

The companion's table, checked against GNU bash 5.2 run as sh, gives:

state   ${API_BASE:-D}   ${API_BASE-D}   ${API_BASE:+S}   ${API_BASE+S}
unset   D                D               (empty)          (empty)
empty   D                (empty)         (empty)          S
value   value            value           S                S

Read the empty row twice. With the colon, empty behaves like unset. Without it, empty is a real value that wins over the default, and the + form even reports the variable as present.

Docker Compose follows the same rules

Docker Compose's interpolation documentation lists the same forms with the same meanings: ${VAR:-default} uses the value if set and non-empty, otherwise the default; ${VAR-default} uses the value if set, otherwise the default; ${VAR:?error} and ${VAR?error} exit with an error under the matching conditions; ${VAR:+replacement} and ${VAR+replacement} substitute the replacement under the matching conditions. It also documents two behaviours that catch people out: when a variable cannot be resolved and has no default, Compose warns and substitutes an empty string, and $$ produces a literal dollar sign. Other shell features, such as pattern substitution, are not supported.

So a Compose file and a shell script interpret ${API_BASE:-...} the same way. A custom configuration loader that copies the syntax but not the semantics is the odd one out, and that is where most incidents of this kind start.

Not every system has defaults at all

Some places that look like they interpolate do not offer these operators. Kubernetes expands $(VAR_NAME) references in a container's env, command and args from variables defined earlier in the same list; its documentation states that an undefined reference is left unchanged as a literal string, and that $$ escapes it. There is no default operator, so $(API_BASE:-x) is not a default; it is text.

Application code has its own versions of the two semantics. In Python, os.environ.get("API_BASE", default) returns the empty string when the variable is set but empty, which is the colon-less - behaviour. os.environ.get("API_BASE") or default falls back on empty, which is the :- behaviour. Both idioms are common, often in the same codebase, and they disagree exactly in the case the CI incident hit.

Writing a loader that matches the shell

If you write or maintain a loader that accepts shell-style placeholders, implement the six operators with the three states explicitly. The companion's version is a single regular expression and a small function:

import re

PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?)([-?+])([^}]*))?\}")

def expand(text, env):
    def sub(m):
        name, colon, op, word = m.groups()
        is_set = name in env
        value = env.get(name, "")
        usable = is_set and (value != "" if colon else True)
        if op is None:
            return value
        if op == "-":
            return value if usable else word
        if op == "?":
            if usable:
                return value
            raise KeyError(f"{name}: {word or 'parameter null or not set'}")
        return word if usable else ""          # op == "+"
    return PATTERN.sub(sub, text)

The bug in the CI incident, and the one fixed in Gemini CLI, is the version where usable is simply is_set, ignoring the colon. The companion keeps that variant behind a flag so the tests can show the difference: with an empty API_BASE, ${API_BASE:-D} gives D under the shell rules and the empty string under the variant.

This sketch does not handle nested defaults such as ${A:-${B}}, which Compose supports, or the = assignment operators. If you need nesting, parse rather than rely on one regular expression, and add the nested cases to the tests.

Testing a loader against the real shell

The strongest test for a shell-compatible loader is the shell itself. The companion runs sh -c 'printf %s "<form>"' for each operator in each of the three states and compares the output with its own expansion. Twelve combinations cover the table. Add the cases your configuration actually uses: a URL with a colon in the default, a default containing a space, and a required variable whose error message you rely on in logs.

For loaders that are not meant to be shell-compatible, the test is different: document which semantics you chose, and make the syntax say it. A loader that only supports "set means use it" should reject :- with an error rather than silently treat it as -.

Choosing semantics on purpose

Most configuration wants :-. An empty value almost never means "use nothing" for a URL, a port or a hostname; it means someone left a placeholder. Use :? for values that must be present, so an empty secret stops the process at start-up with a clear message instead of failing later. Reserve the colon-less forms for the rare variable where empty is a legitimate setting, such as an optional prefix, and comment why.

Then check the sources that produce empty values. .env templates with NAME= lines, CI systems that define a variable for every declared secret whether or not it has a value, and container platforms that pass through empty strings all turn "unset" into "empty" without anyone deciding to. Either delete such lines or make every consumer treat empty as missing.

Checklist

  • Search configuration for ${ and classify each use as :-, -, :?, ?, :+ or +.
  • Confirm each loader in the path (shell, Compose, your application's loader, any templating step) implements the colon the same way.
  • Add a test with the variable unset, empty and set, for every default you depend on.
  • Replace os.environ.get(name, default) with an explicit helper where empty should mean missing.
  • Remove empty assignments from shared templates, or document them as intentional.

Run the example yourself

The six-operator expander, the colon-ignoring variant, and a test that cross-checks every form against sh when it is on the PATH. Companion code on GitHub; its README lists the prerequisites, exact commands, expected output and limits.

Primary references and authorship

Sources read 2026-11-12. Original explanation and synthetic examples by Sandeep Belgavi. Views are my own and do not represent any employer, past or present.