Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 19 · Arrays and Lists of Values

Associative Arrays: Keys and Values

An array keyed by name rather than number is the obvious tool for counting verdicts or looking up a tool's path. It is also a Bash 4 feature, and the way an older shell fails is the worst kind: the declaration is rejected, the assignments that follow are accepted as something else entirely, and every key you write ends up in the same slot with no error at all.

Advanced20 min readLinuxPart IBashArraysPortabilityScripting

Part I · Module 19 · Chapter 19.6 · Associative Arrays: Keys and Values

Every array so far was keyed by a number. The obvious next want is a key that is a word — count how many runs ended PASS, look up which binary a tool name maps to.

1. A Note on This Chapter

Everything else in this course has been measured on the machine it was written on, and the transcripts are pasted output. This chapter cannot be, and it is better to say so than to let you assume.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ declare -A m
bash: declare: -A: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
$ bash --version | head -1
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)

Associative arrays arrived in Bash 4.0, and macOS ships Bash 3.2.57 — the last version under the GPLv2 licence, from 2007. Apple has never shipped a newer one.

So this chapter is in two halves, and they are labelled:

  • Sections 2 and 4–6 are measured. That includes how Bash 3.2 fails, which is the most important thing here, and the substitutes that work everywhere.
  • Section 3 is quoted from the Bash manual and was not executed. The syntax is correct and widely documented; treat it as reference rather than as something this course verified.

That split is not an apology — it is the chapter's actual subject. The curriculum's own description of this chapter says "know the Bash version requirement that comes with it before relying on one in a shared script", and the sharpest way to learn that requirement is on a shell that does not meet it.

2. How an Older Shell Fails, Which Is Badly

The declaration is rejected, loudly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ declare -A counts
bash: declare: -A: invalid option
$ typeset -A counts
bash: typeset: -A: invalid option

You might reasonably expect the script to stop there. It does not — declare -A failing is just a command failing, and unless you are under set -e the next line runs. And the next line is an assignment that Bash is perfectly happy to accept:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ counts=()
$ counts[PASS]=10
$ counts[FAIL]=3
$ echo "count=${#counts[@]}"
count=1
$ echo "counts[0]=${counts[0]}"
counts[0]=3

One element. Index 0. Value 3.

counts[PASS]=10 was not an error and did not create a key called PASS. In an indexed array the subscript is an arithmetic expression — Chapter 8.5's arithmetic context — and in arithmetic an unset variable name evaluates to 0. So PASS became 0 and FAIL became 0, both assignments wrote index 0, and 10 was overwritten by 3.

You can watch the subscript being evaluated:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ m=()
$ k=2
$ m[k]=v
$ echo "indices=${!m[@]}"
indices=2

m[k] with k=2 wrote index 2. The subscript is live arithmetic, not a name.

A keyed array on a shell that has none

Why a keyed array needs a version check before you rely on it. On a shell too old to have them, the declaration is rejected but the assignments that follow are still accepted as an ordinary numbered array, whose subscript is evaluated as arithmetic — so every unset key counts as zero, all of them land in the same slot, and only the last one survives. Checking the major version first turns that silent loss into a clear message.A keyed array on an oldshellthe declaration is rejectedoutrightBut the assignmentsstill runagainst an ordinary numberedarrayEvery key evaluates tozeroso they overwrite, and thelast one winsCheck the major versionfirstbefore a single key is writtenRefuse, or take theother pathtwo parallel lists, or acounting pipelineA clear message beatssilent lossand the script runs everywhereor says why12
The declaration is rejected, but the assignments that follow are still accepted — against an ordinary numbered array, whose subscript is evaluated as arithmetic. Every key that is not a set variable therefore evaluates to zero, all of them write the same slot, and only the last value survives. Checking the major version before a single key is written turns that silent loss into a message.

3. The Syntax — Reference, Not Measured

From the Bash manual, for Bash 4.0 and later. None of this section was executed on this machine; see §1.

Declaration is mandatory, and must come before any assignment:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
declare -A counts                     # script level
local -A counts                       # inside a function
declare -A paths=([vcs]=/tools/vcs [xrun]=/tools/xrun)

Assignment and lookup use the key in the subscript:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
counts[PASS]=10
counts[FAIL]=3
counts["cache coherency"]=1           # a key may contain spaces

echo "${counts[PASS]}"                # 10
echo "${#counts[@]}"                  # 3   — number of keys
echo "${!counts[@]}"                  # the keys
echo "${counts[@]}"                   # the values

