skip to content

A CI script that begins `declare -A seen` runs fine on the Linux runners but fails on a developer's Mac with `declare: -A: invalid option`. What is happening, and what are your options for making that script work there?

level: seniorimportance: should knowfreq 42%

answer

  1. macOS bash is stuck at 3.2
  2. associative arrays are a bash 4 feature
  3. the failed declare does not stop the script
  4. guard on BASH_VERSINFO, not $SHELL
  5. or restructure so no map is needed

basics

~20 s

macOS still ships bash 3.2 as /bin/bash, and associative arrays arrived in bash 4.0, so declare -A is simply not a valid option there. Options: run the script under a newer bash the developer installed, guard on BASH_VERSINFO and fail fast, or restructure to avoid the map.

solid answer

~50 s

Associative arrays are a bash 4.0 feature, and Apple's stock `/bin/bash` is still 3.2 for licensing reasons, so `declare -A` is rejected outright. The dangerous part is what happens next: `declare` fails but, without `set -e`, the script keeps going, and the following `seen[key]=1` quietly creates an *indexed* array with arithmetic subscripts, so every key collapses onto element 0 and the run produces wrong results instead of stopping. Three practical options. Run it under a bash 4+ that the developer installed separately, which a `#!/usr/bin/env bash` shebang will pick up from PATH ahead of `/bin/bash`. Add a version guard at the top — `(( BASH_VERSINFO[0] >= 4 ))` or exit with a clear message — so the failure is loud and diagnosable. Or drop the map: two parallel indexed arrays, a `case` statement, or piping through `sort`/`awk` all work on 3.2.

code

bash · 11 lines
bash
#!/usr/bin/env bash
# fail loudly instead of producing a silently wrong run on bash 3.2
if (( BASH_VERSINFO[0] < 4 )); then
  printf 'error: this script needs bash 4+, found %s\n' "$BASH_VERSION" >&2
  exit 1
fi

declare -A seen
seen[build]=ok
seen[test]=ok
printf 'entries: %d\n' "${#seen[@]}"

go deeper

for a junior

Remember that associative arrays need bash 4 and macOS still ships bash 3.2 as /bin/bash, so declare -A fails there and the script needs a newer bash to run.

for a middle

Explain the mechanism: the failed declare returns non-zero but does not stop the script, and the following subscripted assignment creates an indexed array whose keys all evaluate to 0, so the run continues with wrong data.

for a senior

Show the guard you would actually add — BASH_VERSINFO[0] with a clear message and a non-zero exit — and explain why #!/usr/bin/env bash plus a guard beats relying on PATH alone.

for a principal

Decide the script's supported-interpreter contract deliberately: a CI-only script can require bash 4+ and check it, but anything shipped to machines you do not control should target 3.2 or delegate keyed work to a tool, because a silently wrong run is a worse outcome than a refusal.

