Why this matters
Until now every variable held one string. But scripts constantly deal with lists: the files to back up, the servers to check, the lines of a report. People without arrays fake lists with a space-separated string, files="a.txt b.txt", and it works right up to the day a file is called my notes.txt. Then the "list" silently has one element too many and the script processes two files that do not exist. Arrays are bash's answer: a list whose elements keep their exact contents, spaces, stars and all.
The concept
An indexed array is a variable that holds many strings, each at a numbered position (its index), starting at 0:
files ─┬─ [0] "report.txt"
├─ [1] "my notes.txt"
└─ [2] "data.csv"
You create one with parentheses. Inside them, bash applies its normal word rules: whitespace separates elements, quotes keep things together.
$ files=(report.txt "my notes.txt" data.csv)
$ echo "${files[1]}"
my notes.txt
$ echo "${#files[@]}"
3
The braces are mandatory. $files[1] means "the variable files, then the text [1]", and $files on an array is the same as ${files[0]}:
$ echo $files[1]
report.txt[1]
The main forms, all worth memorizing:
| Expression | Meaning |
|---|---|
arr=(a b c) | assign the whole array |
arr[5]=x | set one element (gaps are allowed) |
arr+=(d e) | append elements |
"${arr[i]}" | one element; i is evaluated arithmetically, no $ needed |
"${arr[-1]}" | the last element (negative indexes count from the end) |
"${arr[@]}" | every element, each as a separate word |
"${arr[*]}" | all elements joined into one word with the first char of IFS |
${#arr[@]} | number of elements |
${#arr[i]} | length of element i |
"${!arr[@]}" | the indexes that exist |
"${arr[@]:1:2}" | a slice: 2 elements starting at index 1 |
unset 'arr[1]' | remove one element (indexes are not renumbered) |
What happens underneath
Inside bash, an indexed array is a sparse list of (index, string) pairs. That is why arr[100]=x on an empty array does not create 100 empty elements, and why unset leaves a hole:
$ a=(x y z)
$ unset 'a[1]'
$ echo "${#a[@]}"; echo "${!a[@]}"
2
0 2
So never loop with for (( i=0; i<${#a[@]}; i++ )) over an array that might have holes; loop over "${!a[@]}" (the real indexes) or over "${a[@]}" (the values).
The quote-at-sign form is special in the same way "$@" is: it is the one place where a quoted expansion produces several words. bash builds the argument list for the next program directly from the elements, with no splitting and no globbing in between:
$ files=(a "b c" '*')
$ printf '[%s]\n' "${files[@]}"
[a]
[b c]
[*]
$ printf '[%s]\n' ${files[@]}
[a]
[b]
[c]
[script.sh]
Unquoted, the element b c was split, and * was expanded as a glob into the files in the current directory. Quoted, every element arrived untouched. In fact "$@" is just this: the positional parameters are bash's built-in array, and items=("$@") copies them into a named one you can modify.
Arrays cannot be exported to child processes. The environment that execve passes to a new program is a flat list of NAME=value strings, so there is nowhere to put a list. Pass array elements as arguments instead: cmd "${arr[@]}".
Worked examples
Building a command line safely. This is the most important practical use. Options that depend on conditions go into an array, then the array is expanded as arguments:
opts=(-czf "backup 2026.tar.gz")
if [[ $verbose == yes ]]; then
opts+=(-v)
fi
tar "${opts[@]}" "$src_dir"
The archive name keeps its space because it is one element. Building the same thing as a string and running tar $opts would split the name in two.
Collecting files with a glob. A glob in an array assignment expands into elements, one per file, whatever the names contain:
$ ls
a.txt b c.txt notes.md
$ txt=(*.txt)
$ echo "${#txt[@]} text files"; printf '%s\n' "${txt[@]}"
2 text files
a.txt
b c.txt
Watch out: if nothing matches, the pattern itself stays (txt=('*.txt'), count 1) unless you set shopt -s nullglob, as you saw in the globbing module.
Reading lines into an array. mapfile (also spelled readarray) reads standard input, one element per line. -t strips the trailing newline from each:
$ printf 'alpha\n beta\ngamma\n' > words.txt
$ mapfile -t words < words.txt
$ echo "${#words[@]}: ${words[1]}|"
3: beta|
Do not use words=($(cat words.txt)) for this. The command substitution is split on any whitespace and globbed, so beta loses its spaces and a line like a b becomes two elements. A useful related trick: mapfile -t lines < <(grep ERROR app.log) reads the output of a command via process substitution. (Do not pipe into mapfile: like any command in a pipeline it would run in a subshell, and the array would vanish with it.)
Slices and the last element.
$ n=(10 20 30 40 50)
$ echo "${n[@]:1:3}"
20 30 40
$ echo "${n[@]: -2}"
40 50
$ echo "${n[-1]}"
50
The space in : -2 matters: :- would be the "default value" expansion from the variables module.
Iterating with the index.
servers=(web1 web2 db1)
for i in "${!servers[@]}"; do
echo "$((i + 1)). ${servers[i]}"
done
prints 1. web1, 2. web2, 3. db1.
Common mistakes
- Forgetting braces.
$arr[0]prints the first element followed by[0]. Always${arr[0]}. - Unquoted
${arr[@]}. Splits elements with spaces and globs ones with*. ShellCheck calls this SC2068. ${#arr}instead of${#arr[@]}. The first is the length of element 0's text.- Assigning with commas.
arr=(a, b, c)makes elementsa,b,c. Whitespace separates. - Spaces around
=.arr = (a b)runs a command namedarrand fails withbash: syntax error near unexpected token '('. - Piping into mapfile.
grep x f | mapfile -t mfillsmin a subshell; afterwardsmis empty. Usemapfile -t m < <(grep x f). - Expecting indexes to close up after unset. They do not; re-pack with
a=("${a[@]}").
Exercises
Two quizzes on how arrays expand, then two scripts. Test the scripts in the sandbox shell with bash script.sh a b "c d" and printf 'x\ny\n' | bash script.sh before checking.
Recap
arr=(...)creates an array; elements follow the normal quoting rules."${arr[@]}"gives each element as its own word, untouched; it is the form to use by default.${#arr[@]}counts,"${!arr[@]}"lists indexes,${arr[-1]}is the last element,"${arr[@]:start:len}"slices.- Arrays are sparse;
unsetleaves holes. mapfile -t arr < filereads lines safely; neverarr=($(cat file)).- Build variable command lines in an array, then run
cmd "${arr[@]}". - Arrays live only inside bash; they cannot be exported.
Going further: read help mapfile and help declare (look at -a). Then try declare -p arr on an array you built: it prints the exact command that would recreate it, which is the best way to see what an array really contains.