A space-delimited string is the wrong data structure, and it is the one most shell scripts use. Arrays cost the same effort and survive filenames, hostnames, and arguments that contain spaces.
Topic 1: Indexed Arrays
services=("nginx" "postgresql" "redis server")
echo "${services[0]}" # nginx -- zero-indexed
echo "${services[-1]}" # redis server -- negative from the end (bash 4.3+)
echo "${#services[@]}" # 3 -- number of ELEMENTS
echo "${#services[0]}" # 5 -- LENGTH of element 0
echo "${!services[@]}" # 0 1 2 -- the INDICES
Building them:
arr=() # empty
arr+=("one") # append
arr+=("two" "three") # append several
arr[10]="sparse" # arrays are SPARSE -- index 3-9 do not exist
declare -a explicit # explicit declaration, rarely needed
Sparseness matters: after arr[10]="sparse", ${#arr[@]} is 4, not 11. It counts set elements, not the highest index. Iterating for ((i=0; i<${#arr[@]}; i++)) on a sparse array silently skips real data — always iterate over "${arr[@]}" or "${!arr[@]}" instead.
Expansion — the same rule as "$@":
"${arr[@]}" # one word per element, boundaries preserved ← use this
"${arr[*]}" # ALL elements joined into one word by IFS[0]
${arr[@]} # split on whitespace -- destroys elements with spaces
${arr[*]} # same damage
This is exactly the "$@" versus "$*" distinction from the quoting lesson, and for the same reason: "${arr[@]}" is special-cased to produce one properly-quoted word per element.
for s in "${services[@]}"; do
echo "restarting $s" # 3 iterations, "redis server" intact
done
Slicing:
"${arr[@]:1:2}" # 2 elements starting at index 1
"${arr[@]:2}" # everything from index 2 on
"${@:2}" # positional parameters from $2 on -- very useful
"${@:2}" is the idiom for “pass everything except the first argument”, which is how subcommand dispatchers forward the remainder.
Topic 2: Filling an Array From Output
This is where most people fall back to a string, and where mapfile (bash 4+) is the correct answer.
# Read lines into an array, stripping newlines
mapfile -t files < <(find /var/log -name '*.log')
# readarray is an alias -- identical behaviour
readarray -t lines < config.txt
echo "${#files[@]} files found"
-t strips the trailing newline from each line; without it every element carries one. < <(...) is process substitution — it feeds the command’s output to mapfile as a file, without a subshell, which matters because a pipeline would put mapfile in a subshell and the array would vanish when it exited.
The null-delimited form, for filenames that can contain anything:
mapfile -d '' -t files < <(find /var/log -name '*.log' -print0)
A newline is a legal character in a filename. -print0 and -d '' pair up to split on NUL instead, which is the only separator a filename cannot contain.
On bash 3.2 (macOS), where mapfile does not exist:
files=()
while IFS= read -r line; do
files+=("$line")
done < <(find /var/log -name '*.log')
Verbose, but portable. Worth knowing because macOS still ships bash 3.2 for licensing reasons, and a script that works on your laptop can fail on a colleague’s.
Splitting a string into an array:
csv="alpha,beta,gamma"
IFS=',' read -ra parts <<< "$csv"
echo "${parts[1]}" # beta
-a reads into an array, -r keeps backslashes literal, and setting IFS on the same line scopes the change to that command only. <<< is a here-string — it feeds the variable to read as stdin.
Topic 3: Associative Arrays
Bash 4 introduced string-keyed arrays. They must be declared, and forgetting the declaration is the most common bug — bash silently treats string keys as arithmetic expressions evaluating to 0, so every write lands in index 0.
declare -A env_of # MANDATORY. Without it, everything breaks silently.
env_of["web-01"]="production"
env_of["web-02"]="production"
env_of["db-01"]="staging"
echo "${env_of[web-01]}" # production
echo "${#env_of[@]}" # 3
echo "${!env_of[@]}" # the KEYS
echo "${env_of[@]}" # the VALUES
Iterating:
for host in "${!env_of[@]}"; do
printf '%-10s → %s\n' "$host" "${env_of[$host]}"
done
Order is not guaranteed — associative arrays are hash tables, and bash gives no ordering promise. Pipe the keys through sort if output order matters.
Testing membership:
if [[ -v env_of["web-01"] ]]; then # bash 4.3+, the clean form
echo "known host"
fi
if [[ -n "${env_of[web-01]:-}" ]]; then # portable to older bash
echo "known host"
fi
The :- in the second form matters under set -u, where referencing an unset key is a fatal error rather than an empty string.
Counting with an associative array:
This replaces sort | uniq -c when you need the counts in the script rather than on stdout:
declare -A count
while read -r ip _; do
(( count["$ip"]++ ))
done < access.log
for ip in "${!count[@]}"; do
echo "${count[$ip]} $ip"
done | sort -rn | head
(( )) creates the key on first use with a value of 0, so no initialisation is needed.
Topic 4: Passing Arrays to Functions
Arrays do not survive being passed as a single argument — this is the limitation people hit first.
# WRONG -- the function receives the elements as separate arguments,
# and any second array is indistinguishable from the first
process "${arr[@]}"
Three working approaches, in increasing order of sophistication:
1. Expand and rebuild — fine for one array:
process() {
local items=("$@")
echo "${#items[@]} items"
}
process "${arr[@]}"
2. Pass by name with a nameref (bash 4.3+) — the clean modern answer:
process() {
local -n ref=$1 # ref becomes an ALIAS for the caller's array
ref+=("added by function")
echo "${#ref[@]} items"
}
process arr # pass the NAME, not the expansion
A nameref lets the function mutate the caller’s array, which is otherwise impossible in bash. The name must differ from the nameref variable, or bash errors with a circular reference.
3. Global by convention — what older scripts do, and why they are hard to reason about. Avoid unless targeting bash 3.
Topic 5: Where Arrays Earn Their Keep
Building a command safely:
The classic mistake is accumulating flags in a string:
# WRONG -- breaks the moment a path contains a space
opts="--config /etc/my app.conf --verbose"
mycmd $opts
# RIGHT
opts=(--config "/etc/my app.conf" --verbose)
mycmd "${opts[@]}"
An array preserves argument boundaries exactly. This is the correct way to build any dynamic command line, and it is why docker run wrappers and deploy scripts that use strings eventually break on a path with a space.
Conditional flags:
args=(--input "$file")
[[ -n "${VERBOSE:-}" ]] && args+=(--verbose)
[[ -n "${DRY_RUN:-}" ]] && args+=(--dry-run)
mytool "${args[@]}"
This replaces the string-concatenation-and-hope pattern, and it stays readable as the number of options grows.
Try it yourself: Build an array of rsync options conditionally based on two environment variables, print it with printf '%q ' to see the exact quoting, then run it with --dry-run.
Common mistake: declare -A inside a function without local. declare is implicitly local inside a function, so an associative array you meant to be global is destroyed on return. Declare globals at the top level, or use declare -gA explicitly.