opscape

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
output
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"
output
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
output
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
output
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
output
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
output
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"
output
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"
output
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 use jq for 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]=v form, which works on 4.x too.

Related