A bash array is declared with parentheses and indexed from zero: files=(a.txt b.txt c.txt). Almost everything about using them is straightforward except one rule, and getting that rule wrong is the source of nearly every bash array bug: expansions must be written as ”${array[@]}” with the quotes and the @, or values containing spaces silently split into multiple elements.
That single rule is worth more than the rest of the syntax combined. A script that works in testing and mangles a filename with a space in it six months later has almost always violated it.
Table of contents
- Declaring and accessing
- The quoting rule
- Associative arrays
- Passing arrays to functions
- Reading command output safely
- When bash arrays are the wrong tool
- How this fits the rest of the stack
- FAQ
Declaring and accessing
The basic forms:
# Literal
fruits=(apple banana cherry)
# Explicit declaration
declare -a fruits
# From a command, one element per line
mapfile -t lines < <(ls)
# Individual assignment
fruits[0]=apple
fruits+=(date elderberry) # append
Accessing:
echo "${fruits[0]}" # first element
echo "${fruits[@]}" # all elements
echo "${#fruits[@]}" # count
echo "${!fruits[@]}" # indices
echo "${fruits[-1]}" # last element
The braces are not optional. $fruits[0] does not index the array — bash expands $fruits to the first element and then treats [0] as a literal string. This is a silent failure that produces plausible-looking output.
One more surprise: referring to the array without an index gives you element zero, not the whole array. echo $fruits prints apple. That behaviour exists for historical compatibility and is a reliable source of confusion.
The quoting rule
This is the part that matters. Consider an array containing a filename with a space:
files=("report final.pdf" "notes.txt")
# Wrong: splits on whitespace, three iterations
for f in ${files[@]}; do echo "$f"; done
# report
# final.pdf
# notes.txt
# Correct: two iterations
for f in "${files[@]}"; do echo "$f"; done
# report final.pdf
# notes.txt
Without quotes, bash performs word splitting on the expanded values. A filename with a space becomes two arguments, and the script proceeds confidently with the wrong data.
The @ against * distinction compounds it:
- ”${array[@]}” — expands to one quoted word per element. This is what you want essentially always.
- ”${array[*]}” — expands to a single word with elements joined by the first character of IFS. Occasionally useful for building a display string; wrong for iteration.
- ${array[@]} unquoted — subject to word splitting and glob expansion. Almost always a bug.
The rule to internalise: always write ”${array[@]}” with both the quotes and the @. If you are consciously doing something else, it should be a decision, not a default.
Associative arrays
Bash 4 and later support string-keyed arrays, and they must be declared before use:
declare -A config
config[host]=localhost
config[port]=5432
# Or in one go
declare -A config=([host]=localhost [port]=5432)
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
The declare -A is mandatory. Without it bash treats the array as indexed and every string key evaluates to 0, so every assignment overwrites element zero. You get one value where you expected several, with no error at all.
Two limitations worth knowing up front. Iteration order is unspecified — it is a hash table, so do not rely on insertion order. And macOS ships bash 3.2 for licensing reasons, so a script using associative arrays fails there unless a newer bash is installed. If portability to macOS matters, that alone is often a reason to write the script in something else.
Passing arrays to functions
Bash cannot pass arrays by value. What you can do is expand the array into arguments:
process() {
local items=("$@")
for item in "${items[@]}"; do
echo "processing $item"
done
}
process "${files[@]}"
This works and it flattens everything into one argument list, so you cannot pass two arrays or an array plus other arguments cleanly.
For those cases, pass by name using a nameref, available in bash 4.3 and later:
process() {
local -n arr=$1 # arr is now a reference
for item in "${arr[@]}"; do
echo "processing $item"
done
}
process files # note: the name, not the expansion
Namerefs are the cleanest option available, and the moment you need several of them in one script is a reasonable signal that the script has outgrown bash.
Reading command output safely
The classic mistake:
# Broken: splits filenames on whitespace
files=($(ls))
# Broken differently: still splits
for f in $(find . -name '*.txt'); do echo "$f"; done
Both split on whitespace, so any filename with a space produces wrong results.
The safe forms:
# One line per element, backslashes preserved
mapfile -t files < <(find . -name '*.txt')
# Null-delimited, handles newlines in filenames too
while IFS= read -r -d '' file; do
files+=("$file")
done < <(find . -name '*.txt' -print0)
The second is the fully correct version, because filenames can legally contain newlines. It is verbose enough that most scripts use mapfile and accept that a newline in a filename would break it — a reasonable trade if you know you are making it.
For the common case of iterating files, a glob avoids the problem entirely: for f in *.txt needs no splitting at all, since bash produces the array itself. Guard against the no-match case with shopt -s nullglob or the loop runs once with the literal pattern.
When bash arrays are the wrong tool
Bash arrays handle simple lists well. The signals that a script has outgrown them:
- Nested structures. Bash has no arrays of arrays. Encoding structure into key names is a sign to stop.
- Parsing JSON or CSV. Possible with external tools and consistently more painful than a language with real data structures.
- Arithmetic on many elements. Workable and slow, since bash forks for a lot of operations.
- Needing to run on macOS with associative arrays, given the bash 3.2 default.
The practical threshold: bash is excellent for orchestrating commands and poor at manipulating data. When most of the script is data manipulation rather than calling other programs, the rewrite is overdue.
One habit that helps regardless: start every script with set -euo pipefail. It makes the script exit on error, treat unset variables as errors, and propagate failures through pipes. Most silently-wrong bash scripts would have failed loudly with those three options on.
How this fits the rest of the stack
The quoting rule is a small thing that determines whether a script is correct or merely untested, which is a fair description of a lot of deployment scripting. The same care applies to what those scripts do: a build step that fails silently is worse than one that fails loudly, which is why exit codes and visible build logs matter more than the script’s cleverness. Builds on RunxBuild covers how a build’s output and its failure end up somewhere you can read. If you are working out what the services a script deploys to actually cost, the RunxBuild hosting calculator breaks it into line items.
Useful related references:
- Python Array Length: len Is the Answer, and Python Lists Are Not Arrays
- Bash Append to File:
>>,tee -a, and Heredoc - How to Run .sh File in Linux: bash, chmod, and shebang
- Services on RunxBuild
FAQ
How do I declare an array in bash?
With parentheses: fruits=(apple banana cherry). For an associative array you must declare it first with declare -A, otherwise bash treats string keys as index 0 and silently overwrites the same element.
What is the difference between ${array[@]} and ${array[*]}?
Quoted, @ expands to one word per element while * joins everything into a single word using the first character of IFS. Use ”${array[@]}” for iteration; [*] is only for building a display string.
Why does my loop split filenames with spaces?
The expansion is unquoted. Writing for f in ${files[@]} lets bash word-split each value. Always quote it as ”${files[@]}” — this single rule prevents most bash array bugs.
How do I read command output into an array?
Use mapfile -t arr < <(command) for line-based output. For filenames that might contain newlines, use find -print0 with a while IFS= read -r -d ” loop. Never use arr=($(command)), which splits on whitespace.
Can I pass an array to a bash function?
Not by value. Expand it into arguments with process ”${files[@]}” and rebuild inside, or pass the name and use a nameref with local -n in bash 4.3 or later.