Module 10 — Arrays and Associative Arrays
Updated 3 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — Indexed arrays: a numbered list in one variable
A1. Create, read, count
An indexed array is one variable holding an ordered list of values, numbered from 0:
hosts=(web-1 web-2 "db primary" cache-1) # parentheses, space-separated; quote spacey members
echo "${hosts[0]}" # one element, by number
echo "${#hosts[@]}" # how many elements (the # is Module 9's length, applied to the collection)
echo "${!hosts[@]}" # the indexes themselves (0 1 2 3)Every access wears ${ } braces — $hosts[0] without them is $hosts (which quietly means element 0!) followed by literal text [0], a Module-2-style greedy-parse trap. And notice the third member: "db primary" went in as one element, spaces and all — arrays hold values, not words, which is the entire reason they exist and the subject of A2.
Where the analogy stops working. Pigeonhole walls are dense — slots 0 to N, no gaps. Bash arrays can be sparse: removing slot 1 leaves 0, 2, 3 — the numbering does not close up (A3 demonstrates, and it surprises everyone). "How many" and "up to what number" are different questions in bash.
🧪 Exercise 10.1 — the pigeonhole wall
hosts=(web-1 web-2 "db primary" cache-1)
echo "first: ${hosts[0]}"
echo "count: ${#hosts[@]}"
echo "indexes: ${!hosts[@]}"✅ Expected result — click to reveal
first: web-1
count: 4
indexes: 0 1 2 3What to read out of it: four values, one of them containing a space, counted as exactly 4 — the collection knows its own boundaries, which no space-separated string ever could (that was Module 2's whole tragedy). The ! prefix meaning "give me the indexes, not the values" is a shape worth filing: it returns transformed for associative arrays in Part C, where the indexes are names.
A2. "${arr[@]}" — the only correct way to say "all of them"
Expanding a whole array replays Module 7's finale, note for note. "${arr[@]}" (quoted, @) → one word per element, boundaries intact — the faithful forward. "${arr[*]}" (quoted, *) → all elements welded into a single word — the log-line form. Unquoted either way → elements re-split on spaces, the collection's boundaries destroyed. The rule is the same reflex with brackets on: iterating or forwarding → "${arr[@]}"; building one display string → "${arr[*]}"; unquoted → never. It is not a coincidence: $@/$* are this mechanism — the positional parameters are simply bash's oldest array.
🧪 Exercise 10.2 — the probe settles it again
The third probe mangles on purpose.
hosts=(web-1 web-2 "db primary" cache-1)
printf '[%s]\n' "${hosts[@]}"
echo "---"
printf '[%s]\n' "${hosts[*]}"
echo "---"
printf '[%s]\n' ${hosts[@]} # ← unquoted: boundaries destroyed✅ Expected result — click to reveal (contains a deliberate mangling)
[web-1]
[web-2]
[db primary]
[cache-1]
---
[web-1 web-2 db primary cache-1]
---
[web-1]
[web-2]
[db]
[primary]
[cache-1]What to read out of it: quoted-@ gave four brackets with db primary whole — the collection survived. Quoted-* gave one bracket — the weld. Unquoted gave FIVE brackets: bash expanded, then word-split, and the space inside db primary became a boundary — a fleet script just gained a host named db and another named primary, neither of which exists. Every array loop you ever write is for h in "${hosts[@]}" — the quoted-@ spelling, untouched, forever.
A3. Growing, shrinking, and the sparse surprise
Append with hosts+=(web-3) — the parentheses matter (hosts+=web-3 would glue text onto element 0, another brace-family trap). Remove one element with unset 'hosts[1]' (quoted, so the brackets reach unset instead of globbing — Module 2 B3 in a new costume). And then the surprise:
🧪 Exercise 10.3 — grow, punch a hole, feel it
hosts=(web-1 web-2 "db primary" cache-1)
hosts+=(web-3)
echo "count: ${#hosts[@]}"
unset 'hosts[1]'
echo "count: ${#hosts[@]} indexes: ${!hosts[@]}"
hosts=("${hosts[@]}")
echo "count: ${#hosts[@]} indexes: ${!hosts[@]}"✅ Expected result — click to reveal
count: 5
count: 4 indexes: 0 2 3 4
count: 4 indexes: 0 1 2 3What to read out of it: after the unset, four elements — at indexes 0, 2, 3, 4. The hole is real and permanent until you act. The rebuild line — expand-quoted, re-collect — closes it: same four elements, dense numbering. If you take one habit from this section: "${arr[@]}" iteration never sees holes, so scripts that stick to it are immune to the whole sparse-array bug class.
Interview questions — Part A
🎯 "What are arrays in Bash, and how do you use them?" — asked verbatim at Zero To Mastery; Edureka asks "How to print the first array element?"
The direct answer: arr=(a b "c d") creates an indexed array; "${arr[0]}" reads one element; "${arr[@]}" expands all elements one-word-each; "${#arr[@]}" counts; arr+=(e) appends; for x in "${arr[@]}" iterates safely.
Going deeper: say why arrays over space-separated strings — elements keep their boundaries, so values with spaces survive; and the quoting law inherited from $@/$*: quoted-@ forwards, quoted-* welds, unquoted destroys. Note arrays are bash (and ksh/zsh) — not POSIX sh.
The details that separate candidates: the sparse-array behavior of unset (indexes don't renumber; count ≠ highest index; "${arr[@]}" iteration as the immunity); and the brace-trap that $arr alone means element 0 — both are the follow-ups strong interviewers reach for.
🎯 "How to print all array elements and their respective indexes?" — asked verbatim at Edureka
The direct answer: values: echo "${array[@]}"; indexes: echo "${!array[@]}".
Going deeper: Edureka's published answer writes both unquoted — which works for their spaceless sample and silently breaks on real data; you can now say precisely why, and that the robust display is printf '[%s]\n' "${array[@]}" (element boundaries made visible). The ! prefix generalizes: on associative arrays it yields the keys, making for k in "${!map[@]}" the universal map-iteration idiom.
The details that separate candidates: pairing values-and-indexes in one loop — for i in "${!arr[@]}"; do echo "$i: ${arr[$i]}"; done — the spelling that survives sparse arrays, where a naive 0..count loop breaks; naming that failure unprompted is the differentiator.
Part B — Filling arrays from the world
B1. mapfile: a file into an array, safely
Module 6 read files line-by-line in a streaming loop. When you instead want the whole list in memory — to count it, slice it, pass it around — the builtin mapfile (also spelled readarray) loads stdin into an array, one line per element: mapfile -t servers < hosts.txt. The -t trims each line's trailing newline and is effectively mandatory (without it every element carries an invisible newline that corrupts later comparisons). Globs fill arrays too — logs=(/var/log/app/*.log) — inheriting Module 6's rules (split-safe results, the no-match literal pattern, nullglob).
🧪 Exercise 10.4 — load, count, slice
cd ~/bash-course
printf 'web-1\nweb-2\ndb primary\n' > hosts.txt
mapfile -t servers < hosts.txt
echo "loaded: ${#servers[@]}"
printf '[%s]\n' "${servers[@]}"
echo "first two: ${servers[@]:0:2}"✅ Expected result — click to reveal
loaded: 3
[web-1]
[web-2]
[db primary]
first two: web-1 web-2What to read out of it: three lines became three elements — db primary intact, no IFS ceremony required (mapfile splits on newlines only, by design; it is the collection-shaped sibling of while IFS= read -r). The last line is Module 9's slice syntax promoted to collections: ${servers[@]:0:2} — offset and length, but counting elements. Choose by need: streaming loop for transform-as-you-go over big inputs; mapfile for "I need the whole list as a thing."
Interview questions — Part B
🎯 "How do you read the lines of a file into an array?" — the natural follow-up to Module 6's file-reading question; asked in screens more than in published lists (tutorials at Baeldung and LinuxConfig cover the mechanics)
The direct answer: mapfile -t arr < file — one line per element, -t stripping newlines. From a command: mapfile -t arr < <(command) (process substitution, Module 12's tool, previewed).
Going deeper: name the anti-pattern it replaces — arr=($(cat file)) splits on all whitespace and glob-expands, Module 6's cat-loop disease in array form — and the pre-mapfile portable spelling (a while-read loop appending with arr+=("$line")), worth knowing for old bash. State the streaming-vs-loading trade: mapfile holds everything in memory; while-read streams.
The details that separate candidates: the < <(cmd) spelling and why the obvious cmd | mapfile -t arr fails silently — the pipeline puts mapfile in a subshell and the array evaporates with it (Module 12's headline gotcha, cited by name); knowing that one failure mode is the strongest signal on this question.
Part C — Associative arrays: labels instead of numbers
C1. declare -A: the map
An associative array indexes by name instead of number — service→port, host→role, code→meaning. Unlike indexed arrays it must be declared first: declare -A port — and this is a hard requirement, not politeness: without the declaration, port[web]=8080 is treated as an indexed array assignment where web is arithmetic (an unset variable, so 0), and every key silently lands on element 0, last write winning. The rest of the grammar mirrors Part A: ${port[db]} reads, "${!port[@]}" lists keys, "${#port[@]}" counts, for k in "${!port[@]}" iterates. Existence-of-key uses a Module 9 alumnus: [ -n "${port[web]+x}" ] — the +x form expands only if the key is set, distinguishing "unmapped" from "mapped to empty".
Where the analogy stops working. A real cabinet has visible physical order (alphabetized drawers). Bash's cabinet has none — folders come back in whatever order the clerk grabs them, a different order tomorrow. And a cabinet mislabeled as a coat check (the missing declare -A) doesn't refuse your folders — it stuffs them all into slot 0 and smiles, which is exactly the silent-corruption failure the declaration exists to prevent.
🧪 Exercise 10.5 — the service map
declare -A port
port[web]=8080
port[db]=5432
port[cache]=6379
echo "db port: ${port[db]}"
echo "count: ${#port[@]}"
for svc in "${!port[@]}"; do
echo "$svc -> ${port[$svc]}"
done
[ -n "${port[web]+x}" ] && echo "web is mapped"
[ -z "${port[queue]+x}" ] && echo "queue is not mapped"✅ Expected result — click to reveal
db port: 5432
count: 3
db -> 5432
web -> 8080
cache -> 6379
web is mapped
queue is not mappedWhat to read out of it: lookups read like sentences — the map's whole point. But stare at the loop's order: db, web, cache — neither insertion order (web, db, cache) nor alphabetical. That is the no-order rule showing itself; your machine may print another permutation, and that variability is the expected result. The last two lines are the existence idiom earning its keep: queue being unmapped is a checkable fact, not a silent empty string.
Interview questions — Part C
🎯 "What are associative arrays and when would you use them?" — asked in screens wherever bash 4+ is assumed; published lists rarely carry a fixed wording (Codecademy, Baeldung and LinuxConfig document the mechanics as tutorials)
The direct answer: string-keyed maps, declare -A map; map[key]=value; ${map[key]} — for lookups by name: service→port, env→URL, host→state. Iterate keys with "${!map[@]}"; count with "${#map[@]}".
Going deeper: the two sharp edges — the mandatory declare -A (without it, keys evaluate as arithmetic and silently collapse onto index 0) and no ordering guarantees (sort keys when output must be stable). Requires bash 4+; notably absent on stock macOS bash 3.2 (the Module 1 caveat, resurfacing).
The details that separate candidates: the key-existence idiom ${map[k]+x} and why it beats -n "${map[k]}" (empty value vs absent key are different facts); and the honest design boundary — nested structures don't exist in bash, so a map-of-lists means encoded values or parallel arrays, and "this data has outgrown bash, use jq/python" is a professional answer, not a confession.
Part D — Choosing your collection
D1. The decision
Diagram source
flowchart TD
A["I have multiple values<br>to keep together"] --> B{"How are they<br>looked up?"}
B -->|"by position /<br>just iterate them"| C["indexed array<br>arr=(a b c)"]
B -->|"by name<br>(service, host, code)"| D["associative array<br>declare -A map"]
C --> E{"where do the<br>values come from?"}
E -->|"literals in<br>the script"| F["arr=(...)"]
E -->|"lines of a file<br>or command"| G["mapfile -t arr < file<br>or < <(cmd)"]
E -->|"matching files"| H["arr=(*.log)<br>+ nullglob"]
D --> I{"output order<br>matters?"}
I -->|"yes"| J["sort the keys<br>at report time"]
I -->|"no"| K["iterate ${!map[@]}<br>as-is"]Interview questions — Part D
🎯 "Why store hosts in an array instead of a space-separated string?" — the design-question form in which array knowledge is actually probed
The direct answer: because arrays preserve element boundaries. A string hosts="web-1 web-2" cannot hold a value containing a space, cannot be counted reliably, and every use re-splits it; an array holds each value intact, counts itself ("${#hosts[@]}"), and iterates safely ("${hosts[@]}").
Going deeper: the string version is the Module 2 word-splitting hazard institutionalized — it works until the first spacey value, then fails at every use site simultaneously. Arrays move the boundary decision to fill time (mapfile, globs, explicit quoting) and make every later use safe by construction.
The details that separate candidates: naming the one place strings still win — exporting to child processes (arrays don't export; a child needs the data serialized, e.g. newline-joined, and re-parsed — a real bash limitation worth admitting); and the observation that positional parameters are themselves an array, which is why "$@" and "${arr[@]}" obey identical laws.
Part E — Production
E1. 🏭 Production practices
Collections live in arrays from the moment they enter the script. Hostlists via mapfile -t, file sets via globs (with nullglob), never space-joined strings that re-split at every use.
"${arr[@]}" is the only iteration spelling. Quoted-@ in every loop and forward; "${arr[*]}" only inside log lines; unquoted array expansion is a review-blocking defect.
Associative arrays are declared at the top, next to the constants — declare -A STATUS VERSION — so the mandatory -A can never be forgotten mid-file.
Map output is sorted before humans or diffs see it. Key order is noise; reports, metrics, and test fixtures sort at the boundary.
Sparse traps are designed out. Element removal is followed by a compacting rebuild, or (better) iteration never depends on index arithmetic in the first place.
Bash-version reality is checked. Associative arrays need bash 4+; scripts that must run on stock macOS or ancient AMIs either guard ((( BASH_VERSINFO[0] >= 4 )) || die …) or avoid them.
E2. Production-practice table
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Fleet loop targets a host named db and another named primary — neither exists | Unquoted ${hosts[@]} re-split a spacey element | printf '[%s]\n' both spellings and count brackets | "${hosts[@]}" — quoted-@, everywhere |
| Every key of a "map" reads back the same value | Missing declare -A — keys evaluated as arithmetic, all writes landed on index 0 | declare -p map — shows declare -a (indexed!) with one element | declare -A map before first write; declare maps at the top of the script |
| C-style loop misses the last element / prints an empty slot | The array is sparse — an unset punched a hole; count ≠ highest index | echo "${!arr[@]}" — look for gaps | Iterate "${arr[@]}" or "${!arr[@]}"; compact with arr=("${arr[@]}") after removals |
| Array loaded from a command is mysteriously empty | cmd | mapfile -t arr — the pipeline's subshell took the array to its grave | echo "${#arr[@]}" right after → 0 | mapfile -t arr < <(cmd) — process substitution keeps mapfile in the current shell |
| Comparisons fail although elements "look identical" | mapfile without -t — every element carries an invisible trailing newline | printf '[%s]\n' "${arr[0]}" — the bracket closes on the next line | mapfile -t, always |
| Map-driven report ordering changes run to run; diffs are noise | Associative arrays guarantee no key order | Run the report twice; compare | Sort keys at report time; or keep an insertion-order index array alongside |
E3. 🎓 Capstone — four tickets from the queue
🎓 Ticket 1 — "Rollout script loads targets with targets=($(cat hosts.txt)). Since networking added edge gateway 3 (a jump-host entry with spaces) the script has been attempting SSH to hosts named edge, gateway, and 3."
Diagnosis. Module 6's cat-loop disease, array edition: $( ) substituted the file, unquoted expansion word-split it, and the array collected words, not lines. Three fake hosts from one real line.
Work the steps: printf '[%s]\n' "${targets[@]}" versus wc -l hosts.txt — element count exceeds line count; the brackets show exactly where lines shattered.
Fix and prevention: mapfile -t targets < hosts.txt — line-per-element by construction. Then the boundary guard: validate each element ([[ "$t" =~ ^[a-zA-Z0-9.-]+$ ]] || die "bad hostname: $t") so the next surprising entry fails loudly at load time instead of weirdly at SSH time.
🎓 Ticket 2 — "Inventory script: declare -A role was deleted in a 'cleanup' commit because 'the script worked without it in testing.' Now role[web]=frontend; role[db]=database — and every service reports role database."
Diagnosis. The missing-declare collapse (C1): without -A, role became an indexed array; web and db were evaluated as arithmetic — unset variable names, both worth 0 — so every write landed on element 0 and the last writer won. (Keys with hyphens like web-1 fail differently: bad array subscript, arithmetic refusing a negative index — loud, and in a way luckier.) It "worked in testing" because testing read back the same key it had just written — the one pattern that can't expose last-write-wins.
Work the steps: declare -p role — the output opens declare -a (indexed) with a single element; that one line is the entire diagnosis.
Fix and prevention: restore declare -A role (top of script, beside the constants — E1's placement rule exists to make such deletions visible in review). Add the cheap tripwire test that testing lacked: write two keys, read both back, compare count to 2.
🎓 Ticket 3 — "Nightly fleet report loops a status map and emails the table. The on-call lead complains: 'the host order shuffles every night — I can't diff today against yesterday to see what changed.'"
Diagnosis. C1's no-order rule meeting a workflow that assumes stability: for h in "${!status[@]}" emits whatever order the hash table feels like, per run. Nothing is broken in bash; the interface (a diffable report) has a requirement the data structure never promised.
Work the steps: run the report twice in a row; diff — the shuffle reproduces without any data change.
Fix and prevention: impose order at the boundary: for h in $(printf '%s\n' "${!status[@]}" | sort); do echo "$h: ${status[$h]}"; done — or iterate the source hostlist array (which has stable order) and look each host up in the map. General rule for the runbook: maps are for lookup; any output humans read or diff gets ordered deliberately on the way out.
🎓 Ticket 4 — "A cleanup pass removes decommissioned hosts from the in-memory array with unset 'hosts[i]' inside a C-style index loop, then a second C-style loop pushes config to ${hosts[i]}. Some hosts get skipped; occasionally the push targets an empty string."
Diagnosis. The sparse trap (A3) plus index arithmetic: each unset punches a hole; the second loop walks 0..count-1, where count has shrunk but the surviving indexes haven't moved — so it stops before the tail (skipped hosts) and dereferences holes on the way (empty-string pushes). Two symptoms, one cause: index math over a sparse array.
Work the steps: after the removal pass: echo "${!hosts[@]}" — gaps visible; echo "${#hosts[@]}" vs the highest index — mismatch confirmed.
Fix and prevention: either compact after removals (hosts=("${hosts[@]}")) or — structurally better — stop removing mid-structure: build a new array of keepers (for h in "${hosts[@]}"; do keep "$h" && new+=("$h"); done; hosts=("${new[@]}")), and iterate with "${hosts[@]}" so holes can't exist in the first place. Filter-into-new beats delete-in-place in bash every time.
E4. Documentation reference
| Topic | Authoritative reference | What you'll find there |
|---|---|---|
| Arrays, indexed and associative | Bash manual — Arrays | Creation, subscripts, @ vs * expansion, ! for indexes/keys, unset behavior |
| mapfile / readarray, declare | Bash manual — Bash Builtins | mapfile's options (-t and friends); declare -A and declare -p for inspection |
| The @/* law these obey | Bash manual — Special Parameters | The original $@/$* semantics that array expansion mirrors |
E5. Self-assessment
Answer from memory, out loud or on paper. Every answer is in this module.
- Create a four-element array where one element contains a space; read element 0, the count, and the index list.
- Why is $hosts[0] without braces wrong twice over?
- "${arr[@]}" vs "${arr[*]}" vs unquoted — map each to its Module 7 twin and its use.
- What does unset 'arr[1]' do to the indexes, the count, and a C-style loop? Why is the unset quoted?
- How do you compact a sparse array, and which iteration style never notices holes at all?
- Load a file's lines into an array safely; what does -t prevent, and what does the arr=($(cat file)) anti-pattern do instead?
- Why does cmd | mapfile -t arr leave the array empty, and what is the working spelling?
- What goes wrong if you skip declare -A before using string keys — and which one command diagnoses it instantly?
- What ordering do associative arrays guarantee, and what are the two production remedies when output must be stable?
- Distinguish "key mapped to empty" from "key absent" — the exact test for each.
- Which bash versions lack associative arrays, where does that bite in practice, and what is the guard line?
- Name the one thing space-separated strings can still do that arrays cannot, and the workaround.
E6. Sources
Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "What are arrays in Bash, and how do you use them?" (published June 18, 2026)
Edureka — Top 60 Shell Scripting Interview Questions and Answers — "How to print all array elements and their respective indexes?", "How to print the first array element?" (page updated Dec 9, 2024)
A note on the corpus: published lists cover indexed-array basics thinly and associative arrays almost not at all — the working material circulates as tutorials (Baeldung, LinuxConfig, Codecademy, LinuxJunkies). The associative-array and design questions above are therefore labeled as screen-style questions without canonical published wording, rather than given invented citations. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2; associative-array key order is explicitly machine-varying and flagged as such where shown.