The forms you already know carry over with one change in meaning: ${!m[@]} gives the keys rather than numeric indices, which is what makes iteration work:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for k in "${!counts[@]}"; do
  printf '%-10s %s\n' "$k" "${counts[$k]}"
done

Note two things the manual is explicit about:

The order of ${!m[@]} is unspecified. It is a hash table, so keys come out in whatever order the implementation likes — not insertion order and not sorted. If you need a stable report, pipe the keys through sort.

Testing for a key needs -v, not a value test. [ -n "${m[k]}" ] cannot distinguish "key absent" from "key present with an empty value". [[ -v m[k] ]] tests presence, and it is Bash 4.3 — later than the arrays themselves.

The counting idiom, which is what people actually want them for:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
declare -A counts
while IFS= read -r verdict; do
  counts[$verdict]=$(( ${counts[$verdict]:-0} + 1 ))
done < verdicts.txt

The :-0 matters: the first time a key is seen it is unset, and $(( + 1 )) on an empty value is an arithmetic error.

4. Substitute One: Two Parallel Arrays

Measured, and portable to every Bash. Keep keys in one indexed array and values in another at the same index:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ keys=(); vals=()
$ kv_set() {
>   local i
>   for i in "${!keys[@]}"; do
>     if [ "${keys[$i]}" = "$1" ]; then vals[$i]=$2; return; fi
>   done
>   keys+=("$1"); vals+=("$2")
> }
$ kv_get() {
>   local i
>   for i in "${!keys[@]}"; do
>     if [ "${keys[$i]}" = "$1" ]; then printf '%s\n' "${vals[$i]}"; return 0; fi
>   done
>   return 1
> }
$ kv_set PASS 10; kv_set FAIL 3; kv_set PASS 11
$ echo "PASS=$(kv_get PASS)  FAIL=$(kv_get FAIL)  count=${#keys[@]}"
PASS=11  FAIL=3  count=2
$ kv_get NOPE || echo "NOPE: not found (status 1)"
NOPE: not found (status 1)

kv_set PASS 11 updated rather than appended, so the count stayed at 2. And kv_get on a missing key returns status 1 with no output — which is the thing [ -n "${m[k]}" ] cannot do even in Bash 4 (§3's second note).

The cost is honest: lookup is a linear scan, so this is O(n) per access where a real hash table is O(1). For the handful of verdict words or tool names a shell script tallies, that is irrelevant. For thousands of keys in a loop it is not, and at that point the work does not belong in shell at all.

Both loops use "${!keys[@]}" rather than a counting loop, for Chapter 19.2 §4's reason.

5. Substitute Two: Let the Tools Count

Measured, and usually better than either array approach. Counting distinct values is what sort | uniq -c is for:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ printf 'PASS\nFAIL\nPASS\nPASS\nERROR\n' > verdicts.txt
$ sort verdicts.txt | uniq -c | sort -rn
   3 PASS
   1 FAIL
   1 ERROR

One line, Chapter 13.4's uniq -c and Chapter 13.3's sort, and it is sorted by frequency — which an associative array would have cost you another loop to achieve, given §3's unspecified key order.

Read the result back into parallel arrays if the script needs to do more with it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ks=(); vs=()
$ while read -r n k; do ks+=("$k"); vs+=("$n"); done < <(sort verdicts.txt | uniq -c)
$ for i in "${!ks[@]}"; do printf '  %-6s %s\n' "${ks[$i]}" "${vs[$i]}"; done
  ERROR  1
  FAIL   1
  PASS   3

read -r n k splits each uniq -c line into the count and the rest, which is Chapter 17.5 §5's field splitting.

Reach for this first. If the question is "how many of each", the pipeline is shorter, portable to every shell, sorted however you like, and does not care how many distinct values there are.

6. Substitute Three: Dynamic Variable Names (Do Not)

You will meet this in older scripts. It works, and it is a command-injection hole:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ set_count() { eval "count_$1=\$2"; }
$ get_count() { eval "printf %s \"\$count_$1\""; }
$ set_count PASS 10
$ echo "PASS=$(get_count PASS)"
PASS=10

Now a key that came from outside:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ set_count "x; echo INJECTED" 1
bash: count_x: command not found
INJECTED=1

INJECTED=1 was executed. The semicolon in the key ended the assignment and eval ran the rest, exactly as Chapter 19.5 §2 measured for command lines. A verdict word out of a log file, a tool name out of a config, a branch name out of CI — any of those as a key, and this runs arbitrary commands.

It also cannot represent a key containing a space, a hyphen, or anything else illegal in a variable name, which rules out most real keys.

Use §4 or §5. This section exists so you recognise the pattern in code you inherit and know to replace it.

7. A Complete Script

A verdict tallier that runs on every Bash, uses the fast path where available, and never silently loses a key:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# tally.sh — count verdicts, on any bash
# usage: tally.sh <verdictfile>
#
# exit: 0 all PASS · 1 some did not · 2 bad usage

set -o nounset

note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }

