skip to content

Associative Arrays and Namerefs

Bash 4 added real hash maps through declare -A, which is exactly why they break on macOS's stock bash 3.2 — a portability question that comes up constantly. Namerefs (declare -n) are the companion trick for passing an array into a function by name instead of by value.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

A bash script that never calls declare does `counts[apple]=3; counts[banana]=5; echo "${counts[apple]}"` and prints 5. What did bash actually create, and why did the two keys collide?

level: middleimportance: must knowfreq 55%

answer

  1. bash never infers a hash map
  2. subscript is an arithmetic context
  3. unset name evaluates to zero
  4. both writes land on element 0
  5. declare -A before the first assignment

basics

~20 s

Without declare -A, bash created an ordinary indexed array, whose subscript is an arithmetic expression. The unset names apple and banana both evaluate to 0, so both assignments wrote element 0 and the second overwrote the first.

solid answer

~40 s

Bash does not infer an associative array from the way you use it. Assigning to a subscript of an undeclared variable creates an *indexed* array, and an indexed subscript is an arithmetic context: `apple` is read as a variable name, it is unset, so it evaluates to `0`. Both `counts[apple]=3` and `counts[banana]=5` therefore write index 0, and the read `${counts[apple]}` is really `${counts[0]}`, which is 5. The fix is to declare the map before the first assignment — `declare -A counts` (bash 4.0+) — after which the subscript is taken as a literal string key. Nothing warns you: no syntax error, and `set -u` does not save you, because the array itself is perfectly well defined. `declare -p counts` is the quickest way to see which kind you actually have.

code

bash · 11 lines
bash
#!/usr/bin/env bash
# No declare: an indexed array, subscripts evaluated as arithmetic
counts[apple]=3
counts[banana]=5
declare -p counts    # declare -a counts=([0]="5")

# With declare -A: subscripts are literal string keys
declare -A tally
tally[apple]=3
tally[banana]=5
declare -p tally     # declare -A with both apple and banana

go deeper

for a junior

Remember the rule: a bash map must be created with declare -A name before you assign to it, and declare -p name shows you which kind of array you really have.

for a middle

Explain the mechanism, not just the fix: an indexed subscript is evaluated as arithmetic, an unset name evaluates to zero, so every key collapses onto element 0. Mention that bash 4.0 introduced associative arrays.

for a senior

Show how you would catch this in a real script — declarations grouped at the top, declare -p when debugging, ShellCheck in CI — and note that no strict-mode flag fires because the array is perfectly valid, only wrong.

for a principal

Frame it as a language-design hazard worth a team convention: implicit array creation plus arithmetic subscripts means wrong data instead of an error, so decide where lint gates run and when a lookup-heavy script should stop being bash at all.