## Why the Mac is different Bash 4.0 changed licence to GPLv3, and Apple has never shipped a GPLv3 bash. macOS therefore still carries bash 3.2 at `/bin/bash` (zsh has been the default login shell since Catalina, but `/bin/bash` remains, and remains ancient). Associative arrays, `declare -A`, `mapfile`/`readarray`, `${var^^}` case conversion and `wait -n` all arrived in bash 4.x, so every one of them is a cliff at that boundary. This leaf's concern is the first of those; the general bashisms-versus-POSIX story is a topic of its own. ``` $ /bin/bash --version | head -1 GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin...) $ /bin/bash -c 'declare -A m' /bin/bash: declare: -A: invalid option ``` ## The failure is worse than the error message A visible error is the good case. What usually happens is this sequence: ```bash declare -A seen # fails, prints to stderr, returns non-zero seen[build]=ok # creates an INDEXED array, subscript evaluated as arithmetic seen[test]=ok # writes element 0 again (( ${#seen[@]} == 2 )) # false - there is one element ``` Without `set -e`, the failed `declare` does not stop anything; the script continues and every map key lands on index 0. The developer sees one confusing line on stderr followed by output that is merely *wrong* — a deduplication that deduplicates everything, a counter stuck at the last value. That is why the version guard below matters more than the fix itself: you want an unambiguous stop, not a subtly wrong run. ## Option 1: run under a newer bash Developers on macOS usually already have a modern bash from a package manager, installed alongside the system one rather than replacing it (typically under `/opt/homebrew/bin` on Apple Silicon or `/usr/local/bin` on Intel). A shebang of `#!/usr/bin/env bash` resolves `bash` through `PATH`, so it picks up that newer interpreter when the package manager's directory precedes `/bin` — which is the normal arrangement. A hard-coded `#!/bin/bash` pins you to 3.2 no matter what is installed. This is the least invasive fix, but it is a *convention*, not a guarantee: it depends on each machine's PATH. Pair it with option 2 so a mis-ordered PATH is caught rather than silently downgrading. ## Option 2: guard the version and fail loudly ```bash #!/usr/bin/env bash if (( BASH_VERSINFO[0] < 4 )); then printf 'error: this script needs bash 4+, found %s\n' "$BASH_VERSION" >&2 exit 1 fi declare -A seen ``` `BASH_VERSINFO` is an indexed array the shell maintains, element 0 being the major version; `BASH_VERSION` is the full string for the message. Inside `(( ))` the name needs no `$`. Do not test `$SHELL` instead — that is the user's *login* shell from the password database, not the interpreter currently running the script, and it is a classic wrong answer. A guard converts a mystery into a one-line instruction. It costs four lines and it is what a reviewer should ask for in any script that uses a bash 4 feature and might be run outside the CI image. ## Option 3: do not need the map If the script genuinely has to run on stock macOS bash — an installer, a bootstrap script, something you cannot ask people to prepare a shell for — restructure so no map is required. Practical substitutes on 3.2: - **Two parallel indexed arrays**, one of keys and one of values, with a small linear-search helper. Fine for tens of entries, poor for thousands. - **A `case` statement** when the key set is fixed and known at authoring time — often clearer than a map anyway. - **Delegate to a tool.** Deduplication is `sort -u`, counting is `sort | uniq -c`, and keyed lookup is a two-line `awk` program with its own associative arrays. Shelling out to `awk` is frequently both faster and more portable than emulating a hash in bash. The restructure is the honest answer when the script is distributed to machines you do not control. Emulating a hash map with prefixed variable names and `eval` is the fourth option people reach for, and it is the one to argue against: it re-introduces quoting and injection problems to work around a missing data structure. ## Choosing For an internal script that also runs on the CI image, guard plus `env bash` is right. For anything shipped to users' machines, target 3.2 or explicitly declare and check the requirement — the failure mode of the middle path, a wrong-but-silent run, is the outcome to design away.

  • Why is a failed `declare -A` more dangerous than a syntax error would be?
    A syntax error stops the shell before anything runs. A failed `declare` is just a command returning non-zero: without `set -e` the script continues, and the next `seen[k]=v` implicitly creates an indexed array whose subscripts are arithmetic. Every key becomes element 0, so the run finishes successfully with wrong data rather than aborting.
  • Why not just test `$SHELL` to decide whether the shell is new enough?
    `SHELL` holds the user's login shell from their account record, not the interpreter executing the script — it is often stale, and on macOS it usually says zsh while your script runs under bash. Use `BASH_VERSINFO[0]`, which the running shell sets for itself, or `BASH_VERSION` for a human-readable message.
  • If the script must run on stock macOS bash, what replaces the map?
    Two parallel indexed arrays with a small lookup helper, a `case` statement when the keys are fixed, or delegating the keyed work to `awk`, which has its own associative arrays. Avoid emulating a hash with `eval` and prefixed variable names — that trades a missing data structure for quoting and injection problems.

saying these in an interview costs you the question

  • Says macOS just needs bash updated, it is the same version
  • Assumes a failed declare aborts the script
  • Checks $SHELL to detect the running bash version
  • Thinks #!/bin/bash picks up a Homebrew bash
  • Emulates a map with eval and prefixed variable names

context