file=${1:-}
[ -n "$file" ] || { note "usage: ${0##*/} <verdictfile>"; exit 2; }
[ -r "$file" ] || { note "$file is not readable"; exit 2; }

# ---- the portable path: let the tools count -----------------------------
# chosen unconditionally. an associative array would be the "modern"
# answer and buys nothing here: this is shorter, sorts by frequency for
# free, and needs no version check at all.

declare -a words=()
declare -a nums=()

while read -r n w; do
  words+=("$w")
  nums+=("$n")
done < <(sort "$file" | uniq -c | sort -rn)

if [ "${#words[@]}" -eq 0 ]; then
  note "$file is empty"
  exit 2
fi

total=0
failed=0

printf '%-12s %5s\n' VERDICT COUNT
for i in "${!words[@]}"; do
  printf '%-12s %5s\n' "${words[$i]}" "${nums[$i]}"
  total=$(( total + nums[i] ))
  case ${words[$i]} in
    PASS) ;;
    *)    failed=$(( failed + nums[i] )) ;;
  esac
done

printf '%-12s %5s\n' TOTAL "$total"
[ "$failed" -eq 0 ]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ printf 'PASS\nFAIL\nPASS\nPASS\nERROR\n' > verdicts.txt
$ ./tally.sh verdicts.txt
VERDICT      COUNT
PASS             3
FAIL             1
ERROR            1
TOTAL            5
$ echo $?
1

$ printf 'PASS\nPASS\n' > clean.txt
$ ./tally.sh clean.txt
VERDICT      COUNT
PASS             2
TOTAL            2
$ echo $?
0

$ : > empty.txt && ./tally.sh empty.txt
tally.sh: empty.txt is empty
$ echo $?
2

$ ./tally.sh
tally.sh: usage: tally.sh <verdictfile>
$ echo $?
2

Four decisions:

No associative array, and no version check either — because choosing the portable path removed the need for one. That is the better outcome than guarding: a script with no version requirement has no version bug.

total=$(( total + nums[i] )) uses the array inside arithmetic with no $ and no braces, which works because an arithmetic context reads bare names (Chapter 17.3 §1). It is also the one place a numeric subscript is natural.

"${!words[@]}" to walk two arrays together — the §4 idiom, and the reason parallel arrays are usable at all.

The empty file is an exit 2, not an exit 0. Remove that guard and the script does not abort — it reports success:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./no-guard.sh empty.txt
VERDICT      COUNT
TOTAL            0
$ echo $?
0

Worth being precise about why it does not abort, because Chapter 19.2 §5 might lead you to expect one: the loop is for i in "${!words[@]}", and "${!a[@]}" on an empty array does not trip set -u — only "${a[@]}" does. So the loop runs zero times, nothing fails, failed stays 0, and the final [ "$failed" -eq 0 ] reports a clean pass for a file with nothing in it.

That is Chapter 17.1 §5's trap rather than Chapter 19.2 §5's: a loop that ran zero times is indistinguishable from one that worked. The count guard is what makes the difference, and it is needed here for the meaning of the result rather than to prevent a crash.