## The two kinds of bash array Bash has two array types and they are not interchangeable. An **indexed array** is keyed by non-negative integers and is created implicitly the moment you assign to a subscript of a name that has no other type. An **associative array** — a real hash map keyed by arbitrary strings — exists only if you asked for it explicitly with `declare -A` (or `local -A` inside a function). Associative arrays were added in bash 4.0; there is no implicit path to one. That asymmetry is the whole bug. `counts[apple]=3` is a legal assignment even with no prior declaration, so bash quietly does the only thing it can: it creates an indexed array named `counts`. ## Why the subscript becomes zero For an indexed array, the text between the brackets is evaluated as an **arithmetic expression**, exactly as if it were inside `$(( ))`. In arithmetic context a bare word is treated as a variable name, and a variable that is unset or empty evaluates to `0`. So: ```bash counts[apple]=3 # apple is unset -> counts[0]=3 counts[banana]=5 # banana is unset -> counts[0]=5 echo "${counts[apple]}" # -> ${counts[0]} -> 5 ``` Every key in the script maps onto index 0, so the array ends up holding exactly one element that always shows the value written last. The counter appears to work — it just always reports the most recent write. The failure mode gets worse, not better, when a same-named variable happens to exist. If some earlier line set `apple=7`, then `counts[apple]=3` writes index 7 instead, and the array becomes a sparse indexed array whose contents depend on unrelated variables. Renaming a loop variable elsewhere in the script can silently change where the data lands. ## The correct declaration ```bash declare -A counts # must come BEFORE the first assignment counts[apple]=3 counts[banana]=5 declare -p counts # shows a declare -A line with both keys ``` With `-A` in force, the subscript is no longer arithmetic: it is taken as a literal string, so `counts[apple]` and `counts[banana]` are distinct keys. You can also initialise in one step: `declare -A counts=([apple]=3 [banana]=5)`. Order matters absolutely. Declaring after the fact does not repair anything — bash refuses to change an existing array's type and reports `cannot convert indexed to associative array`. You must `unset counts` first, which of course discards whatever it held. ## Scope, and the function trap `declare` inside a function creates a **function-local** variable, the same as `local`. A helper that does `declare -A cache` builds a map the caller never sees, and the caller's later reads fall back to whatever global of that name exists. If a function must populate a map the rest of the script uses, either declare it at top level, or use `declare -gA cache` (the `-g` global flag, bash 4.2+) inside the function. ## How to notice it in review Three habits catch this reliably: 1. **Declare at the top.** Every map gets a `declare -A` line near the top of the script or immediately inside the function that owns it, next to the other declarations — never implicitly at the first assignment. 2. **Print the type when debugging.** `declare -p name` prints the variable with its attributes: `declare -a` means indexed, `declare -A` means associative. If you expected a map and see `declare -a name=([0]="...")`, you have this bug. 3. **Lint it.** ShellCheck flags associative-array syntax used on a variable it never saw declared with `-A`, which is usually the first hint in a large script. ## Why interviewers like it It is the archetype of the tree's favourite shape: code that reads correctly, runs without error, and produces wrong data. There is no exception, no non-zero exit status, and no strict-mode flag that fires — the array is valid, the arithmetic is valid, and only the programmer's intent was lost. Being able to explain *why* the subscript turned into zero (arithmetic evaluation of an unset name) rather than just reciting "you need declare -A" is what separates a memorised answer from an understood one.

  • Can you add `declare -A` later, after the array already has contents?
    No. Bash refuses to change an existing array's type and reports `cannot convert indexed to associative array`. You have to `unset` the variable first and then declare it, which throws away whatever it held. In practice that means the declaration belongs above the first assignment, not bolted on when the bug is found.
  • What changes if a variable named `apple` happens to exist in the script?
    The subscript still evaluates arithmetically, so it resolves to that variable's value: `apple=7` makes `counts[apple]=3` write index 7. The array becomes a sparse indexed array whose layout depends on unrelated variables elsewhere in the script, so the bug moves around when someone renames a loop variable.
  • A function does `declare -A cache` and the caller sees nothing. Why?
    `declare` inside a function behaves like `local`: the variable is function-scoped and disappears on return. Either declare the map at top level and let the function fill it, or use `declare -gA cache` (bash 4.2+) to create it globally from inside the function.

Typing a word into a pocket calculator does not store the word — the calculator insists on reading it as a number and gets zero. An indexed array subscript does exactly that with your key names.

saying these in an interview costs you the question

  • Thinks bash infers an associative array from string keys
  • Says declare -A is optional style, not a requirement
  • Believes set -u or set -e catches the collision
  • Assumes declare -A can convert an existing indexed array
  • Cannot explain that indexed subscripts are arithmetic

context

open as a page

Given a bash associative array declared as `declare -A env_of`, how do you loop over its keys, what does `"${env_of[@]}"` give you instead, and can you rely on the order you get?

level: juniorimportance: should knowfreq 48%

basics

~20 s

Loop over "${!env_of[@]}" for the keys; "${env_of[@]}" expands to the values, and ${#env_of[@]} is the entry count. The order is bash's internal hash order — neither insertion order nor sorted — so sort explicitly if output must be stable.

open as a page

In bash, a function needs to fill an array that its caller declared, but arrays cannot be exported or passed by value. How does `declare -n` (or `local -n`) let the caller pass the array by name, and what naming pitfall comes with it?

level: middleimportance: should knowfreq 30%

basics

~20 s

A nameref variable created with local -n out=$1 becomes an alias for whatever variable $1 names, so assignments to out write straight into the caller's array. It needs bash 4.3+, and the alias breaks if the caller's variable has the same name as the nameref.

open as a page

A CI script that begins `declare -A seen` runs fine on the Linux runners but fails on a developer's Mac with `declare: -A: invalid option`. What is happening, and what are your options for making that script work there?

level: seniorimportance: should knowfreq 42%

basics

~20 s

macOS still ships bash 3.2 as /bin/bash, and associative arrays arrived in bash 4.0, so declare -A is simply not a valid option there. Options: run the script under a newer bash the developer installed, guard on BASH_VERSINFO and fail fast, or restructure to avoid the map.

open as a page

A bash associative array `declare -A opts` may legitimately hold empty-string values. How do you test whether a given key is present, as opposed to present-but-empty?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Use [[ -v opts[key] ]], which is true when the key exists whatever its value (bash 4.3+ accepts array subscripts here). The pre-4.3 equivalent is [[ -n ${opts[key]+x} ]]. Testing [[ -n ${opts[key]} ]] conflates absent with empty.

open as a page