Why this matters
Indexed arrays answer "give me item number 3". Very often you need "give me the item called web1" instead: the port for a service, the owner of a host, how many times each IP address appeared in a log. That is a lookup table, also called a map, dictionary or hash, and bash (version 4 and later) has one built in: the associative array. With it, counting and grouping tasks that would need awkward temporary files become a few lines of script.
The concept
An associative array maps keys (any strings) to values (strings):
port ─┬─ "http" → "80"
├─ "https" → "443"
└─ "ssh" → "22"
Unlike indexed arrays, associative arrays must be declared before use, with declare -A:
$ declare -A port
$ port[http]=80
$ port[https]=443
$ port[ssh]=22
$ echo "${port[https]}"
443
You can also fill one in a single assignment:
declare -A port=([http]=80 [https]=443 [ssh]=22)
The expansions mirror the indexed ones:
| Expression | Meaning |
|---|---|
"${map[key]}" | the value for key (empty if missing) |
"${!map[@]}" | all keys, each as a separate word |
"${map[@]}" | all values |
${#map[@]} | number of entries |
[[ -v map[key] ]] | true if key exists (even with an empty value) |
unset 'map[key]' | remove an entry |
Keys can contain spaces and punctuation; quote them where bash would otherwise split: map["new york"]=NY or map[$city]=... with a variable (no splitting happens inside the brackets of an assignment, but quoting does no harm).
What happens underneath
bash stores an associative array as a hash table. To store port[https], it computes a number from the characters of https (the hash), uses it to choose a bucket, and puts the pair there. Looking up a key repeats the computation and goes straight to the bucket, so lookups stay fast however many entries there are. Two consequences:
- No order. Iterating with
"${!map[@]}"walks the buckets, so the order looks arbitrary. Always sort when order matters. - Indexes are not arithmetic. In an indexed array,
a[i+1]is evaluated as a sum. In an associative array,m[i+1]is the key with the literal texti+1. That difference is why forgettingdeclare -Ais so treacherous: bash then treats your keys as arithmetic, where an unknown word is a variable that evaluates to 0, so every key lands on index 0.
Like indexed arrays, associative arrays exist only inside the bash process. They cannot be exported to programs you start, and a pipeline stage that modifies one does it in a subshell.
Worked examples
Missing keys and existence. Reading a missing key gives the empty string, silently. To tell "missing" from "set to empty", use -v:
$ declare -A m=([a]=1 [b]=)
$ [[ -v m[b] ]] && echo "b exists"
b exists
$ [[ -v m[c] ]] || echo "no c"
no c
$ echo "c is '${m[c]:-unset}'"
c is 'unset'
Counting. The classic use. Read items, increment a counter per item:
declare -A hits
while read -r ip _; do
hits[$ip]=$(( ${hits[$ip]:-0} + 1 ))
done < access.log
for ip in "${!hits[@]}"; do
printf '%6d %s\n' "${hits[$ip]}" "$ip"
done | sort -rn | head -5
read -r ip _ puts the first field into ip and the rest of the line into the throwaway variable _. ${hits[$ip]:-0} supplies 0 the first time an IP is seen. You could also write (( hits[$ip]++ )), but if the key ever contains characters that mean something in arithmetic (quotes, $, brackets), the (( )) form can misbehave, and under set -e a post-increment from 0 returns status 1 and kills the script. The explicit form avoids both traps.
Compare with the pipeline version from the text module, cut -d' ' -f1 access.log | sort | uniq -c | sort -rn | head -5. For a single count the pipeline is shorter and faster. The array wins when you need several things in one pass: counts and bytes per IP, or a value looked up from one file while reading another.
Joining two files. owners.txt maps host to owner; alerts.txt lists alerts per host:
declare -A owner
while read -r host person; do
owner[$host]=$person
done < owners.txt
while read -r host message; do
echo "${owner[$host]:-nobody}: $host $message"
done < alerts.txt
Each alert is printed with its owner, looked up in constant time.
Splitting key=value lines. read with IFS='=' splits at the first =, and the last variable receives the rest of the line unchanged:
$ IFS='=' read -r key value <<< 'url=https://x.example/?a=1'
$ echo "$key | $value"
url | https://x.example/?a=1
(The <<< here-string feeds one line to standard input; lesson 3 covers it properly.) Setting IFS right before read on the same line changes it for that one command only.
Seeing everything. declare -p prints an array as bash code, which is the clearest way to inspect it:
$ declare -p port
declare -A port=([ssh]="22" [https]="443" [http]="80" )
Common mistakes
- Forgetting
declare -A. No error; every key collapses onto index 0 (see the quiz). If a map "only keeps the last value", this is why. declare -Ainside a function without thinking about scope. In a function,declaremakes a local variable, which disappears when the function returns. Usedeclare -gAto make a global one from inside a function, or declare it at the top of the script.- Expecting insertion order. Sort.
- Using
$inside the brackets of an indexed array but not here. For associative arraysmap[$key]needs the$;map[key]means the literal keykey. - Old bash. macOS ships bash 3.2, which has no associative arrays:
declare: -A: invalid option. Linux has bash 5.
Exercises
Two quizzes on how the table behaves, then a word counter and a config reader. In the sandbox, try your scripts with printf 'a b a\n' | bash script.sh, and for the config exercise create the file shown in the prompt first.
Recap
declare -A namecreates an associative array: string keys mapped to string values."${map[key]}"reads,map[key]=vwrites,"${!map[@]}"lists keys,${#map[@]}counts,[[ -v map[key] ]]tests existence.- It is a hash table: fast lookup, no order. Sort output when order matters.
- Counting:
map[$k]=$(( ${map[$k]:-0} + 1 )). - Without
declare -Abash quietly builds an indexed array and evaluates keys as arithmetic.
Going further: in help declare, look at -A, -g and -p. Then write a two-pass script that counts requests per IP and sums the bytes per IP from one log in a single while read loop, using two associative arrays with the same keys.