bash · level 2

Arrays & Strings

Indexed arrays, associative arrays, slicing, length — and where zsh's array semantics quietly win.

150 XP

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:

  1. ${#arr} is NOT the array length. It's ${#arr[0]} — length of element zero. Use ${#arr[@]} for count.
  2. unset 'arr[2]' leaves a hole. Indices don't compact. Iterate via ${arr[@]} (which skips holes) instead of for ((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 .log file" as a single expansion. Bash needs ls -t *.log | head -n1.
  • No accidental word-splitting. zsh doesn't re-split unquoted variables, so for f in $files works for files="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
  • ShellCheckfree tier

    Catches the bare `${arr[@]}` (no quotes) bug that loses elements containing spaces.

    cli
  • bash-itfree tier

    Themed shell framework with utility libraries — array helpers, prompt theming, completions for ~70 tools.

    library
  • Oh My Zshfree tier

    The zsh equivalent of bash-it. 300+ plugins lean on zsh's nicer array semantics.

    library