A bash script that uses `declare -A` and `mapfile` runs fine on your Linux CI runner, but on a colleague's Mac it dies with `declare: -A: invalid option`. Why does the same script behave differently there, and what are your options?
answer
- works on Linux, breaks on a Mac
- the interpreter is old, not wrong
- associative arrays are a 4.0 feature
- macOS is stuck on 3.2.57
- guard with BASH_VERSINFO[0]
basics
~20 smacOS still ships bash 3.2.57 as /bin/bash, and declare -A, mapfile and ${var^^} are all bash 4.0 features. Either guarantee a newer bash on the Mac, or rewrite those constructs so they run on 3.2.
solid answer
~40 smacOS has been frozen on bash 3.2.57 for many years — the usual explanation is that bash 4.0 moved to GPLv3 — while `declare -A` (associative arrays), `mapfile`/`readarray` and `${var^^}` all arrived in bash 4.0, and `wait -n` and `declare -n` in 4.3. A Linux CI runner has bash 5.x, so the script never fails there. My first move is to fail loudly instead of half-running: check `${BASH_VERSINFO[0]}` at the top and exit with a clear message below 4. Then pick one of three fixes: require a newer bash (Homebrew installs bash 5 at /opt/homebrew/bin/bash or /usr/local/bin/bash and developers put it first on PATH), rewrite to bash 3.2 constructs (`while IFS= read -r` instead of `mapfile`, `tr` instead of `${var^^}`), or pin the interpreter by running the script in a container.
code
bash · 10 lines#!/usr/bin/env bash
if (( BASH_VERSINFO[0] < 4 )); then
printf 'error: this script needs bash 4 or newer; found %s\n' "$BASH_VERSION" >&2
printf 'hint: brew install bash, then put it earlier on PATH\n' >&2
exit 1
fi
declare -A counts
counts[ok]=1
printf 'ok=%s\n' "${counts[ok]}"go deeper
Know that bash has versions and that macOS ships a very old one, so a script working on your Linux box is not proof it works everywhere. Be able to run bash --version and read what it says.
Be ready to name which constructs are bash 4+ — associative arrays, mapfile/readarray, ${var^^} — and to write the 3.2-compatible stand-in for each, plus a BASH_VERSINFO guard that itself runs on 3.2.
Show that you make the failure loud and early rather than debugging it on someone else's laptop: a guard with an actionable message, a documented minimum version, and a decision about whether the Mac is even a supported runtime for this script.
Own the policy question: is the dev laptop a supported execution environment at all, or does every script of consequence run in a pinned image? Decide once, so teams stop paying the cross-platform tax script by script.
## Why a Mac has an ancient bash Apple's bundled `/bin/bash` reports version 3.2.57, a release from 2007. The widely-repeated explanation is the licence: bash 4.0 and later are GPLv3, and Apple does not ship GPLv3 software in the base system. Modern macOS also defaults the *interactive* shell to zsh, but that is irrelevant here — `/bin/bash` is still present, still 3.2.57, and still what a bash script gets unless something newer is earlier on `PATH`. So you have a single language with two very different feature sets in the wild: bash 5.x on essentially every Linux distribution and CI image, bash 3.2 on a stock Mac. ## Where the version line falls Added in bash 4.0: - `declare -A` — associative arrays - `mapfile` / `readarray` — read a file into an array in one builtin - `${var^^}`, `${var,,}` — case modification - `shopt -s globstar` — recursive `**` - `coproc` Added in bash 4.3: - `wait -n` — wait for the next job to finish - `declare -n` — namerefs Already present in 3.2, so safe: - indexed arrays and `arr+=(x)` (append arrived in 3.1) - `[[ ]]`, `$( )`, `$'...'`, `local`, process substitution `<( )` - `${var//a/b}` and the rest of the classic parameter-expansion operators ## How the failure actually presents These are mostly *runtime* failures, not syntax errors, which is why the script often does half its work first: ```bash declare -A counts # bash: declare: -A: invalid option (exit status 2) mapfile -t lines < f # bash: mapfile: command not found echo "${name^^}" # bash: ${name^^}: bad substitution ``` Under a strict-mode script the non-zero status ends the run; without it, `declare -A counts` fails and `counts[k]=v` then quietly creates an *indexed* array where `k` evaluates arithmetically to 0, so every key collides on index 0. That silent-wrong-answer mode is the reason to add an explicit guard rather than rely on the error message. ## Fail fast with a version guard The guard itself must be 3.2-legal, so use `BASH_VERSINFO`, which is an indexed array present since bash 2: ```bash if (( BASH_VERSINFO[0] < 4 )); then printf 'error: needs bash 4+, found %s\n' "$BASH_VERSION" >&2 exit 1 fi ``` One honest limitation: bash parses and executes a script command by command, so a guard placed first does catch the *runtime* failures above — but if an incompatible construct is a parse error inside the same compound command or function definition being read, the parse can fail before your check runs. In practice the bash-4 features people trip over are runtime errors, so the guard works. ## The three real fixes **Require bash 4+.** Document it, guard for it, and tell Mac developers to `brew install bash` — Homebrew puts bash 5 at `/opt/homebrew/bin/bash` (Apple Silicon) or `/usr/local/bin/bash` (Intel). With `#!/usr/bin/env bash` and that directory ahead of `/bin` on `PATH`, the script gets the new interpreter. The weakness is that it depends on each developer's `PATH`, and a script launched from a GUI app or a scheduler may not have it. **Rewrite to bash 3.2.** Usually cheap: ```bash lines=() while IFS= read -r line; do lines+=("$line"); done < input.txt # instead of mapfile upper=$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]') # instead of ${name^^} ``` Associative arrays are the painful one; the stand-ins are two parallel indexed arrays, a `case` lookup, or accepting that this script requires bash 4. **Pin the interpreter.** If the script is really infrastructure — a build step, a deploy step — run it inside a container image whose bash version you control, and the developer's laptop stops being a supported runtime at all. ## Why CI never warned you Linux runners ship modern bash, so a Linux-only pipeline cannot catch this class of bug. Either add a macOS leg that runs the script, or treat the version guard as mandatory boilerplate so the failure is a one-line message instead of a mystery.
- What replaces `mapfile -t lines < file` on bash 3.2?A read loop: `lines=(); while IFS= read -r line; do lines+=("$line"); done < file`. `IFS=` stops leading and trailing whitespace being stripped and `-r` stops backslash processing, so each array element is the raw line. Indexed arrays and `+=` both exist in 3.2, so only the builtin was missing.
- Your guard checks `${BASH_VERSINFO[0]}` — is that always enough to catch a bash 4 feature before it breaks?Not always. Bash reads a script command by command, so the guard runs before later commands — which covers runtime failures like `declare -A` or a bad substitution. But a construct that fails at parse time inside the same compound command or function being read can error before the guard executes. In practice the common bash-4 features fail at runtime, so the guard fires.
- If `declare -A counts` fails and the script keeps going, what actually happens to `counts[user]=1`?Bash creates an ordinary indexed array and evaluates the subscript arithmetically. An unset name like `user` evaluates to 0, so every distinct key writes to index 0 and the last write wins. You get no error and a silently wrong result — which is exactly why an explicit version guard beats relying on the failure being visible.
saying these in an interview costs you the question
- Assuming every machine has a recent bash 5
- Thinking /usr/bin/env bash guarantees a modern bash
- Believing arrays in general are unavailable on macOS bash
- Treating the failure as a macOS bug rather than a version gap
- Adding features without documenting the minimum bash version