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

Bash Arrays: Preserving Arguments, Sparse Indices, and Associative Keys

Use Bash indexed and associative arrays without losing spaces, confusing keys with positions, or relying on unsupported shell versions.

Bash arrays are containers for shell words, not serialized text lists. An indexed array associates values with integer subscripts; an associative array maps string keys to values. The most important operational rule is to expand an array as separate quoted arguments when that is what the called program needs. Joining an array into one string and later splitting it again loses the original boundaries around spaces, empty strings, and wildcard characters.

Array syntax is Bash-specific, not POSIX sh syntax. Associative arrays require Bash 4 or newer. Apple’s maintained Bash 3.2 source line, and /bin/bash on the macOS host used for this review (3.2.57), do not support declare -A; check the actual interpreter instead of inferring its features from an interactive shell or OS marketing version. A script that relies on arrays should name Bash in its shebang and verify the interpreter version it actually runs under, especially in cron, CI, and macOS automation.

Indexed arrays are sparse

An indexed array starts at zero but does not need contiguous indices. Assigning elements at indices 2 and 7 creates two members, not an array with five meaningful gaps. The element count is not the same as the highest index plus one. Iterating by the array’s actual index list preserves sparse positions.

declare -a targets=()
targets[2]="build host"
targets[7]="database"

# Each member remains one argument, even when it contains a space.
printf '<%s>\n' "${targets[@]}"

# Iterate over assigned indices when the index itself matters.
for index in "${!targets[@]}"; do
    printf '%s=%s\n' "$index" "${targets[$index]}"
done

printf 'members=%s\n' "${#targets[@]}"

Within double quotes, the quoted array-at expansion produces each member as its own word. The quoted array-star expansion instead joins the members into one word using the first character of IFS. That difference is why the array-at form is usually right when forwarding arguments to another command, while the array-star form is for the uncommon case where a deliberate join is required.

When removing an indexed element, quote the array reference so its brackets are not treated as a pathname pattern by the shell:

unset 'targets[2]'

Bash releases that support negative indexed subscripts interpret them relative to one greater than the highest assigned index. This can be convenient for the last populated position, but it is not a portable POSIX-shell feature and should be version-tested in scripts that must run on older Bash.

Associative arrays map names to values

Use declare -A for a map whose keys are strings. It is a good fit for a small lookup table, named status, or configuration parsed into known fields. Keys may contain spaces when quoted carefully, but empty associative keys are not valid. Iteration order is not a contract; sort keys explicitly if output must be stable.

declare -A state=()
state[api]="healthy"
state[database]="waiting for recovery"

for name in "${!state[@]}"; do
    printf '%s=%s\n' "$name" "${state[$name]}"
done

Do not confuse a map with a stable external data format. Bash’s declare output is shell syntax, and evaluating generated text with eval can turn data into code. For data crossing a trust boundary, use a real parser for a defined format and validate keys and values before using them in paths or commands.

Preserve the boundary between values and commands

Array expansion can protect argument boundaries, but it does not make every use safe. A value can still be an option interpreted by the receiving program, a path with special meaning, or a command name resolved through PATH. Use an explicit – where the target supports it, validate paths against the intended root, and select commands from a trusted environment.

Avoid unquoted scalar expansion when traversing an array or building a command. The safe pattern is to keep arguments as separate elements and pass them directly:

args=(--output "$output_path" --label "$label")
tool "${args[@]}" -- "$input_path"

This preserves spaces and empty arguments. It does not neutralize every option or path semantic; validation and the called program’s interface still matter. In particular, never use eval to reconstruct a command from array contents. Arrays already are the argument-vector representation Bash needs.

Associative-array subscripts are also subject to shell expansion rules. Treat externally supplied keys as data, keep them out of dynamically evaluated shell fragments, and test lookups on the exact Bash versions the program supports. For simple membership checks, a separate validated key list or a parser with a structured map type may be easier to audit than clever indirect expansions.

Scope and function boundaries

Use local -a or local -A inside a function when the collection is temporary. A local array avoids leaking internal state into the caller, but returning arrays requires an explicit interface: write a result to a nameref when the supported Bash version is known, emit NUL-delimited fields for a carefully designed protocol, or use a file or structured-data tool for larger results. Command substitution strips trailing newlines and creates a subshell context, so it is not a transparent way to return arbitrary array contents.

For scripts that must work in the macOS system Bash 3.2 or another older interpreter, avoid associative arrays and newer features or declare an explicit minimum Bash version and install/use that interpreter. Do not assume that the Bash found interactively is the one selected by launchd, cron, an IDE, or a CI worker; log Bash’s version and path during startup.

Test boundary cases

Test empty arrays, empty members, embedded spaces, wildcard characters, sparse indices, keys containing punctuation, a Bash version below the declared minimum, and an argument beginning with a hyphen. Compare the exact argument vector received by a test helper instead of visually inspecting a joined echo string. A successful printout does not prove that word boundaries were preserved.

Used carefully, Bash arrays prevent many quoting mistakes by keeping arguments structured. Their limits are equally important: arrays are not POSIX, map iteration is not ordered, and expansion does not replace input validation. Keep values in arrays until the final command boundary and pass them as quoted elements.

Related:

Sources:

Comments