Arrays & Strings
Indexed arrays, associative arrays, slicing, length — and where zsh's array semantics quietly win.
Arrays & Strings
Bash has two array types: indexed (default) and associative (a string-keyed map, requires bash 4 or zsh). The syntax is awkward, the semantics are subtle, and they're still the right tool for "I have a list of files" or "I have a config map" — until they aren't.
Indexed arrays
Declare with parentheses, access with [i], expand with [@]:
arr=(alpha beta gamma)
echo "${arr[0]}" # alpha
echo "${arr[2]}" # gamma
echo "${arr[@]}" # alpha beta gamma (each element a separate word)
echo "${arr[*]}" # alpha beta gamma (joined with first IFS char)
echo "${#arr[@]}" # 3 — element count
echo "${#arr[0]}" # 5 — length of element 0 (chars in "alpha")
echo "${arr[@]:1:2}" # beta gamma — slice offset 1, length 2
arr+=(delta) # append
arr[10]="far" # sparse OK; ${arr[1]}..${arr[9]} stay unset
unset 'arr[2]' # remove gamma; index 2 becomes a hole, NOT renumbered
Two pitfalls jump out:
${#arr}is NOT the array length. It's${#arr[0]}— length of element zero. Use${#arr[@]}for count.unset 'arr[2]'leaves a hole. Indices don't compact. Iterate via${arr[@]}(which skips holes) instead offor ((i=0; i<${#arr[@]}; i++))(which sees the wrong count).
The [@] vs [*] rule
The most important array rule:
"${arr[@]}" # ✅ each element a separate word — preserves whitespace inside elements
"${arr[*]}" # joined with first character of IFS into a single word
${arr[@]} # ❌ each element re-split on IFS — corrupts elements with spaces
Always quote "${arr[@]}". Bare ${arr[@]} is the bug behind "my script worked until someone had a space in their filename".
Iterating
for elem in "${arr[@]}"; do
echo "got: $elem"
done
# Index + value:
for i in "${!arr[@]}"; do
echo "$i → ${arr[$i]}"
done
${!arr[@]} is the list of indices (skipping holes). The ! prefix means "names of", and indices are the names of an indexed array's elements.
Associative arrays — bash 4+ only
The string-keyed map. The declare -A is mandatory:
declare -A colors
colors[red]="#ff0000"
colors[green]="#00ff00"
colors[blue]="#0000ff"
echo "${colors[red]}" # #ff0000
echo "${!colors[@]}" # red green blue (keys, order undefined)
echo "${colors[@]}" # #ff0000 #00ff00 #0000ff (values, same order as keys)
echo "${#colors[@]}" # 3 — element count
# Iterate:
for key in "${!colors[@]}"; do
echo "$key → ${colors[$key]}"
done
The "skip the declare -A" bug is brutal:
# WITHOUT declare -A:
colors[red]="#ff0000" # bash treats `red` as arithmetic → 0
colors[green]="#00ff00" # `green` → 0 → overwrites
echo "${colors[@]}" # #00ff00 ← only one element!
bash 3 (the system bash on macOS — Apple froze it at 3.2 for licensing reasons) has no associative arrays. If you need them on macOS, install bash 4+ via Homebrew, or write the script in zsh (which has them natively), or move to Python.
Sparse arrays and gotchas
arr=()
arr[5]="five"
echo "${#arr[@]}" # 1 — element count, not max index
echo "${!arr[@]}" # 5 — index list
echo "${arr[2]}" # (empty)
Sparse arrays mean you can't safely write for ((i=0; i<${#arr[@]}; i++)) — you'll iterate i=0 (empty) and miss i=5. Always use for elem in "${arr[@]}" or for i in "${!arr[@]}".
Strings as quasi-arrays
Sometimes you have a string with a delimiter and want array semantics. The cleanest way:
csv="alpha,beta,gamma"
IFS=',' read -ra parts <<< "$csv"
echo "${parts[1]}" # beta
read -ra reads into array parts, splitting on the (locally scoped) IFS. The here-string <<< feeds the value as stdin without a temp file or pipe.
zsh has nicer syntax:
parts=(${(s:,:)csv}) # split on ","; one expression
echo "${parts[2]}" # beta (zsh arrays are 1-indexed by default!)
Note that bash arrays are 0-indexed, zsh arrays are 1-indexed. zsh provides setopt KSH_ARRAYS to switch to 0-indexed for compat.
Sorting and uniquifying
Bash has no built-in sort. Pipe to coreutils:
sorted=()
while IFS= read -r line; do
sorted+=("$line")
done < <(printf '%s\n' "${arr[@]}" | sort -u)
The < <(...) is process substitution (covered in lesson 06). It treats the output of a command as a file. The IFS= read -r is the canonical safe-read incantation: IFS= keeps leading/trailing whitespace, -r disables backslash escapes.
If you find yourself doing this for a 10-element array, you've passed the bash boundary.
When to leave bash
If you need:
- Arrays of arrays / dicts of arrays.
- Sorting with custom keys.
- Random access with O(1) lookup over thousands of keys (bash associative arrays scale poorly past ~10k).
- Anything that needs to round-trip through JSON or YAML.
…the answer is Python (or jq for one-liners). A bash script with five array operations is a Python script with one — and the Python version will be easier to test.
Bash vs zsh — array wins
zsh's array story is genuinely better:
- 1-indexed by default (configurable via
KSH_ARRAYS). Matches mathematical / linguistic convention; surprises bash users. - Subscript flags:
${(s/sep/)var}to split,${(j/sep/)arr}to join,${(u)arr}for unique,${(o)arr}for sorted. One-line operations. - Glob qualifiers:
*.log(om[1])is "the most-recently-modified.logfile" as a single expansion. Bash needsls -t *.log | head -n1. - No accidental word-splitting. zsh doesn't re-split unquoted variables, so
for f in $filesworks forfiles="a b c"as a single string (treats it as one word) — different from bash.
The trade-off: zsh-isms don't run on bash. If your script has a #!/usr/bin/env zsh shebang, you're fine. If it's #!/usr/bin/env bash, stay portable.
Common bugs
Forgot the declare -A. Symptom: only the last colors[key]=val "sticks", because every key resolves to index 0. Fix: declare -A colors before the first assignment.
Bare $arr instead of "${arr[@]}". $arr is ${arr[0]} — the first element, not the whole array. Almost never what you want.
Sparse iteration with ((i<${#arr[@]})). Holes mean count != max index. Use "${arr[@]}" or "${!arr[@]}".
Reading lines into an array with for line in $(cat file). Word-splits on whitespace, not newlines. Correct: mapfile -t lines < file (bash 4+) or while IFS= read -r line; do lines+=("$line"); done < file.
Tools in the wild
3 tools- cliShellCheckfree tier
Catches the bare `${arr[@]}` (no quotes) bug that loses elements containing spaces.
- librarybash-itfree tier
Themed shell framework with utility libraries — array helpers, prompt theming, completions for ~70 tools.
- libraryOh My Zshfree tier
The zsh equivalent of bash-it. 300+ plugins lean on zsh's nicer array semantics.