macOS bash: "declare: -A: invalid option" — the script keeps going and keys usually land in slot 0 (bash 3.2)
A small lookup script that uses an associative array, run with the Mac’s own bash:
$ T=$(mktemp -d) && cd "$T"
$ cat > assoc.sh <<'EOF'
#!/usr/bin/env bash
declare -A color
color[apple]=red
color[sky]=blue
echo "apple is ${color[apple]}"
echo "sky is ${color[sky]}"
EOF
$ bash assoc.sh; echo "exit $?"
assoc.sh: line 2: declare: -A: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
apple is blue
sky is blue
exit 0
There is an error message, but the script doesn’t stop there, it prints a
wrong answer (apple is blue), and the exit status is 0. If the output goes
to a log or another program, nothing looks broken.
The examples write small files (assoc.sh, list.txt, …) into the current
folder, so run them in an empty folder like the mktemp -d one above.
Why: /bin/bash on macOS is 3.2
$ bash --version | head -n 1
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
$ which -a bash
/bin/bash
Associative arrays (declare -A) are a bash 4 feature (per the bash
changelog; I had no bash 4 here to run). bash 3.2 doesn’t know the -A option, so declare fails (status 2) and the script continues with
the next line. color[apple]=red is still valid bash 3.2, though: it creates
an ordinary indexed array, and the part in brackets is an arithmetic
expression. apple is the name of an unset variable, which counts as 0.
So both lines write to color[0]:
$ bash -c 'declare -A c; c[apple]=red; c[sky]=blue; declare -p c' 2>/dev/null
declare -a c='([0]="blue")'
A key with a dash is arithmetic too (my - key = 0 − 0):
$ bash -c 'declare -A c; c[my-key]=1; c[other]=2; declare -p c' 2>/dev/null
declare -a c='([0]="2")'
And if a variable with the key’s name happens to hold a number, the value goes to that slot instead:
$ bash -c 'declare -A c; sky=3; c[sky]=blue; declare -p c' 2>/dev/null
declare -a c='([3]="blue")'
Only a key that isn’t a valid expression stops the script:
$ bash -c 'declare -A c; c["light blue"]=1; echo after'; echo "exit $?"
bash: line 0: declare: -A: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
bash: light blue: syntax error in expression (error token is "blue")
exit 1
local -A inside a function behaves the same way:
$ bash -c 'f() { local -A m; m[a]=1; echo "${m[a]}"; }; f; echo "exit $?"'
bash: line 0: local: -A: invalid option
local: usage: local name[=value] ...
1
exit 0
The rest of the newer-bash family
Scripts written for a newer bash often use a few more features that 3.2 doesn’t have. In 3.2:
$ bash -c 'mapfile -t lines < /etc/shells; echo "${#lines[@]} lines"'; echo "exit $?"
bash: mapfile: command not found
0 lines
exit 0
$ bash -c 'readarray -t lines < /etc/shells'; echo "exit $?"
bash: readarray: command not found
exit 127
$ bash -c 'n=Hello; echo "${n,,}"'; echo "exit $?"
bash: ${n,,}: bad substitution
exit 1
$ bash -c 'n=Hello; echo "${n^^}"'; echo "exit $?"
bash: ${n^^}: bad substitution
exit 1
$ bash -c 'a=(x y z); echo "[${a[-1]}]"'; echo "exit $?"
bash: a: bad array subscript
[]
exit 0
$ bash -c 'shopt -s globstar'; echo "exit $?"
bash: line 0: shopt: globstar: invalid shell option name
exit 1
$ bash -c 'echo hi |& cat'; echo "exit $?"
bash: -c: line 0: syntax error near unexpected token `&'
bash: -c: line 0: `echo hi |& cat'
exit 2
$ bash -c 'declare -n r=x'; echo "exit $?"
bash: line 0: declare: -n: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
exit 2
Note the ones that keep going: mapfile is “command not found” and the
script continues with an empty array (0 lines, exit 0), and ${a[-1]}
prints an error and expands to nothing. ${n,,} ends the bash -c here
with exit 1.
bash -n (syntax check only) is no help for the ones I tried (declare -A), because they
are failures at run time, not syntax errors. Only |& is caught:
$ bash -n assoc.sh; echo "exit $?"
exit 0
$ echo 'echo hi |& cat' > pipe.sh; bash -n pipe.sh; echo "exit $?"
pipe.sh: line 1: syntax error near unexpected token `&'
pipe.sh: line 1: `echo hi |& cat'
exit 2
set -e does stop at the failing declare (status 2) or at mapfile
(127), so a script with set -e fails loudly instead of printing a wrong
answer:
$ bash -c 'set -e; declare -A c; echo after'; echo "exit $?"
bash: line 0: declare: -A: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
exit 2
$ bash -c 'set -e; mapfile -t l < /etc/shells; echo after'; echo "exit $?"
bash: mapfile: command not found
exit 127
Fix 1: stop at the top if bash is too old
If the script really needs a newer bash, say so on the first lines, before
anything else runs. The check below asks for 4, enough for declare -A,
mapfile and ${x,,}; the bash NEWS file lists declare -n and negative
subscripts as later 4.x additions, so raise the number if you use those
(not checked here):
$ cat > needs4.sh <<'EOF'
#!/usr/bin/env bash
if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
echo "${0##*/}: needs bash 4 or newer, this is ${BASH_VERSION:-not bash}" >&2
exit 1
fi
declare -A color=([apple]=red [sky]=blue)
echo "apple is ${color[apple]}"
EOF
$ bash needs4.sh; echo "exit $?"
needs4.sh: needs bash 4 or newer, this is 3.2.57(1)-release
exit 1
$ sh needs4.sh; echo "exit $?"
needs4.sh: needs bash 4 or newer, this is 3.2.57(1)-release
exit 1
$ zsh -f needs4.sh; echo "exit $?"
needs4.sh: needs bash 4 or newer, this is not bash
exit 1
(sh on macOS is bash 3.2 too. zsh has no BASH_VERSINFO, so the first
test catches it.) The check uses only [, so it works in all three.
#!/usr/bin/env bash runs whichever bash comes first in PATH. On this
Mac that is only /bin/bash. If you install a newer bash elsewhere, a job
started with a short PATH (launchd, cron, another script) can still get
/bin/bash; the check turns that into a clear exit 1. I only ran it on
3.2 here; I didn’t have bash 4 or 5 installed to run the passing side.
Fix 2: write it for bash 3.2
For a short script it’s often simpler to not need bash 4. This one runs on
macOS /bin/bash and covers the lookup, reading lines into an array, case
conversion and the last element:
$ printf 'one\ntwo\nthree' > list.txt
$ cat > compat.sh <<'EOF'
#!/bin/bash
# Runs on macOS /bin/bash 3.2: no declare -A, mapfile, ${x,,} or ${a[-1]}.
color_of() {
case $1 in
apple) echo red ;;
sky) echo blue ;;
*) return 1 ;;
esac
}
lines=()
while IFS= read -r line || [ -n "$line" ]; do
lines+=("$line")
done < "$1"
echo "apple is $(color_of apple)"
echo "grass is $(color_of grass || echo unknown)"
echo "${#lines[@]} lines, last: ${lines[@]: -1}"
name=Hello
echo "lower: $(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"
echo "upper: $(printf '%s' "$name" | tr '[:lower:]' '[:upper:]')"
EOF
$ bash compat.sh list.txt; echo "exit $?"
apple is red
grass is unknown
3 lines, last: three
lower: hello
upper: HELLO
exit 0
Details that matter:
-
|| [ -n "$line" ]keeps a last line that has no newline at the end (list.txtabove has none). Without it,readfails on that line and the loop drops it:$ bash -c 'n=0; while IFS= read -r l; do n=$((n+1)); done < list.txt; echo "$n lines"' 2 lines -
${a[@]: -1}(with the space) is the last element in 3.2. Without the space,:-1is the “default value” expansion and you get the whole array.${a[${#a[@]}-1]}looks like it works too, but it’s wrong for an array with gaps:$ bash -c 'a=(x y z); echo "${a[@]: -1}"; echo "${a[@]:-1}"' z x y z $ bash -c 'a=([0]=x [5]=z); echo "[${a[${#a[@]}-1]}]"; echo "${a[@]: -1}"' [] z -
tr '[:upper:]' '[:lower:]'follows the locale. With a UTF-8 localeÄbecomesä; underLC_ALL=Cit is left alone:$ printf 'ÄÖ Straße\n' | LC_ALL=en_US.UTF-8 tr '[:upper:]' '[:lower:]' äö straße $ printf 'ÄÖ Straße\n' | LC_ALL=C tr '[:upper:]' '[:lower:]' ÄÖ straße **(globstar): usefind . -name '*.txt'instead.|&: writecmd 2>&1 | other.-
A
casefunction only works for keys you know in advance. For keys that come from data, a different tool fits better: zsh, which ships with macOS, has associative arrays built in:$ zsh -fc 'typeset -A color; color[apple]=red; color[sky]=blue; echo "apple is ${color[apple]}"' apple is red
Clean up
$ cd ~ && rm -rf "${T:?}"; ls -d "$T"
ls: /var/folders/xx/xxxxxxxx/T/tmp.XXXXXXXXXX: No such file or directory
(The folder name is random; I replaced the random parts with x/X.)
Checklist
declare: -A: invalid optionon a Mac means the script ran under bash 3.2 (bash --version). The script keeps running: the array becomes an indexed array, word keys are arithmetic (usually 0), the last assignment wins, and the exit status can still be 0.mapfile/readarray“command not found”,${x,,}/${x^^}“bad substitution”,${a[-1]}“bad array subscript”,globstar“invalid shell option name”,|&syntax error,declare -ninvalid option: same cause.bash -npasseddeclare -A; only|&was a syntax error (the others were not run through it).set -eturns the failingdeclareormapfileinto a stop (exit 2 / 127) instead of a wrong answer.- If you need bash 4, check
BASH_VERSINFOon the first lines andexit 1. - Or write for 3.2:
caselookup,while IFS= read -r line || [ -n "$line" ],tr,${a[@]: -1},find,2>&1 |— or use zsh’stypeset -A.
Tested on 2026-11-05 on macOS 26.5.2 (build 25F84, Apple silicon) with
/bin/bash 3.2.57 (also as /bin/sh), zsh 5.9, and /usr/bin/tr;
PATH=/usr/bin:/bin:/usr/sbin:/sbin. Not tested: bash 4/5 (Homebrew or
otherwise; the passing side of the version check was not run), Linux,
other locales than C and en_US.UTF-8.