If you’ve spent years writing shell that pretends arrays don’t exist — using space-separated strings as fake lists, parsing the output of ls, or shoving “structured” data into colon-separated KEY=VALUE blobs — this is the lesson that changes how you write shell. Bash has had real indexed arrays since the early 90s and real associative arrays (hashes) since version 4 (released 2009). They are first-class values, fast, and dramatically safer than every string-based hack people use to avoid them.
This lesson covers both kinds of array, the iteration patterns that go with them, the mapfile/readarray builtin for reading lines into an array safely, the all-important "${arr[@]}" vs "${arr[*]}" distinction, and the file-list collection pattern that should replace every for f in $(ls) you’ve ever written.
A few macOS users will hit a wall here: macOS ships with bash 3.2 (it’s been frozen there since 2007 because of GPLv3 licensing). Associative arrays don’t work on stock macOS bash. The fix is brew install bash, then either chsh -s /opt/homebrew/bin/bash or just call your scripts with the new bash. Every example in this lesson assumes bash 4+; we’ll note bash 3.2 limitations where they matter.
In a nutshell
An array is a variable that holds many values instead of one. Think of a variable like a single labelled cardboard box — it holds one thing. An array is a filing cabinet: one label on the front (FRUITS), many drawers inside, each holding one item. There are two kinds of cabinet. An indexed array has drawers numbered 0, 1, 2, … — you pull “drawer 2.” An associative array (a “hash” or “map”) has drawers labelled by name — you pull the drawer marked "role" and get back "engineer". That’s the entire idea; everything else is syntax for putting things in and taking them out.
Why should a beginner care? Because the moment you have “a list of things” — a list of files, users, servers, log lines, arguments — the string-hack way of doing it (FILES="a.txt b.txt c.txt" and hoping spaces never appear) breaks the instant a filename contains a space, a newline, or a *. A real array keeps each item in its own drawer, byte-for-byte, so it simply cannot be mangled by the shell’s word-splitting. Learning arrays is the single biggest jump in safety you will make in this course.
There is one discipline you must build a reflex for, and this whole lesson keeps circling back to it: when you take everything out of the cabinet, you must say how many things you want it to become. Write "${arr[@]}" and you get back N separate items (the right choice almost always — for passing a list of arguments to a command). Write "${arr[*]}" and you get back one glued-together string (right only for printing a summary line). And a bare $arr — the beginner’s favourite mistake — quietly gives you back only the first drawer, not the cabinet. Get that trio straight and you have the hard part.
Level: Beginner → Intermediate · Time: ~40 min. Bring Variables, quoting & IFS and Loops; we lean on both.
Read the diagram left to right: raw lines are loaded by mapfile into an array, stored as either an indexed or associative collection, transformed as a whole without per-element forks, and finally expanded — where the @ vs * choice decides whether you get many arguments or one string.
Prerequisites & what you’ll be able to do
Before this lesson you should be comfortable with simple variables and ${VAR} expansion, the difference quotes make (word-splitting), and IFS from Variables, quoting & IFS; basic for loops from Loops; and local scope from Functions & local scope. If parameter expansion (${VAR##*/}, ${VAR:-default}, ${VAR:offset:length}) is fuzzy, skim the variables lesson first — arrays reuse all of it.
After this lesson you will be able to:
- Build, append to, index, slice, and iterate indexed arrays correctly, and know why a bare
$arris not the array. - Use associative arrays as maps and sets, remembering the
declare -A-first rule that trips up almost everyone. - Load files and command output into arrays byte-safely with
mapfile/readarray, including the NUL-delimitedfind -print0pattern for filename-proof iteration. - Apply the
"${arr[@]}"vs"${arr[*]}"cardinality discipline on reflex, and forward a list of arguments to any command without it splitting or gluing. - Sort, deduplicate, and transform whole arrays with no per-element
fork, and spot the bash-3.2 / bash-4.4 portability edges before they bite in production.
1. Indexed arrays: the basics
An indexed array is an ordered collection of strings, indexed by non-negative integers starting at 0. Bash arrays are sparse — you can have indices 0, 1, 5, 100 with nothing in between, and the array still works.
Declaration and assignment
# Implicit declaration with the parentheses syntax
FRUITS=(apple banana cherry)
# Explicit declaration
declare -a FRUITS=(apple banana cherry)
# Empty array
EMPTY=()
# With explicit indices (allows sparse arrays)
SPARSE=([0]=zero [5]=five [10]=ten)
# With computed values
TODAY=$(date +%a)
DAYS=(Mon Tue Wed Thu Fri Sat Sun "$TODAY")
The parentheses-with-spaces syntax is the canonical form. Each space-separated token becomes one element. The same word-splitting and quoting rules from L2 apply — quote elements that contain spaces:
NAMES=(alice bob "carol jones" david)
echo "${#NAMES[@]}" # 4 — four elements
echo "${NAMES[2]}" # carol jones
If you wrote NAMES=(alice bob carol jones david) (no quotes around "carol jones"), you’d get five elements with carol and jones as separate tokens.
Accessing elements
FRUITS=(apple banana cherry)
echo "${FRUITS[0]}" # apple — first element (index 0!)
echo "${FRUITS[1]}" # banana
echo "${FRUITS[2]}" # cherry
echo "${FRUITS[-1]}" # cherry — bash 4.3+: negative indices count from end
echo "${FRUITS[-2]}" # banana
echo "${FRUITS}" # apple — same as ${FRUITS[0]}, NOT all elements!
The last form is a notorious pitfall. $FRUITS and ${FRUITS} and ${FRUITS[0]} are all the same — they give you the first element, not the array. To get all elements, you need the special [@] or [*] index. We’ll cover the difference between those in the next section.
Getting all elements
FRUITS=(apple banana cherry)
echo "${FRUITS[@]}" # apple banana cherry — all elements as separate words
echo "${FRUITS[*]}" # apple banana cherry — all elements joined by IFS[0]
echo "${#FRUITS[@]}" # 3 — number of elements
echo "${!FRUITS[@]}" # 0 1 2 — list of indices (note the leading !)
The [@] form expands to one shell-word per element. The [*] form joins all elements with the first character of IFS (default: a space). When unquoted, the difference is invisible. When quoted, the difference is everything. Section 4 of this lesson is dedicated to that distinction — don’t skim it.
Appending
FRUITS=(apple banana)
FRUITS+=(cherry) # append one
FRUITS+=(date elderberry) # append several
echo "${FRUITS[@]}" # apple banana cherry date elderberry
The += operator with (...) appends. The parentheses are essential — without them, += does string concatenation on the first element only:
FRUITS+=cherry # WRONG: appends "cherry" to ${FRUITS[0]}, giving "applecherry"
echo "${FRUITS[0]}" # applecherry
Always use arr+=(value) to append, not arr+=value.
Modifying and deleting
FRUITS=(apple banana cherry)
FRUITS[1]="berry" # replace index 1
echo "${FRUITS[@]}" # apple berry cherry
unset 'FRUITS[1]' # remove index 1
echo "${FRUITS[@]}" # apple cherry
echo "${#FRUITS[@]}" # 2
echo "${!FRUITS[@]}" # 0 2 — note: 1 is gone, 2 didn't shift down (sparse!)
unset 'FRUITS[1]' removes the element but does not renumber the remaining elements. The array is now sparse. This is sometimes surprising. To get a “compact” array after deletion:
FRUITS=("${FRUITS[@]}") # rebuild without gaps
echo "${!FRUITS[@]}" # 0 1 — now indices are contiguous
The quoting around 'FRUITS[1]' matters: bash’s globbing might expand [1] as a character class against files in your current directory, breaking the unset. Always quote the argument to unset for arrays.
To delete the entire array:
unset FRUITS # delete the variable entirely
FRUITS=() # set to empty (variable still defined as array)
2. Iterating over arrays
The canonical iteration form:
FRUITS=(apple banana cherry)
for fruit in "${FRUITS[@]}"; do
echo "$fruit"
done
Notice the double quotes around "${FRUITS[@]}". This expansion produces one quoted shell-word per array element, preserving every byte of every element including spaces, tabs, newlines. This is the only correct iteration form for arrays.
Without quotes:
NAMES=(alice "bob jones" carol)
for n in ${NAMES[@]}; do # WRONG: bash word-splits each element, giving 4 names
echo "$n"
done
# Output:
# alice
# bob
# jones
# carol
With quotes:
for n in "${NAMES[@]}"; do # CORRECT: 3 names preserved
echo "$n"
done
# Output:
# alice
# bob jones
# carol
This is the same lesson as L2 — quoting suppresses word splitting. Arrays don’t change the rule.
Iterating with index access
FRUITS=(apple banana cherry)
for i in "${!FRUITS[@]}"; do
echo "${i}: ${FRUITS[$i]}"
done
# Output:
# 0: apple
# 1: banana
# 2: cherry
${!FRUITS[@]} expands to the list of indices. Useful when you need both the index and the value (like Python’s enumerate).
Iterating with a counter
FRUITS=(apple banana cherry)
for ((i=0; i<${#FRUITS[@]}; i++)); do
echo "${i}: ${FRUITS[$i]}"
done
C-style loop for when you want to skip elements, walk in reverse, or process in pairs:
# Process in pairs of two
PAIRS=(name alice age 30 role engineer)
for ((i=0; i<${#PAIRS[@]}; i+=2)); do
echo "${PAIRS[$i]} = ${PAIRS[$i+1]}"
done
For most cases, for x in "${arr[@]}" is cleaner. Drop to indexed iteration only when you need the index for arithmetic.
3. Associative arrays (bash 4+ hashes)
An associative array (also called a “hash” or “map”) indexes by string keys rather than integer positions. You declare them with declare -A:
declare -A USER
USER[name]="alice"
USER[age]=30
USER[role]="engineer"
echo "${USER[name]}" # alice
echo "${USER[age]}" # 30
Or with the parentheses form:
declare -A CAPITALS=(
[USA]=Washington
[UK]=London
[France]=Paris
[Japan]=Tokyo
)
echo "${CAPITALS[USA]}" # Washington
echo "${CAPITALS[Japan]}" # Tokyo
Critical: you must declare -A before assigning. Without declare -A, bash treats USER[name]=alice as an indexed-array assignment with the string name evaluated as an arithmetic expression (which gives 0). You’d get USER[0]=alice — silently wrong.
# WRONG — without declare -A first
USER[name]="alice" # assigns to USER[0] because "name" arithmetic-evaluates to 0
echo "${USER[anything]}" # also "alice" — every key looks like 0
echo "${USER[name]}" # alice (still index 0)
# RIGHT
declare -A USER
USER[name]="alice"
echo "${USER[name]}" # alice
echo "${USER[role]:-unknown}" # unknown
This is one of the most common bugs in associative-array code. Always declare -A first. When in doubt, do it explicitly at the top of your function:
process_user() {
declare -A user # local-scoped associative array
user[name]="$1"
user[age]="$2"
# ...
}
In bash 4.4+ you can combine: declare -A is implicitly local inside a function, but local -A user is more explicit and works in all bash 4.x.
Iterating
Iteration is the same shape as indexed arrays:
declare -A CAPITALS=(
[USA]=Washington
[UK]=London
[France]=Paris
)
# Iterate over keys
for country in "${!CAPITALS[@]}"; do
echo "${country}: ${CAPITALS[$country]}"
done
The ${!ARR[@]} form gives the list of keys for both indexed and associative arrays — for indexed, those are integers; for associative, they’re strings.
Key order is unspecified. Bash does not guarantee any particular ordering for associative-array iteration. If you need sorted output:
for country in $(echo "${!CAPITALS[@]}" | tr ' ' '\n' | sort); do
echo "${country}: ${CAPITALS[$country]}"
done
Or, more robustly with a real array:
mapfile -t SORTED_KEYS < <(printf '%s\n' "${!CAPITALS[@]}" | sort)
for country in "${SORTED_KEYS[@]}"; do
echo "${country}: ${CAPITALS[$country]}"
done
Membership test
declare -A FLAGS=([verbose]=1 [debug]=1)
if [[ -v FLAGS[verbose] ]]; then
echo "verbose is set"
fi
if [[ -n "${FLAGS[debug]+x}" ]]; then
echo "debug exists (POSIX-friendly form)"
fi
The -v test (bash 4.2+) checks “is this variable/key set” — works for both regular variables and array keys. The ${VAR+x} form is the older trick: expands to the literal x if VAR is set, empty if not.
Counting
echo "${#CAPITALS[@]}" # number of key-value pairs
Removing a key
unset 'CAPITALS[USA]' # delete one key
echo "${!CAPITALS[@]}" # USA is gone
The same quoting rule applies: quote the argument to prevent globbing.
4. The "${arr[@]}" vs "${arr[*]}" distinction — read this carefully
This is the single most-misunderstood piece of array syntax in bash. It also appears for "$@" vs "$*" (which we covered in L2), and for the same reason. The rule is clean once you see it.
ARR=(a b "c d" e)
| Expansion | Behaviour |
|---|---|
${ARR[@]} |
Splits each element on IFS; effectively gives 5 words: a b c d e |
${ARR[*]} |
Joins all elements with first char of IFS, then splits on IFS; same 5 words |
"${ARR[@]}" |
Each element becomes one quoted word; 4 args: a, b, c d, e |
"${ARR[*]}" |
All elements joined by first char of IFS; 1 quoted word: a b c d e |
The rule:
"${arr[@]}"— use this when forwarding array elements as separate arguments to commands. Each element stays distinct."${arr[*]}"— use this when you want the array flattened to a single string with elements joined by IFS. Logging, debug output, single-line summaries.
Practical examples:
ARGS=(--verbose --port 8080 "--name=Alice Smith")
# CORRECT — 4 separate arguments, "--name=Alice Smith" stays intact
my-tool "${ARGS[@]}"
# WRONG — 1 argument: "--verbose --port 8080 --name=Alice Smith"
my-tool "${ARGS[*]}"
# WRONG — 5+ arguments because "--name=Alice Smith" word-splits
my-tool ${ARGS[@]}
When in doubt: use "${arr[@]}". The [*] form is correct only when you specifically want a single string.
Joining elements with a custom separator
The first character of IFS controls the join character for "${arr[*]}". So you can join with any character:
ARR=(one two three)
IFS=',' ; echo "${ARR[*]}" # one,two,three
IFS=' | ' ; echo "${ARR[*]}" # one |two |three (only first char of IFS used)
IFS=$'\n' ; echo "${ARR[*]}" # one<NL>two<NL>three
The first-character rule is annoying — you can’t join with a multi-character separator this way. For multi-char joins, use printf or a loop:
join_by() {
local sep="$1"; shift
local out=""
local first=1
local elem
for elem in "$@"; do
if (( first )); then
out="$elem"
first=0
else
out+="${sep}${elem}"
fi
done
printf '%s' "$out"
}
ARR=(one two three)
join_by ' | ' "${ARR[@]}" # one | two | three
This is a common helper to keep around.
5. Slicing and substring operations
Bash supports slicing on arrays with the same syntax as substring extraction on strings (covered in L2).
ARR=(zero one two three four five)
echo "${ARR[@]:1:3}" # one two three — start at index 1, take 3 elements
echo "${ARR[@]:2}" # two three four five — start at index 2 to end
echo "${ARR[@]: -2}" # four five — last 2 (note the leading space!)
The slice syntax: ${arr[@]:OFFSET:LENGTH}. Both are arithmetic expressions. Negative offsets count from the end (need a leading space to disambiguate from :-default).
For associative arrays, slicing on [@] doesn’t quite work the same way (key order is unspecified), but slicing on the list of keys does:
declare -A USER=([name]=alice [age]=30 [role]=engineer)
KEYS=("${!USER[@]}") # snapshot keys into an indexed array
echo "${KEYS[@]:0:2}" # first two keys (whatever those are)
String operations on array elements
All the parameter-expansion operators from L2 work on array elements — and on the entire array at once with [@].
PATHS=(/var/log/a.log /var/log/b.log /tmp/c.log)
# Apply a transformation to every element
echo "${PATHS[@]##*/}" # a.log b.log c.log — basename of every element
echo "${PATHS[@]%.log}" # /var/log/a /var/log/b /tmp/c — strip .log suffix
echo "${PATHS[@]/log/LOG}" # /var/LOG/a.log /var/LOG/b.log /tmp/c.LOG — replace first "log" each
echo "${PATHS[@]//log/LOG}" # /var/LOG/a.LOG /var/LOG/b.LOG /tmp/c.LOG — replace all "log"s
This is enormously powerful. You can do basename, dirname, replacement, case folding, and length operations across an entire array in one expansion, with no fork. It’s much faster than piping through sed or awk.
# Fast: bash builtin, no fork
LOWERED=("${ARR[@]}")
LOWERED=("${LOWERED[@],,}") # all lowercase
# Slow: forks one awk per element
LOWERED=()
for x in "${ARR[@]}"; do
LOWERED+=("$(echo "$x" | tr '[:upper:]' '[:lower:]')")
done
In a loop with thousands of iterations, the bash-builtin form can be 100× faster.
6. mapfile (also called readarray) — the right way to load files into arrays
mapfile -t ARR < FILE reads a file line by line and stores each line as one array element. The -t flag strips trailing newlines from each line (almost always what you want).
mapfile -t LINES < /etc/hostname
echo "${LINES[0]}" # the hostname
echo "${#LINES[@]}" # 1
mapfile -t USERS < users.txt
for u in "${USERS[@]}"; do
echo "Processing user: $u"
done
mapfile is the modern, byte-safe replacement for while read line; do arr+=("$line"); done and the various arr=( $(cat file) ) hacks people write. It handles every line correctly: leading whitespace, trailing whitespace, embedded glob characters — every edge case is handled by being byte-exact and not subject to word splitting.
Reading from a command instead of a file
mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend | awk '{print $1}')
echo "${#SERVICES[@]}"
The < <(cmd) is process substitution (covered in L4 and revisited in L7). It runs cmd and presents its output as a file the array can read from. The whole thing happens in the parent shell, so the array is populated correctly (no subshell trap from L4).
Useful mapfile flags
-t— strip trailing newlines (almost always wanted).-n N— read at most N lines.-s N— skip the first N lines.-d DELIM— use DELIM instead of newline (bash 4.4+). With-d ''(empty), reads NUL-separated input. Combine withfind -print0for filename-safe iteration.-O N— start storing at index N (so you can append to an existing array).-c Nand-C CALLBACK— call CALLBACK every N lines (rarely used).
The NUL-separated form is essential for filename-safe collection:
mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)
for f in "${FILES[@]}"; do
echo "Processing: $f"
done
This handles every legal filename, including ones with newlines or weird characters. This is the right way to collect a list of files into an array — far better than parsing find’s output as text.
readarray is exactly the same builtin as mapfile. Bash defines them as aliases. Use whichever name you prefer; this course uses mapfile.
7. Sorting, deduplicating, and the read-into-array idioms
Bash has no built-in sort. You shell out to sort:
NAMES=(charlie alice bob alice david)
# Sort alphabetically, in place
mapfile -t SORTED < <(printf '%s\n' "${NAMES[@]}" | sort)
echo "${SORTED[@]}" # alice alice bob charlie david
# Sort and dedupe
mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | sort -u)
echo "${UNIQUE[@]}" # alice bob charlie david
# Numeric sort
NUMBERS=(10 2 30 4 100)
mapfile -t SORTED_NUM < <(printf '%s\n' "${NUMBERS[@]}" | sort -n)
echo "${SORTED_NUM[@]}" # 2 4 10 30 100
# Reverse sort
mapfile -t SORTED_REV < <(printf '%s\n' "${NAMES[@]}" | sort -r)
The printf '%s\n' "${arr[@]}" idiom is worth memorising — it prints each array element on its own line, byte-exact. Combined with mapfile -t ... < <(...), it gives you a clean “transform array via Unix tool” pipeline.
Deduplication preserving original order
NAMES=(charlie alice bob alice david bob)
mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | awk '!seen[$0]++')
echo "${UNIQUE[@]}" # charlie alice bob david
awk '!seen[$0]++' is a famous one-liner: tracks each line in seen, prints it the first time only. Preserves original order, unlike sort -u.
Membership test (linear scan)
Bash has no built-in “is this element in the array” check. The simple form:
contains() {
local needle="$1"; shift
local x
for x in "$@"; do
[[ "$x" == "$needle" ]] && return 0
done
return 1
}
FRUITS=(apple banana cherry)
if contains "banana" "${FRUITS[@]}"; then
echo "found"
fi
This is O(N) — fine for arrays under a few thousand elements. For larger collections where membership tests are frequent, use an associative array as a set:
declare -A FRUIT_SET=([apple]=1 [banana]=1 [cherry]=1)
if [[ -n "${FRUIT_SET[banana]:-}" ]]; then
echo "found"
fi
O(1) lookup. The associative array is the right choice for set semantics.
8. The file-list collection pattern
This is the canonical pattern this course wants you to know cold. It replaces every “iterate over files” hack you’ve ever seen.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Step 1: collect files into an array, NUL-safe
mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)
# Step 2: report the count before doing anything destructive
echo "Found ${#FILES[@]} log files"
# Step 3: iterate safely
for f in "${FILES[@]}"; do
echo "Processing: $f"
# do something with $f
done
# Step 4: bulk operations are also safe
gzip -- "${FILES[@]}"
Why this is the right shape:
find -print0+mapfile -d ''handles every legal filename (including newlines, glob chars, spaces).-type ffilters out directories and weird filesystem entries before we even look at them.- The intermediate array is inspectable. You can
echo "${#FILES[@]}"to see how many you got, before doing anything destructive. This is the single most-important guard against accidental mass-modification. gzip -- "${FILES[@]}"invokesgziponce with all filenames as arguments, instead of one fork per file. Hugely faster for many files.
Compare to the wrong forms you may have written before:
for f in $(ls *.log); do gzip "$f"; done # WRONG: spaces, globs, fork-per-file
for f in *.log; do gzip "$f"; done # OK for one directory; no recursion; doesn't handle empty match
find . -name '*.log' -exec gzip {} \; # forks per file; slower
find . -name '*.log' | while read f; do ...; done # subshell trap from L4
The array-pattern form gets all of these right and is the canonical advanced-shell idiom.
9. Real-world example: a service-status report
#!/usr/bin/env bash
# service-report.sh — print a sorted table of services with their statuses
set -euo pipefail
IFS=$'\n\t'
# 1. Collect service names into an indexed array
mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend --no-pager \
| awk '{print $1}' | sort)
# 2. Build a parallel associative array of statuses
declare -A STATUS
for svc in "${SERVICES[@]}"; do
STATUS[$svc]=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
done
# 3. Print a summary
COUNT_RUNNING=0
COUNT_FAILED=0
COUNT_OTHER=0
printf '%-50s %s\n' "Service" "Status"
printf '%-50s %s\n' "-------" "------"
for svc in "${SERVICES[@]}"; do
printf '%-50s %s\n' "$svc" "${STATUS[$svc]}"
case "${STATUS[$svc]}" in
active) (( COUNT_RUNNING++ )) ;;
failed) (( COUNT_FAILED++ )) ;;
*) (( COUNT_OTHER++ )) ;;
esac
done
# 4. Footer
echo
echo "Running: $COUNT_RUNNING"
echo "Failed: $COUNT_FAILED"
echo "Other: $COUNT_OTHER"
# 5. Exit non-zero if anything is failed
(( COUNT_FAILED == 0 ))
Things to notice:
mapfile -t SERVICES < <(...)to load services safely.declare -A STATUSfor the parallel hash.- Iteration uses
"${SERVICES[@]}"and${STATUS[$svc]}— every quoting rule is in play. - Counting in the parent shell (no pipe-into-while subshell trap).
- The script’s own exit code reflects the failure count: success only if zero failed.
- Strict mode and IFS hardening at the top.
This is the shape of structured shell code with arrays. It’s clean, fast, byte-safe, and readable.
10. Pitfalls and edge cases
Bash 3.2 (macOS) — no associative arrays
If your script must run on stock macOS bash, you can’t use declare -A. Workarounds:
- Use parallel indexed arrays: one for keys, one for values.
- Use a serialised string with a separator:
KEY1=value1;KEY2=value2. - Install bash 4+ via Homebrew:
brew install bash.
The cleanest fix for any non-trivial script is brew install bash and shebang as #!/usr/bin/env bash (which finds /opt/homebrew/bin/bash ahead of /bin/bash). Don’t try to write portable shell that supports bash 3.2; it’s not worth the contortions.
The [*] vs [@] pitfall, again
ARR=(one "two three" four)
cmd ${ARR[@]} # 4 args: one two three four
cmd ${ARR[*]} # 4 args: one two three four (same — IFS-split after expansion)
cmd "${ARR[@]}" # 3 args: one, two three, four
cmd "${ARR[*]}" # 1 arg: "one two three four"
Default to "${ARR[@]}". Use "${ARR[*]}" only when you specifically want a single joined string.
${ARR} is not the array
ARR=(one two three)
echo "${ARR}" # "one" — first element only
echo "${ARR[@]}" # "one two three" — all elements
A bare $ARR or ${ARR} is ${ARR[0]}. This is a constant source of bugs; if you mean “the array”, spell it "${ARR[@]}".
Spaces around = in declare -A
The same rule as L2 — no spaces around =. But there’s a twist for arrays:
declare -A USER=([name]=alice) # WORKS
declare -A USER = ([name]=alice) # WRONG: bash interprets this differently
# Inside an array assignment, no quotes around the key in [...]:
declare -A USER=([name]="Alice Smith") # WORKS
# Quoted key works too:
declare -A USER=(["name"]="Alice Smith") # WORKS
When in doubt, write the elements one per line for clarity:
declare -A USER=(
[name]="Alice Smith"
[age]=30
[role]=engineer
)
Iterating over associative arrays gives keys, not values
declare -A CAPITALS=([USA]=Washington [UK]=London)
for c in "${CAPITALS[@]}"; do # Washington London (values, in indeterminate order)
echo "$c"
done
for c in "${!CAPITALS[@]}"; do # USA UK (keys)
echo "$c -> ${CAPITALS[$c]}"
done
Almost always you want ${!ARR[@]} (keys) and look up the value by key. Pure value iteration is rare for hashes.
Sorting is always external
There is no built-in array sort in bash. You always go through sort. The pattern is:
mapfile -t SORTED < <(printf '%s\n' "${UNSORTED[@]}" | sort [-options])
For numeric sort, sort -n. For reverse, sort -r. For sort-and-dedupe, sort -u. For preserving original order while deduping, awk '!seen[$0]++'.
11. Fourteen array idioms to memorise
# 1. Declare and populate
ARR=(one two three)
# 2. Append
ARR+=(four)
ARR+=(five six)
# 3. Length
COUNT="${#ARR[@]}"
# 4. All elements (the canonical iteration)
for x in "${ARR[@]}"; do
echo "$x"
done
# 5. List of indices / keys
for i in "${!ARR[@]}"; do
echo "${i}: ${ARR[$i]}"
done
# 6. Slice
FIRST_TWO=("${ARR[@]:0:2}")
# 7. Last element (bash 4.3+)
LAST="${ARR[-1]}"
# 8. Apply transformation across array
LOG_PATHS=(/var/log/a.log /var/log/b.log)
BASENAMES=("${LOG_PATHS[@]##*/}") # a.log b.log
# 9. Read file lines into array
mapfile -t LINES < file.txt
# 10. Read NUL-separated input (filename-safe)
mapfile -d '' -t FILES < <(find /path -type f -print0)
# 11. Sort
mapfile -t SORTED < <(printf '%s\n' "${ARR[@]}" | sort)
# 12. Deduplicate, preserving order
mapfile -t UNIQUE < <(printf '%s\n' "${ARR[@]}" | awk '!seen[$0]++')
# 13. Associative array (set membership)
declare -A SET=([apple]=1 [banana]=1)
[[ -n "${SET[banana]:-}" ]] && echo "in set"
# 14. Forwarding to a command
my-command "${ARR[@]}"
Internalise these and you’ve replaced 90% of the string-hack code you’d otherwise write.
12. What you must internalise before lesson 7
- Why is
$ARRnot the same as"${ARR[@]}"? ($ARRis just${ARR[0]}; the array expansion needs[@].) - What’s the difference between
"${ARR[@]}"and"${ARR[*]}"? ([@]expands to one quoted word per element;[*]joins all elements with first char of IFS into one word.) - Why must you
declare -Abefore assigning to an associative array? (Otherwise bash treats keys as arithmetic expressions, silently giving every key the index 0.) - What does
mapfile -t -d '' ARR < <(find ... -print0)do? (Loads NUL-separated input — every legal filename — into ARR, line by line, with newlines stripped.) - What does
${PATHS[@]##*/}do? (Apply parameter expansion across every array element — here, basename.) - How do you remove an element? (
unset 'ARR[index]'. Quote the argument to prevent globbing.) - What’s the right way to test if an element is in an associative array? (
[[ -v ARR[key] ]]in bash 4.2+, or[[ -n "${ARR[key]+x}" ]]for older bash.) - How do you sort an array? (External:
mapfile -t SORTED < <(printf '%s\n' "${ARR[@]}" | sort).) - How do you dedupe preserving original order? (
awk '!seen[$0]++'.) - What’s the right way to forward array elements to a command? (
cmd "${ARR[@]}"— never unquoted, never[*].)
If any felt fuzzy, re-read. With L1–L6 you have all of bash’s data-handling primitives. L7 takes you into I/O — file descriptors, redirection, here-docs, process substitution.
Going deeper: internals, namerefs, serialization & the traps that only bite in production
Everything above makes you productive. This section makes you dangerous — the internals and edge cases that separate someone who uses arrays from someone who can debug a colleague’s broken array code at 2 a.m.
Why a bare name is element 0 (the internal reason)
$ARR giving you ${ARR[0]} is not a special case bash bolted on — it is the rule. In bash, ARR and ARR[0] name the same storage cell; the subscript simply defaults to 0 when you omit it. There is genuinely no such thing as “the scalar value of an array” — an array is its set of subscripted cells, and a nameless reference picks the zero cell. Once you internalise “the name alone is always subscript 0,” the whole family of bugs (echo $PATH_ARRAY, [[ -n $ARR ]] to test emptiness, x=$ARR) stops surprising you. To ask “is this array empty?” you check the count, never the bare name:
(( ${#ARR[@]} == 0 )) && echo "empty" # correct emptiness test
[[ -z "$ARR" ]] && echo "empty" # WRONG: only tests whether element 0 is empty
The empty-array + set -u trap (a real production landmine)
This one is subtle and version-dependent, and it has broken countless set -euo pipefail scripts. On bash before 4.4, expanding "${arr[@]}" (or "${arr[*]}") when the array is empty, under set -u (nounset), raises unbound variable and aborts the script:
set -u
EMPTY=()
for x in "${EMPTY[@]}"; do echo "$x"; done
# bash 3.2 / 4.0–4.3: bash: EMPTY[@]: unbound variable → script dies
# bash 4.4+: loop body simply runs zero times → fine
(That error is reproducible on stock macOS bash 3.2 today.) If you support older bash, guard the expansion. There are two idioms, and the difference between them matters:
# Idiom A — the "+" alternate form: expands to NOTHING when unset/empty, so zero iterations. CORRECT.
for x in ${EMPTY[@]+"${EMPTY[@]}"}; do echo "[$x]"; done
# Idiom B — the ":-" default form: for a truly EMPTY array this injects ONE empty element!
for x in "${EMPTY[@]:-}"; do echo "[$x]"; done # prints one line: []
Idiom B is fine for “print a placeholder” but wrong for forwarding arguments — cmd "${ARGS[@]:-}" passes one empty string argument to cmd when you meant to pass none, which many tools misread. For argument-forwarding on old bash, use idiom A: cmd ${ARGS[@]+"${ARGS[@]}"}. On bash 4.4+ you don’t need either — "${arr[@]}" on an empty array is simply zero words. This is the strongest single reason to standardise on bash 4.4+ and stop supporting 3.2.
Passing arrays to and from functions
Shell functions receive positional parameters — plain strings — so you cannot pass an array as a single argument by value; it flattens. There are three correct techniques, in increasing power:
1. Pass the expanded elements, rebuild inside. The array becomes the function’s "$@":
show_all() {
local -a items=("$@") # rebuild a local copy from positional params
printf 'got %d items\n' "${#items[@]}"
printf ' - %s\n' "${items[@]}"
}
ARR=(alice "bob jones" carol)
show_all "${ARR[@]}" # quoting keeps "bob jones" as one item
Simple and portable, but it loses sparse indices and associative keys (it re-numbers from 0), and you can’t return a modified array this way.
2. Namerefs (declare -n / local -n, bash 4.3+) — operate on the caller’s array in place, by name. This is how you “return” an array:
# Fill the caller's array with the first N even numbers
make_evens() {
local -n out="$1" # nameref: out is an alias for whatever array the caller named
local n="$2" i
out=()
for ((i=0; i<n; i++)); do out+=("$((i*2))"); done
}
declare -a RESULT
make_evens RESULT 4
echo "${RESULT[@]}" # 0 2 4 6 (populated in the caller, no subshell, no serialization)
Namerefs also let you pass an associative array into a function without flattening — local -n map="$1" then iterate "${!map[@]}". Two caveats: namerefs are bash 4.3+ (portability flag), and you must avoid a name collision between the nameref variable and the caller’s variable name, or bash warns about a circular reference — a common gotcha is naming the nameref the same as the argument (local -n arr=arr).
3. Serialize with declare -p. declare -p ARR prints a re-executable declaration; capture it, ship it (across a ssh, into a file, through an eval) and rebuild:
ARR=(a b "c d")
snapshot="$(declare -p ARR)" # declare -a ARR='([0]="a" [1]="b" [2]="c d")'
unset ARR
eval "$snapshot" # rebuilt exactly, keys/sparseness preserved
echo "${#ARR[@]} ${ARR[2]}" # 3 c d
This is the only method that faithfully preserves sparse indices and associative keys. The cost is eval: never eval a declare -p string built from untrusted input — it’s arbitrary code execution. Use it for your own snapshots, not for data that crossed a trust boundary. For plain data that must cross a boundary safely, prefer a NUL-delimited stream (printf '%s\0') read back with mapfile -d ''.
Copying an associative array does not copy its keys
A trap that produces silent data loss:
declare -A SRC=([name]=alice [role]=eng)
declare -A DST=("${SRC[@]}") # WRONG: expands to VALUES only → DST[alice]... nonsense/loss
"${SRC[@]}" yields the values with no keys, so rebuilding an associative array from them is meaningless. To copy an associative array, loop over keys, or serialize:
declare -A DST
for k in "${!SRC[@]}"; do DST["$k"]="${SRC[$k]}"; done # correct key-preserving copy
# or, one line via declare -p with a rename (bash 4.4+ prints the -A flag):
eval "$(declare -p SRC | sed 's/ SRC=/ DST=/')" # careful: your own trusted data only
For indexed arrays, NEW=("${OLD[@]}") is fine (and intentionally compacts a sparse array).
The @-transformation operators (bash 4.4+)
Bash 4.4 added parameter transformations that are gold for array-heavy code — all bash 4.4+, so flag portability:
"${arr[@]@Q}"— quote every element so it’s safe to re-read as shell input (round-trips your array through a log or a generated script)."${arr[@]@A}"— likedeclare -pbut as an assignment string for each element."${arr[@]@k}"(bash 5.1+) — expand key/value pairs of an associative array as an alternating list, handy for rebuilding.
ARGS=(--flag "two words" 'a$b')
printf '%s ' "${ARGS[@]@Q}" # '--flag' 'two words' 'a$b' → safe to paste back
Performance and scale
- Index and key lookup are cheap. Indexed access is O(1); associative arrays are backed by a hash table, so key lookup is ~O(1) too. Use an associative array as a set (
SET[key]=1) whenever you do repeated membership tests — the linearfor-scancontains()from §7 is O(N) per check and turns a loop into O(N²). - Whole-array parameter expansion avoids forks.
"${ARR[@]##*/}","${ARR[@],,}","${ARR[@]/old/new}"transform every element inside bash with zero subprocesses. The equivalentfor-loop-with-tr/sed/awkforks once per element; on thousands of elements the builtin form is routinely 50–100× faster. Staying inside the shell is the whole performance game (the later performance-profiling lesson makes this its thesis). mapfilereads the whole stream into memory. For a 50-line config that’s nothing; for a 50-GB log it will exhaust RAM. When the input is large and you process it line-by-line anyway, stream it withwhile IFS= read -r line; do …; done < fileinstead of materialising an array. Arrays are for collections you need to hold, not for firehoses you merely pass through.
Attributes: readonly, integer, and global arrays
declare flags compose with -a/-A:
declare -ra FROZEN=(prod staging dev) # readonly array — assignment later is an error
declare -ai COUNTS=(1 2 3) # integer array: COUNTS[0]+=5 does arithmetic
declare -gA REGISTRY # -g: create a GLOBAL assoc array from inside a function
declare -g matters inside functions: a plain declare -A there is function-local and vanishes on return, which surprises people building a “global registry” hash from a helper. And declare -ra (readonly) is a nice guard for lookup tables that must never be mutated — including protecting them from a later accidental unset.
IFS hygiene around [*]
Because "${arr[*]}" joins on the first character of IFS, any code that changes IFS to join then forgets to restore it will corrupt later word-splitting. Scope the change instead of mutating global IFS:
csv_of() { local IFS=,; echo "$*"; } # local IFS restored automatically on return
csv_of "${ARR[@]}" # one,two,three — global IFS untouched
A local IFS inside a function is the clean way to get a custom join without a save/restore dance.
Practice challenges
Six graded exercises, escalating from a warm-up to production-grade edge cases. Run each yourself in a bash 4+ shell first (on macOS: brew install bash and run with /opt/homebrew/bin/bash script.sh), then open the solution. The italic line tells you what the exercise is really drilling.
Challenge 1 — Build, count, last element (beginner)
Create an indexed array of three colours. Print how many elements it has, then print the last element without hard-coding the index 2.
<details> <summary>Solution</summary>
COLOURS=(red green blue)
echo "${#COLOURS[@]}" # 3
echo "${COLOURS[-1]}" # blue (negative index, bash 4.3+)
On bash older than 4.3, use "${COLOURS[${#COLOURS[@]}-1]}" instead of [-1].
</details>
Why: ${#arr[@]} is the count and [-1] is the last element — the two most common “quick facts” you pull from an array.
Challenge 2 — Iterate without mangling a spaced element (beginner)
Given NAMES=(alice "bob jones" carol), print each name on its own line so the output is exactly three lines and bob jones stays intact.
<details> <summary>Solution</summary>
NAMES=(alice "bob jones" carol)
for n in "${NAMES[@]}"; do
echo "$n"
done
# alice
# bob jones
# carol
Drop the quotes (for n in ${NAMES[@]}) and you get four lines — bob and jones split apart. The quotes are the whole exercise.
</details>
Why: "${arr[@]}" is the only correct iteration form; this is the single most important array reflex.
Challenge 3 — Load usernames from /etc/passwd into an array (intermediate)
Read the first colon-separated field (the username) of every line in /etc/passwd into an array and print the count. Do it byte-safely, not with arr=( $(...) ).
<details> <summary>Solution</summary>
mapfile -t USERS < <(cut -d: -f1 /etc/passwd)
echo "Found ${#USERS[@]} users"
printf ' - %s\n' "${USERS[@]}"
cut -d: -f1 extracts field 1; mapfile -t stores one username per element and strips newlines. mapfile is bash 4+ — on bash 3.2 the portable fallback is USERS=(); while IFS= read -r u; do USERS+=("$u"); done < <(cut -d: -f1 /etc/passwd).
</details>
Why: the file-into-array reflex, and knowing mapfile’s portability floor plus its while read fallback.
Challenge 4 — Basenames of every path, no per-element fork (intermediate)
Given PATHS=(/var/log/a.log /etc/nginx/nginx.conf /tmp/x.txt), produce a new array NAMES holding just the filenames (a.log, nginx.conf, x.txt) without calling basename (no subprocess per element).
<details> <summary>Solution</summary>
PATHS=(/var/log/a.log /etc/nginx/nginx.conf /tmp/x.txt)
NAMES=("${PATHS[@]##*/}") # strip the longest leading */ from each element
printf '%s\n' "${NAMES[@]}" # a.log / nginx.conf / x.txt
${PATHS[@]##*/} applies the “remove longest prefix matching */” expansion across every element in one shot — pure bash, zero forks. Wrapping it in ( … ) collects the results into a real array.
</details>
Why: whole-array parameter expansion is the fast, fork-free way to transform a collection.
Challenge 5 — Word-frequency counter with an associative array (advanced)
Read words from standard input (whitespace-separated) and print the top 5 most frequent words with their counts, highest first. Use an associative array as the counter.
<details> <summary>Solution</summary>
#!/usr/bin/env bash
set -euo pipefail
declare -A COUNT
while read -r -a words; do # -a reads the line into an indexed array, split on IFS
for w in "${words[@]}"; do
(( COUNT["$w"]++ )) # first touch creates the key at 0, then increments
done
done
# print "count word", numeric-sort descending, take 5
for w in "${!COUNT[@]}"; do
printf '%d\t%s\n' "${COUNT[$w]}" "$w"
done | sort -rn | head -5
Run: printf 'a b a c a b\n' | ./freq.sh → 3 a, 2 b, 1 c. declare -A is mandatory (bash 4+); (( COUNT["$w"]++ )) relies on an unset key arithmetic-evaluating to 0 on first touch. Sorting is external because bash has no built-in sort.
</details>
Why: the canonical “count things by key” pattern — associative array as a histogram, plus the external-sort reflex.
Challenge 6 — NUL-safe argument forwarding (advanced)
Collect every *.conf file under /etc (recursively) into an array in a way that survives spaces and newlines in filenames, print the count, then pass the whole list as one argument vector to ls -l (a single ls invocation, not one per file). Then explain in one line why "${FILES[*]}" would break it.
<details> <summary>Solution</summary>
#!/usr/bin/env bash
set -euo pipefail
mapfile -d '' -t FILES < <(find /etc -type f -name '*.conf' -print0)
echo "Found ${#FILES[@]} conf files"
(( ${#FILES[@]} )) && ls -l -- "${FILES[@]}" # one ls, all files as separate args
find -print0 emits NUL-delimited names; mapfile -d '' -t (bash 4.4+ for -d) stores each exactly. ls -l -- "${FILES[@]}" forwards each filename as its own argument — the -- stops a name starting with - being read as an option. Why "${FILES[*]}" breaks it: [*] joins every filename into one string separated by IFS, so ls receives a single argument like /etc/a.conf /etc/b.conf and reports “No such file or directory.” Cardinality is the whole point — [@] forwards N arguments, [*] collapses to one.
</details>
Why: combines the three production reflexes — find -print0 + mapfile -d '' for filename safety, the count guard before acting, and "${arr[@]}" for correct argument cardinality.
Common beginner mistakes
These are misconceptions — wrong mental models that produce wrong code — as opposed to the symptom-oriented pitfalls in §10. Each is a belief to unlearn, followed by the model that replaces it.
-
“A space-separated string is basically a list.”
FILES="a.txt b.txt c.txt"looks like three items, but it’s one string that the shell happens to re-split on spaces — and it detonates the instant a filename contains a space, tab, newline, or*. Right model: a list of things is an array, one item per cell, immune to word-splitting. If you’re reaching for a delimiter to fake a list, you want an array. -
“
$ARRmeans the whole array.” It means${ARR[0]}— the first element only — every time. Right model: the collection is always"${ARR[@]}"; the bare name is just cell zero. Any time you write$ARRand expect more than one value, it’s a bug. -
“
arr+=xadds an element.” Without parentheses,+=does string concatenation on element 0:FRUITS+=cherryturnsappleintoapplecherry. Right model: appending an element is alwaysarr+=(value)— the parentheses are what make it an array operation. -
“I can just start assigning to make a hash.” Writing
USER[name]=alicewithoutdeclare -Afirst doesn’t make an associative array — bash arithmetic-evaluates the keynameto0, soUSER[name],USER[role], andUSER[anything]all write to index0and silently overwrite each other (last write wins). Right model:declare -Abefore the first assignment, always. A hash that “mysteriously only remembers one value” is this bug every time. -
“
for x in ${arr[@]}is fine, it usually works.” It works until an element contains whitespace or a glob character, then it splits"bob jones"into two and expands*against your filesystem. Right model: quote it —"${arr[@]}"— unconditionally. “Usually works” is how word-splitting bugs reach production. -
“Associative arrays iterate in the order I inserted them.” Bash gives no ordering guarantee for
"${!MAP[@]}"— it follows the hash table’s internal layout, not insertion or sort order. Right model: if order matters, sort the keys explicitly (printf '%s\n' "${!MAP[@]}" | sort) or keep a separate indexed array of keys in the order you want. -
“
arr=( $(cat file) )loads a file into an array.” It splits the file on all whitespace (so multi-word lines shatter) and glob-expands any*in the content. Right model:mapfile -t arr < file— one line per element, byte-exact, no splitting, no globbing. -
“Copying an associative array is
new=(\"${old[@]}\").” That expands to the values only and builds a meaningless indexed array — the keys are gone. Right model: loop over"${!old[@]}"and copy key by key, or serialize withdeclare -p. Indexed arrays copy fine that way; associative ones do not.
Glossary
- Array — a variable holding many values, each in its own indexed cell; the fix for every “fake list in a string” hack.
- Indexed array — an array whose cells are addressed by non-negative integers starting at
0. Declared withdeclare -aor justARR=(…). - Associative array (hash / map / dictionary) — an array whose cells are addressed by arbitrary string keys. Declared with
declare -A; requires bash 4+ (absent on stock macOS bash 3.2). - Element — one value stored in one cell of an array.
- Index / subscript — the address of a cell: an integer for indexed arrays, a string key for associative ones. Written in
[...]. - Key — the string subscript of an associative array (the “name” of a drawer).
- Sparse array — an indexed array with gaps in its indices (e.g.
0, 2, 5), produced byunsetor explicit-index assignment; iteration skips the holes. - Dense / compact array — an array whose indices are contiguous from 0; rebuild one with
ARR=("${ARR[@]}"). - Cardinality — how many words an expansion produces. The core discipline of this lesson:
"${arr[@]}"= N words,"${arr[*]}"= 1 word, bare$arr= 1 word (element 0). [@]— the “all elements as separate words” subscript. Quoted,"${arr[@]}"yields one word per element — the correct way to forward a list.[*]— the “all elements joined into one string” subscript. Quoted,"${arr[*]}"joins on the first character ofIFS— for summaries/logging only.${!arr[@]}— the list of indices/keys (note the leading!), as opposed to the values.${#arr[@]}— the element count of the array.- Word-splitting — the shell’s habit of chopping an unquoted expansion into words on
IFS; the thing arrays and quoting exist to control. - IFS (Internal Field Separator) — the characters the shell splits on; its first character is also the join character for
"${arr[*]}". mapfile/readarray— the same bash 4+ builtin that reads a stream into an array, one line per element;-tstrips newlines,-d ''reads NUL-delimited records.- Process substitution
<(cmd)— presents a command’s output as a readable file, lettingmapfile … < <(cmd)populate an array in the parent shell (no subshell data loss). - NUL-delimited — records separated by the zero byte
\0(fromfind -print0), the only separator that cannot appear inside a filename — hence the safe way to collect files. - Parameter expansion — the
${VAR…}operators (##*/,%.log,//a/b,,,,:o:l) that also apply across a whole array with[@], transforming every element with no subprocess. - Slicing —
${arr[@]:offset:length}, extracting a run of elements; negative offsets count from the end (needs a leading space). - Nameref (
declare -n/local -n) — a bash 4.3+ alias variable that lets a function read/modify the caller’s array by name — the clean way to “return” an array. declare -p— prints a re-executable declaration of a variable/array; the faithful way to serialize an array (including keys and sparse indices), rebuilt withevalon trusted data only.- Set semantics — using an associative array as a membership set (
SET[key]=1) for O(1) “is it present?” tests, replacing an O(N) linear scan.
What’s next
Lesson 7 covers I/O redirection in depth: file descriptors (0, 1, 2 and beyond), the < > >> 2> &> 2>&1 operators, here-docs (<<EOF), here-strings (<<<), tee, exec for FD remapping, and process substitution as the elegant alternative to temporary files. Bring everything from L1–L6 — every redirection is a process-and-quoting decision. Continue to I/O redirection, FDs, here-docs & process substitution.