Arrays, Here-docs, Strings and Regex · about 40 min

Associative arrays

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:

ExpressionMeaning
"${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:

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

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

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.

Exercises

Exercise 1 · quiz

The missing declare

This script forgets declare -A. What does it print?

colors[red]=ff0000
colors[blue]=0000ff
echo "${colors[red]}"
Answer
  • 0000ff

Without declare -A, colors becomes an ordinary indexed array, and indexes are evaluated arithmetically. In arithmetic, a bare word is a variable name, and the unset variables red and blue are both 0. So both assignments write colors[0], the second overwrites the first, and ${colors[red]} reads colors[0]: 0000ff. No error, just wrong data. Always declare associative arrays first.

Exercise 2 · quiz

In which order?

You fill declare -A ages=([carol]=41 [alice]=30 [bob]=25) and loop with for k in "${!ages[@]}". In which order are the keys visited?

Answer
  • In no order you can rely on; sort them yourself if order matters

An associative array is a hash table inside bash: keys are placed in buckets by a hash of the key, and the loop walks the buckets. The order looks random and can change between bash versions. When output order matters (and in tests it always does), pipe the loop's output through sort, or sort the keys first.

Exercise 3 · write code

Word frequencies

Read text from standard input and count how often each word occurs, ignoring case (treat The and the as the same word, and print words in lowercase). Words are separated by whitespace; there is no punctuation to worry about.

Print one line per distinct word in the form COUNT WORD, sorted by count (highest first), and words with the same count in alphabetical order. For the input

The cat and the hat
the end and AND

the output is exactly:

3 and
3 the
1 cat
1 end
1 hat

Use an associative array for the counting.

File: script.sh

Starting point

declare -A count
# read words, count them, print "COUNT WORD" lines in the required order

Example

input

The cat and the hat
the end and AND

expected output

3 and
3 the
1 cat
1 end
1 hat

Example

input

  b   a	b

B a c

expected output

3 b
2 a
1 c
Hint 1

read -r -a words reads one line and splits it into the array words. ${w,,} lowercases a string.

Hint 2

Increment with count[$w]=$(( ${count[$w]:-0} + 1 )); the :-0 handles words not seen yet.

Hint 3

Print with a loop over "${!count[@]}" and pipe the whole loop into sort -k1,1nr -k2,2 (count numeric descending, then word).

Reference solution
declare -A count
while read -r -a words; do
  for w in "${words[@]}"; do
    w=${w,,}
    count[$w]=$(( ${count[$w]:-0} + 1 ))
  done
done
for w in "${!count[@]}"; do
  printf '%d %s\n' "${count[$w]}" "$w"
done | sort -k1,1nr -k2,2

Exercise 4 · write code

A config lookup

The file config.ini (present next to your script) holds settings as key=value lines. Blank lines and lines starting with # must be ignored. The value is everything after the first = (it may contain spaces and further = signs).

Write a script that loads the file into an associative array, then:

Example: bash script.sh port name prints port=8080 then name=Shop.

File: script.sh

Starting point

declare -A conf
# load config.ini, then answer the lookups

Example with arguments port name

expected output

port=8080
name=Shop

Example with arguments greeting query

expected output

greeting=Hello, world
query=a=1&b=2
Hint 1

while IFS='=' read -r key value; do ...; done < config.ini splits each line at the first =; the last variable gets the rest of the line, extra = included.

Hint 2

Skip a line with [[ -z $key || $key == '#'* ]] && continue.

Hint 3

Test whether a key exists with [[ -v conf[$k] ]]. Remember a status variable and exit "$status" at the end.

Reference solution
declare -A conf
while IFS='=' read -r key value; do
  [[ -z $key || $key == '#'* ]] && continue
  conf[$key]=$value
done < config.ini

if (( $# == 0 )); then
  for k in "${!conf[@]}"; do
    printf '%s=%s\n' "$k" "${conf[$k]}"
  done | sort
  exit 0
fi

status=0
for k in "$@"; do
  if [[ -v conf[$k] ]]; then
    printf '%s=%s\n' "$k" "${conf[$k]}"
  else
    echo "script.sh: unknown key: $k" >&2
    status=2
  fi
done
exit "$status"