Bash scripting guide · part 11 of 32
Associative arrays
Bash's string-keyed maps, declare -A, testing whether a key exists, counting things, loading a config file, and the unordered-keys and missing-declare traps.
An associative array maps string keys to string values, like a dictionary in Python or a hash
in Perl. Bash has had them since 4.0 (macOS's /bin/bash 3.2 does not). They turn lookups
that would otherwise need case statements or indirect variable names into one line.
Declare first
Unlike indexed arrays, an associative array must be declared with declare -A (or local -A
in a function) before you assign to it. Skipping that is the most common mistake, and bash does
not complain: it quietly creates an indexed array instead.
Without declare -A every key lands on index 0
port[web]=80
port[db]=5432
echo "web is ${port[web]}"
declare -p port
web is 5432 declare -a port=([0]="5432")
Without -A, port is an indexed array, and the subscripts web and db are evaluated as
arithmetic: unset variables, so both are index 0. The second assignment overwrote the first and
"web" now reports 5432.
Declare, assign and read
declare -A port=([web]=80 [db]=5432)
port[cache]=6379
port["admin ui"]=8443
echo "web is ${port[web]}, admin ui is ${port[admin ui]}"
echo "${#port[@]} entries"
web is 80, admin ui is 8443 4 entries
Values can be set in the declaration or one at a time. Keys may contain spaces; inside the
brackets, ${port[admin ui]} needs no quotes, though quoting a key held in a variable,
${port["$k"]}, is a good habit.
Keys, values and order
${!map[@]} expands to the keys and ${map[@]} to the values. The order is the hash table's
internal order, not insertion order and not sorted. When order matters, sort the keys yourself.
Loop over keys in sorted order
declare -A owner=([web01]=alice [db01]=chen [cache01]=dana [build01]=gus)
echo "unsorted keys: ${!owner[*]}"
for host in $(printf '%s\n' "${!owner[@]}" | sort); do
printf '%-8s %s\n' "$host" "${owner[$host]}"
done
unsorted keys: build01 db01 web01 cache01 build01 gus cache01 dana db01 chen web01 alice
The keys came back in an order that has nothing to do with how they were written. Piping them
through sort gives a stable order. The unquoted $( ) is acceptable here only because
these keys contain no spaces or glob characters; for arbitrary keys, read the sorted list
with mapfile -t.
Does a key exist?
Looking up a missing key gives an empty string, which is indistinguishable from a key whose
value is empty. [[ -v map[key] ]] tests for the key itself (bash 4.3 and later). ${map[key]-default}
gives a fallback only for a missing key; ${map[key]:-default} also for an empty value.
Test for a key, not just a value
declare -A opts=([verbose]="" [color]=auto)
for k in verbose color debug; do
if [[ -v opts[$k] ]]; then
echo "$k is set to '${opts[$k]}'"
else
echo "$k is missing, default '${opts[$k]-off}'"
fi
done
verbose is set to '' color is set to 'auto' debug is missing, default 'off'
verbose exists with an empty value and is correctly reported as set; a plain
[[ -n ${opts[verbose]} ]] would have said otherwise. debug was never assigned, so the
- default applies.
Counting and grouping
The classic use: tally occurrences of things. Arithmetic on a missing key treats it as 0, so
(( count[$x]++ )) works from the first sighting. (Under set -e, write (( ++count[$x] ))
for the reason explained in Arithmetic.)
Count occurrences of each line
declare -A seen
while IFS= read -r fruit; do
(( ++seen[$fruit] ))
done < data/fruits.txt
for f in "${!seen[@]}"; do echo "${seen[$f]} $f"; done | sort -k1,1nr -k2
3 apple 2 banana 2 cherry 1 date 1 elderberry 1 fig
Each line of data/fruits.txt is a key, and its value counts the occurrences. The final
sort orders by count, highest first, then by name. For a one-off in a terminal,
sort | uniq -c does the same; the array version is for when the counts feed further
logic in the script.
Group log lines by HTTP status
declare -A by_status
while read -r ip _ _ _ _ _ _ _ status _; do
by_status[$status]+="$ip "
done < access.log
for s in $(printf '%s\n' "${!by_status[@]}" | sort); do
ips=(${by_status[$s]})
echo "$s: ${#ips[@]} requests"
done
200: 11 requests 401: 2 requests 403: 2 requests 404: 4 requests 500: 1 requests
read splits each nginx log line on whitespace; the status code is the ninth field, and
_ swallows the fields we do not need. += appends to a string value, building a
space-separated list of client IPs per status, which is then split back into an
array to count it.
Loading a config file into a map
A key = value config like the fixture's data/settings.conf maps naturally onto an
associative array. Skip comments and blank lines, trim the spaces around =, and every setting is
available by name without sourceing the file (which would execute it as code).
Parse key = value lines
declare -A conf
while IFS='=' read -r key value; do
key=${key//[[:space:]]/}
[[ -z $key || $key == \#* ]] && continue
value=${value#"${value%%[![:space:]]*}"}
conf[$key]=$value
done < data/settings.conf
echo "port=${conf[port]} log_file=${conf[log_file]}"
echo "max_connections=${conf[max_connections]:-<not set>}"
echo "${#conf[@]} settings"
port=8080 log_file=/var/log/demo/app.log max_connections=<not set> 5 settings
IFS='=' makes read split at the first =, with the rest of the line in value. The key
loses all whitespace, and the value loses its leading spaces through the nested ${ }
expansion. Commented lines, including the disabled # max_connections, are skipped.
Removing entries and copying
unset 'map[key]' deletes one key (quote it to keep the brackets away from globbing);
unset map deletes everything. There is no direct copy syntax; loop over the keys, or
inspect and recreate with declare -p.
Delete a key and print the whole map
declare -A env_of=([web01]=prod [web02]=prod [build01]=ci)
unset 'env_of[web02]'
declare -p env_of
[[ -v env_of[web02] ]] || echo "web02 removed"
declare -A env_of=([build01]="ci" [web01]="prod" ) web02 removed
declare -p prints the remaining entries in a form you could paste back into a script.
A trailing space before the closing parenthesis is how bash formats it.
Limits
- Values are strings: no nested arrays. Encode structure in keys instead
(
conf[db.host],conf[db.port]), or usejqfor real structured data. - Associative arrays cannot be exported to child processes.
- Bash 5.1 also accepts a flat list,
declare -A m=(k1 v1 k2 v2). Prefer the[k]=vform, which works on 4.x too.
Related
declareCreate variables with attributes (array, associative array, integer, read-only, exported), or show how variables are defined.readRead one line from standard input and split it into shell variables.sortSort lines of text alphabetically, numerically, by version or by chosen fields; also merge and de-duplicate.jqQuery, filter and reshape JSON from the command line with a small expression language.- ArraysIndexed arrays in bash, creating, reading, appending, looping, slicing and removing elements, and using them to build command lines safely.
- Loopsfor over lists, files and ranges, C-style for, while and until, break and continue, retry loops, and redirecting a whole loop's input or output.
- Reading files line by lineThe correct
while IFS= read -r lineloop part by part, the missing last line, the pipe-subshell trap, CSV fields, mapfile and stdin-stealing commands. - ArithmeticInteger math with $(( )) and (( )), the exit status of (( )) that breaks set -e, leading-zero octal surprises, and what to use for decimals.
- Parameter expansionThe ${...} operators for defaults, lengths, substrings, trimming prefixes and suffixes, search and replace, and case conversion, without calling sed or cut.