Skip to content
Shell & TerminalDeep Dive Published Updated 7 min readViews unavailable

Bash nounset: What set -u Detects, and What It Cannot Prove

Use Bash nounset deliberately by distinguishing unset from empty values, applying safe parameter defaults, and testing arrays, positional parameters, and functions.

set -u, also called nounset, asks Bash to treat many expansions of unset variables as errors. It can expose misspelled names and missing required configuration early, but it does not make a shell script fully typed, validated, or safe. Empty strings are not the same as unset variables; special parameters and arrays have their own rules; and commands can still fail for reasons unrelated to variable expansion. Use nounset as one diagnostic policy inside a script with explicit input validation and deliberate defaults.

This behavior is Bash-specific in its exact details. A script that uses Bash arrays, [[ ... ]], or Bash parameter transformations should select Bash in its shebang and document the minimum version it supports. Running it with /bin/sh changes both syntax and error semantics. Test against the oldest supported Bash, not just the interactive shell on the developer’s machine.

Unset and empty are different states

Without nounset, expanding an unset variable usually produces an empty string. With nounset, an ordinary unset parameter expansion is an error in a non-interactive shell. A variable that is set to an empty string is still set, so nounset does not reject it. If a value is mandatory and must also be non-empty, check both conditions explicitly.

#!/usr/bin/env bash
set -u

: "${INPUT_PATH?INPUT_PATH must be set}"
[[ -n $INPUT_PATH ]] || {
    printf 'INPUT_PATH must not be empty\n' >&2
    exit 2
}

The ${parameter:?word} form is a useful required-input assertion: it produces a diagnostic and a nonzero shell result if the parameter is unset or null. In a reusable function, decide whether that should terminate the current script or whether the function should return an error to its caller. Required-parameter checks belong near the boundary where data enters the program, so a missing value does not fail much later in an unrelated helper.

For optional settings, use parameter expansion rather than temporarily disabling nounset:

cache_dir=${CACHE_DIR:-"$HOME/.cache/example"}
prefix=${OPTIONAL_PREFIX-}

${parameter-default} selects the default only when the parameter is unset; ${parameter:-default} selects it when unset or empty. Choose based on whether an explicitly empty value is meaningful. For example, an empty prefix might mean “do not prepend anything,” while an unset prefix could mean “use the product default.” Replacing every expansion with :- can silently erase that distinction.

Positional parameters and arrays need deliberate handling

The special parameters $@ and $* have exceptions under nounset, but that does not mean every array expression is automatically safe. Indexed arrays may be sparse, associative arrays may lack a requested key, and empty arrays interact with the supported Bash version. Distinguish “the collection has no members,” “the element is absent,” and “the element exists with an empty value.” Do not turn a missing-element error into a fake empty record unless the data model permits it.

Use an existence test appropriate to the array type before reading a possibly absent key. For indexed arrays, prefer iterating over assigned indices when sparsity is possible. For associative arrays, use ${array[$key]+_} to ask whether a key is set, then read it separately. Quote the expansion to preserve one argument even when a stored value contains spaces or wildcard characters.

declare -A labels=()
key=${1-}

if [[ -n $key && ${labels[$key]+present} ]]; then
    printf 'label=%s\n' "${labels[$key]}"
else
    printf 'no label recorded for key\n' >&2
fi

Associative arrays were added in Bash 4.0, so this example requires Bash 4.0 or later; older Bash interpreters do not support declare -A. It also requires a non-empty key. If the application intentionally uses the empty string as a valid associative key, define and test that case against the target Bash version rather than relying on this sample unchanged. When reading caller-provided values, validate key shape and array membership as domain logic; nounset only detects an unset expansion, not a semantically invalid key.

Positional parameters also need safe boundaries. A function called without an argument has no $1; write local option=${1-} rather than reading $1 unconditionally. If the function requires exactly one argument, validate $# and return a documented error instead. For forwarding, use "$@" to preserve argument boundaries. nounset cannot detect that an argument shifted into the wrong position after a caller bug.

Functions, local variables, and indirect errors

nounset applies in functions as well as at top level. A function can accidentally read a variable that a caller happened to define, making a test pass locally but fail in a clean process. Initialize function-owned variables explicitly and use local for local state. Bash’s dynamic scoping means a called helper may see a caller’s local variable; this behavior is separate from nounset and should not be mistaken for a globally defined configuration value.

Command substitutions and arithmetic expansions can also contain unset variable references. A script may exit while constructing an argument list before the command itself runs. Add a clear assertion before complex expansion so the diagnostic names the missing setting. Avoid a large expression where several potentially unset values are expanded at once; otherwise, the first failing expansion can hide the missing precondition that would be most useful to the operator.

set -u does not validate environment values. An exported variable might exist but contain whitespace, an unexpected path, an invalid integer, or an unsupported mode. Parse values at the boundary, constrain accepted forms, and check command success separately. The discipline is: presence checks for unset state, format checks for representation, and domain checks for meaning.

Scope nounset without leaking shell options

Library functions sometimes need to tolerate an intentionally absent variable. Instead of flipping the global shell option for the whole script, Bash 4.4 and later can use the local - mechanism to save and restore shell options inside a function; older Bash releases do not support it. Prefer a safe parameter expansion when possible. Global set +u changes can leak into later callers and make a module behave differently depending on call order.

first_argument_or_empty() {
    local value=${1-}
    printf '%s\n' "$value"
}

The example avoids changing shell options entirely. This is easier to reason about than disabling nounset around a block and attempting to restore the previous state in every return path. If a compatibility wrapper must change shell options, capture and restore the state even when the function exits early, and test nested calls.

Interactive shells have distinct behavior from non-interactive scripts. A developer may see an error message but continue typing commands, while a script can exit at the failing expansion. Do not infer script resilience from interactive experimentation. Run the exact script in a fresh non-interactive process with a minimal environment and the intended interpreter.

Failure handling and diagnostics

An unset expansion can occur during assignment, command argument construction, arithmetic, or a [[ ... ]] test. The error location may point to a function call rather than the original missing configuration source. Add validation with a named diagnostic at boundaries and preserve useful exit codes. If a script has an ERR trap, remember that trap behavior depends on control-flow context and is not a general exception handler; nounset does not change those semantics.

Do not use nounset as a reason to hide every failure behind || true. That converts a useful failure into an unobservable state. If a missing value is expected, handle that exact case with parameter expansion. If it is not expected, fail with the owning component and key name, without printing secret values.

Test matrix

Test each configuration variable in four states where applicable: unset, set to empty, set to a valid value, and set to an invalid value. Add tests for empty and missing positional arguments, empty and sparse arrays, keys with spaces, nested helper calls, and environment values from a clean process. Verify both output and exit status. Include the oldest Bash version supported by the project, because array and parameter behavior can differ across releases.

nounset is most useful when it reinforces explicit data contracts. Use it to detect accidental reads, but express optional defaults and required values directly, validate semantics separately, and test non-interactive behavior. It is a guardrail, not proof that every variable is initialized correctly.

Related:

Sources:

Comments