Practice

  1. Run declare -A m on your machine and record the exact error. Then check ${BASH_VERSINFO[0]} and say whether you have associative arrays.
  2. If you do not: run m=(); m[PASS]=10; m[FAIL]=3; echo "${#m[@]} ${m[0]}". Explain the two numbers using the words arithmetic and zero.
  3. Set k=2, then run m=(); m[k]=v; echo "${!m[@]}". What does this prove about the subscript?
  4. Write the BASH_VERSINFO guard and confirm it fires. Then argue whether a guard or a portable rewrite is the better response.
  5. Build the kv_set/kv_get pair from §4. Confirm that setting an existing key updates rather than appends, and that a missing key returns status 1 with no output.
  6. Count the verdicts in a file with sort | uniq -c | sort -rn. Then read the result into parallel arrays and print it aligned. Which version would you ship, and why?
  7. Build the §6 eval version and inject with a key of "x; echo INJECTED". Then name the chapter that measured the same hole in a command line.
  8. In tally.sh, replace the sort | uniq -c pipeline with an associative-array loop guarded by BASH_VERSINFO. Run it on this machine and on a Linux box if you have one. Which script is longer?
  9. In tally.sh, remove the [ "${#words[@]}" -eq 0 ] guard and run it on an empty file under set -o nounset. It does not abort — explain why by testing which of "${a[@]}", "${!a[@]}" and ${#a[@]} trip set -u on an empty array, and then say which chapter's trap this actually is.
  10. Change tally.sh to sort alphabetically rather than by frequency. How many characters, and what would the associative-array version have needed?

Check Your Understanding

  1. Which Bash version added associative arrays, and what does macOS ship?
  2. On Bash 3.2, declare -A m fails. What do the following assignments do?
  3. Why does m[PASS]=10 write index 0?
  4. What is ${!m[@]} on an associative array, and what is it on an indexed one?
  5. Why can't [ -n "${m[k]}" ] test whether a key exists?
  6. Is the key order of ${!m[@]} defined?
  7. What is the usual better answer to "count how many of each"?

8. Remember

  • Associative arrays are Bash 4.0. macOS ships 3.2.57, where declare -A is invalid option.
  • The failure is silent and lossy. The declaration errors, the assignments still run against an indexed array, and every unset key evaluates to 0 — so all keys land in slot 0 and the last value wins.
  • An indexed array's subscript is arithmetic. m[k] with k=2 writes index 2; m[PASS] writes index 0.
  • Guard on BASH_VERSINFO[0] before writing a key, or choose a portable approach and have no version requirement at all.
  • ${!m[@]} gives keys on an associative array and indices on an indexed one — the same syntax, which is why for k in "${!m[@]}" reads the same either way.
  • Key order is unspecified. Pipe through sort for a stable report.
  • [ -n "${m[k]}" ] is not a presence test. [[ -v m[k] ]] is, and needs Bash 4.3.
  • Parallel indexed arrays are the portable substitute — linear lookup, fine for the dozens of keys a shell script has.
  • sort | uniq -c is usually better than either, and sorts by frequency for free.
  • Never build keys with eval into variable names. Measured injection, and it cannot represent a key with a space.

9. The Module, in One Page

Six chapters, and they reduce to one sentence.

A space-separated string cannot represent a list whose items may contain spaces, and no amount of quoting recovers what joining destroyed (19.1). Everything follows:

  • An array keeps each item in its own slot (19.2) — with two behaviours that break naive code: unset leaves a hole so a counting loop skips items, and an empty array trips set -u.
  • "${a[@]}" is the only form that is a list (19.3). The same three items come back as 3, 1, 4 or 4 depending on two characters, and the quoting mistake is the one that passes on space-free test data.
  • a=($(cmd)) is the original bug in a new costume (19.4) — three files measured as five elements. A read loop with +=, fed by process substitution, is the portable fix; mapfile is Bash 4 and absent here.
  • A command line is itself a list (19.5), so building one in a string leads to eval, and eval ran echo PWNED from a value. An array has no second parse and no such hole.
  • And a keyed array is Bash 4 (19.6), failing on an older shell in the worst possible way: loudly on the declaration, silently on every assignment after it.

That completes the arrays Part I needed. It also completes the language: variables, quoting and expansion, exit status, redirection and pipes, text processing, searching, scripts, conditionals, loops, functions, and lists. A script can now hold data, decide, repeat, be decomposed, and build a correct command line out of values it did not choose.

What it cannot yet do is fail well. Every complete script in the last four modules has carried hand-written guards — [ -r "$f" ] || return 1, rc=$?, || exit 2, count checks before every expansion — and they were written one at a time because there was no other way. Module 20 is the systematic version: set -e, set -u and set -o pipefail with an honest account of where each one does not do what you expect; validating inputs once rather than everywhere; guarding destructive commands; trap for cleanup that runs even on an interrupt; mktemp instead of test-then-create; which stream a message belongs on; and set -x for finding out what a script actually did.

It is also where several things deferred by name in this module land — set -e's exemptions, ${1:?} versus return, and the difference between an assertion about your own code and a condition a caller can handle.

Where this fits

Part of the Linux & Bash curriculum.