Bash & the POSIX Shell Module 8 – Arrays and lists
Indexed arrays: create, slice and loop
Store a list in a Bash indexed array, read and slice its elements, loop over values or indices, and work out the gaps that unset leaves behind.
What you will learn
- Build an indexed array and read, append to and slice its elements
- Use negative subscripts to read and change elements from the end
- Debug the gaps that unset leaves and renumber a sparse array
- Distinguish the expansions ${a[@]}, ${a[*]} and ${!a[@]}
- Explain why arrays are a Bash feature that dash rejects
Before you start
What you’ll learn · Before you start
What you will learn
- Build an indexed array and read, append to and slice its elements
- Use negative subscripts to read and change elements from the end
- Debug the gaps that unset leaves and renumber a sparse array
- Distinguish the expansions ${a[@]}, ${a[*]} and ${!a[@]}
- Explain why arrays are a Bash feature that dash rejects
Before you start
On this page
A plain variable holds one string, so four file names crammed into it, one of them two words, come apart into five
once the shell splits the value. An indexed array: A Bash variable that holds many values at once, each under a whole-number subscript counting from 0; written a=(one two) and read with ${a[i]}. dash has no arrays. keeps them as four values instead,
each whole under a number called its array subscript: The part in brackets that selects one element, as in a[i]; in an indexed array Bash evaluates it as an arithmetic expression, and from Bash 4.2 a negative value counts back from the last element., counting from 0, so
a=(zero one "two words" three) has exactly four elements and two words stays one of them.
You will build arrays and read, append to and slice their elements, reach them from the end with negative subscripts,
loop over the values or over the indices, work out the gaps that unset leaves, and see why the same array a shell
script relies on makes dash stop with a syntax error.
Create an array and read an element by its index
Write the elements in parentheses, and Bash numbers them from 0. Read one back by putting its subscript in braces, ask
how many there are with ${#a[@]}, and take a run of them with a slice. The session builds an array, changes it and
reads it four ways:
from session import run
run("""
# Build an array: the words are kept in order, starting at index 0.
a=(zero one "two words" three)
echo "${a[2]}"
echo "count: ${#a[@]}"
# Assign one element by its index, leaving a gap at index 4.
a[5]=five
# Append to the end with += (Bash 3.1 and later).
a+=(six)
echo "all: ${a[*]}"
echo "indices: ${!a[@]}"
echo "a slice of two: ${a[@]:1:2}"
""") Output
# Build an array: the words are kept in order, starting at index 0.
$ a=(zero one "two words" three)
$ echo "${a[2]}"
two words
$ echo "count: ${#a[@]}"
count: 4
# Assign one element by its index, leaving a gap at index 4.
$ a[5]=five
# Append to the end with += (Bash 3.1 and later).
$ a+=(six)
$ echo "all: ${a[*]}"
all: zero one two words three five six
$ echo "indices: ${!a[@]}"
indices: 0 1 2 3 5 6
$ echo "a slice of two: ${a[@]:1:2}"
a slice of two: one two words
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 create_session.py
${a[2]} is the third element, two words, kept as one value; ${#a[@]} counts the elements, 4 here (the
word-splitting lesson introduced both "${a[@]}" and ${#a[@]}). Assigning
a[5]=five put a value at index 5 and left nothing at index 4, and a+=(six) added one after the last element, so
the set indices became 0 1 2 3 5 6. The slice ${a[@]:1:2} copied two elements from index 1. The braces matter:
$a[2] is not an array read at all, but $a (the element at index 0) followed by the plain text [2]. A bare $a
or ${a} always means ${a[0]}, the first element, which surprises a script that meant the whole list.
Counting with the wrong expansion
${#a} is the length of the first element, not the number of elements: for a=(delhi mumbai chennai) it is 5, the
length of delhi, while ${#a[@]} is 3. Reach for ${#a[@]} (or ${#a[*]}) whenever you mean the count.
Checkpoint
Check: Creating, reading and appending
Slices copy a run of elements, so a queue can rotate
A slice, ${a[@]:start:count}, copies count elements starting at index start; drop the :count and it takes
every element from start to the end. Because a slice returns a run of elements that you can drop back into a new
array, slices are how a list behaves like a work queue. Read queue.sh first:
#!/usr/bin/env bash
# queue.sh: move jobs through a small work queue using slices.
queue=(build test lint deploy)
# Rotate: the first job goes to the back of the queue.
queue=("${queue[@]:1}" "${queue[0]}")
echo "after rotate: ${queue[*]}"
# Remove the job at index 1, then renumber so there is no gap.
unset 'queue[1]'
queue=("${queue[@]}")
echo "after remove: ${queue[*]}"
# Insert "scan" so it becomes the new index 1.
queue=("${queue[@]:0:1}" scan "${queue[@]:1}")
echo "after insert: ${queue[*]}"
declare -p queue Each line rebuilds queue from slices of itself. Rotating moves the first job to the back; removing drops index 1 and
rebuilds so there is no gap; inserting splices a new job between two slices. The session runs it, after ShellCheck
confirms the quoting is sound:
from session import run, files
files("queue.sh")
run("""
# ShellCheck is happy with the quoted expansions in queue.sh.
shellcheck queue.sh && echo "shellcheck: clean"
bash queue.sh
""") Output
# ShellCheck is happy with the quoted expansions in queue.sh. $ shellcheck queue.sh && echo "shellcheck: clean" shellcheck: clean $ bash queue.sh after rotate: test lint deploy build after remove: test deploy build after insert: test scan deploy build declare -a queue=([0]="test" [1]="scan" [2]="deploy" [3]="build")
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 queue_session.py
The jobs rotate from build test lint deploy to test lint deploy build, lose lint, then gain scan at index 1.
The last line is declare -p queue, which prints an array exactly as you would type it, every index and value, so it
is the honest way to see what an array really holds.
Checkpoint
Check: Slicing a run of elements
Negative subscripts count back from the last element
You rarely know the last index of an array, so Bash lets a negative subscript count back from the end: ${a[-1]} is
the last element and ${a[-2]} the one before it. Reading this way has worked since Bash 4.2, and assigning through a
negative subscript since Bash 4.3, both recorded in the Bash NEWS file. The session reads, then assigns, then tries
the same thing on the Bash that ships with macOS:
from session import run
run("""
a=(mon tue wed thu fri)
# -1 is the last element, -2 the one before it (reading, Bash 4.2 and later).
echo "last: ${a[-1]}"
echo "second last: ${a[-2]}"
# Assigning through a negative subscript changes the last element (Bash 4.3 and later).
a[-1]=sat
echo "now: ${a[*]}"
# macOS ships Bash 3.2, which has no negative subscripts:
/bin/bash --version | head -1
/bin/bash -c 'a=(mon tue wed); echo "[${a[-1]}]"'
""") Output
$ a=(mon tue wed thu fri)
# -1 is the last element, -2 the one before it (reading, Bash 4.2 and later).
$ echo "last: ${a[-1]}"
last: fri
$ echo "second last: ${a[-2]}"
second last: thu
# Assigning through a negative subscript changes the last element (Bash 4.3 and later).
$ a[-1]=sat
$ echo "now: ${a[*]}"
now: mon tue wed thu sat
# macOS ships Bash 3.2, which has no negative subscripts:
$ /bin/bash --version | head -1
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
$ /bin/bash -c 'a=(mon tue wed); echo "[${a[-1]}]"'
/bin/bash: a: bad array subscript
[]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 neg_session.py
In Bash 5.3 ${a[-1]} gave fri and a[-1]=sat replaced the last element. The /bin/bash of macOS is 3.2.57,
older than the feature, and there ${a[-1]} printed a: bad array subscript on standard error and expanded to
nothing.
Assigning to a far index, such as a[9]=late, is allowed too and leaves every index in between unset, another way an
array becomes sparse.
Negative subscripts need Bash 4.2
Reading through a negative subscript needs Bash 4.2 and assigning through one 4.3; Bash 3.2.57 rejects both with “bad array subscript”. The shells and Bash versions lesson shows how to check which Bash a script is running under.
Checkpoint
Check: Negative subscripts
Loop over values, or over indices when the array is sparse
To work through every element, loop over the values with for dir in "${a[@]}", the quoted form the
for-loops lesson used so that each element arrives whole. When you need the position of each
element, loop over the indices instead: ${!a[@]} expands to the subscripts that are set, and for i in "${!a[@]}"
hands them over one at a time. The two loops matter because unset can leave an array sparse array: An indexed array whose set subscripts are not contiguous, usually because unset removed an element without renumbering, so its indices have a gap such as 0 2 3.:
a hole where an element used to be. Step through one array as it gains, loses and renumbers elements:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three elements. a=(north east south) holds three elements. Each sits at the next index, counting from 0, shown under its value.
- Append with +=. a+=(west) adds one element after the last, so west takes index 3. The array stays packed, 0 to 3.
- Unset one element. unset 'a[1]' removes east. Its slot at index 1 is gone, but the other elements keep the indices they had.
- The indices now have a gap. ${!a[@]} lists the indices that are set: 0 2 3, not 0 1 2. The array is sparse, with a hole where east was.
- A copy renumbers from zero. b=("${a[@]}") copies the three values into a fresh array, so they land at 0, 1, 2 with no gap.
- The rule. Appending and copying keep an array packed; unset leaves a gap that only a copy through "${a[@]}" closes.
unset 'a[1]' removed the element but not its slot, so the set indices became 0 2 3 rather than 0 1 2, and only a
copy through "${a[@]}" closed the gap. sparse.sh shows both loops over such an array, and ends with the copy:
#!/usr/bin/env bash
# sparse.sh: unsetting elements leaves gaps; a copy renumbers them.
a=(north east south west)
# Remove the element at index 1. Quote the subscript (see the lesson).
unset 'a[1]'
echo "values: ${a[*]}"
echo "indices: ${!a[*]}"
# Loop over the values: gaps do not matter, each value comes once.
for dir in "${a[@]}"; do
echo "value $dir"
done
# Loop over the indices when you need the position, gaps and all.
for i in "${!a[@]}"; do
echo "index $i holds ${a[i]}"
done
# A copy through "${a[@]}" renumbers the elements from 0.
b=("${a[@]}")
declare -p b from session import run, files
files("sparse.sh")
run("""
bash sparse.sh
# The glob trap: an unquoted subscript can be read as a filename pattern.
touch a1
a=(zero one two three)
# a[1] is a glob here: it matches the file a1, so unset deletes a variable named a1.
unset a[1]
echo "unquoted left: ${!a[@]}"
# Quote the subscript, and unset removes element 1 of the array.
unset 'a[1]'
echo "quoted left: ${!a[@]}"
""") Output
$ bash sparse.sh
values: north south west
indices: 0 2 3
value north
value south
value west
index 0 holds north
index 2 holds south
index 3 holds west
declare -a b=([0]="north" [1]="south" [2]="west")
# The glob trap: an unquoted subscript can be read as a filename pattern.
$ touch a1
$ a=(zero one two three)
# a[1] is a glob here: it matches the file a1, so unset deletes a variable named a1.
$ unset a[1]
$ echo "unquoted left: ${!a[@]}"
unquoted left: 0 1 2 3
# Quote the subscript, and unset removes element 1 of the array.
$ unset 'a[1]'
$ echo "quoted left: ${!a[@]}"
quoted left: 0 2 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sparse_session.py
Looping over the values printed north south west with no empty pass, while looping over the indices reported 0, 2
and 3, the real positions. The copy b=("${a[@]}") renumbered the three values to 0 1 2, which declare -p b
confirmed.
Why unset 'a[i]' is quoted
Written bare, unset a[1] is a word with [1] in it, a glob pattern: A word containing *, ? or [ that Bash replaces with the sorted names of the files it matches; it stays as written when nothing matches, unless nullglob or failglob is set. More in “Globbing: wildcards, dotfiles, extglob, globstar”. With a file
named a1 in the folder it matched that name, so the shell ran unset a1 and the array kept every element. The
quotes in unset 'a[1]' stop the match, so unset sees the subscript and removes element 1.
With the subscript quoted, the two loops and the renumbering copy above are all you need to walk a sparse array and then pack it again.
Key idea
Loop over "${a[@]}" for the values and over "${!a[@]}" for the indices; after an unset the indices can have
gaps, and only a copy through "${a[@]}" renumbers them from 0.
Checkpoint
Check: Loops and sparse arrays
Arrays are a Bash feature, and quotes decide how they expand
Two subscripts expand every element, @ and *, and inside double quotes they differ. "${a[@]}" gives one word per element,
each whole; "${a[*]}" joins them into a single word with the first character of IFS: The internal field separator: the characters Bash splits unquoted expansions on and read splits lines on; by default a space, a tab and a newline. More in “Word splitting and IFS: why "$var" needs quotes” between
them, the comma below. Leaving the quotes off invites the word splitting: The step in which Bash cuts the unquoted results of parameter expansion, command substitution and arithmetic expansion into separate words at the characters of IFS. More in “Word splitting and IFS: why "$var" needs quotes” and globbing
that ShellCheck warns about as SC2068. All of this is a Bash feature: the POSIX shell dash has no arrays at all. When an array has no elements,
"${a[@]}" expands to nothing, so a command given it as arguments simply receives none.
from session import run, write
write("report.sh", '#!/usr/bin/env bash\nfiles=(one "two words" three)\nfor f in ${files[@]}; do echo "$f"; done\n')
write("list.sh", '#!/bin/sh\nx=(foo bar)\necho "${x[0]}"\n')
run("""
a=(north east south west)
# "${a[*]}" joins the elements with the first character of IFS; "${a[@]}" keeps each whole.
IFS=,
echo "star: ${a[*]}"
printf 'at: %s\\n' "${a[@]}"
unset IFS
# ShellCheck flags the unquoted ${files[@]} loop in report.sh as SC2068.
!shellcheck report.sh
# dash has no arrays at all.
!dash -c 'a=(north east south)'
# In a #!/bin/sh script ShellCheck warns with SC3030.
!shellcheck list.sh
""") Output
$ a=(north east south west)
# "${a[*]}" joins the elements with the first character of IFS; "${a[@]}" keeps each whole.
$ IFS=,
$ echo "star: ${a[*]}"
star: north,east,south,west
$ printf 'at: %s\n' "${a[@]}"
at: north
at: east
at: south
at: west
$ unset IFS
# ShellCheck flags the unquoted ${files[@]} loop in report.sh as SC2068.
$ shellcheck report.sh
In report.sh line 3:
for f in ${files[@]}; do echo "$f"; done
^---------^ SC2068 (error): Double quote array expansions to avoid re-splitting elements.
For more information:
https://www.shellcheck.net/wiki/SC2068 -- Double quote array expansions to ...
[exit status 1]
# dash has no arrays at all.
$ dash -c 'a=(north east south)'
dash: 1: Syntax error: "(" unexpected
[exit status 2]
# In a #!/bin/sh script ShellCheck warns with SC3030.
$ shellcheck list.sh
In list.sh line 2:
x=(foo bar)
^-------^ SC3030 (warning): In POSIX sh, arrays are undefined.
In list.sh line 3:
echo "${x[0]}"
^-----^ SC3054 (warning): In POSIX sh, array references are undefined.
For more information:
https://www.shellcheck.net/wiki/SC3030 -- In POSIX sh, arrays are undefined.
https://www.shellcheck.net/wiki/SC3054 -- In POSIX sh, array references are...
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dashsc_session.py
With IFS=, the star form gave the single word north,east,south,west, while the at form gave four separate words.
ShellCheck flagged the unquoted ${files[@]} as SC2068, an error, and in a #!/bin/sh script it warned about the
array as SC3030 and about reading an element as SC3054. dash itself stopped at the opening parenthesis with Syntax error: "(" unexpected, which is why
a script that uses arrays needs a Bash shebang, not #!/bin/sh.
Pick the expansion by what you want
Use "${a[@]}" to pass a list on to a command with every element intact, and "${a[*]}" only to build one string,
setting IFS first to choose the separator. An unquoted ${a[@]} is almost never what you want.
Checkpoint
Check: How an array expands
Practise: arrays that keep their shape
Write two functions that change an array in place through a nameref: A variable made with declare -n or local -n that stands for another variable, so every use of it reads or changes the variable it names. More in “Indirection, namerefs and ${var@Q} transforms” (local -n, from the
namerefs lesson): rotate_left ARRAYNAME N, which moves the
first N elements to the back, and remove_value ARRAYNAME VALUE, which drops every element equal to VALUE and
renumbers the rest. Test them in your own shell and check the declare -p lines: tasks=(build test lint deploy ship)
then rotate_left tasks 2 must give declare -a tasks=([0]="lint" [1]="deploy" [2]="ship" [3]="build" [4]="test"),
and servers=(web1 db1 web2 db1 cache1) then remove_value servers db1 must give
declare -a servers=([0]="web1" [1]="web2" [2]="cache1"). The self-check below puts these ideas onto arrays you have
not met here:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does ${!a[@]} expand to, and when is it not simply 0 to n − 1?
${!a[@]} expands to the subscripts that currently have a value, in increasing order, not the values themselves. For
an array built in one go, such as a=(zero one two three), those indices are exactly 0 1 2 3, the same as 0 to
n − 1 for n elements, so looping over "${!a[@]}" and counting 0 to ${#a[@]} - 1 agree. They stop agreeing as
soon as the array is sparse: after unset 'a[1]' the indices are 0 2 3, three elements but highest index 3, and
b[9]=x on a new array b leaves a single element, at index 9. Whenever an array may have gaps, loop over "${!a[@]}" to get
the real positions rather than assuming a contiguous 0 to n − 1.
The questions below go further: a job loop that stops halfway once it starts removing finished entries, and a last-element read that has to give the right answer on three Bash versions at once. Settle on an answer to each yourself first:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A cleanup loop for (( i=0; i < ${#jobs[@]}; i++ )); do handle "${jobs[i]}"; unset "jobs[i]"; done over jobs=(a b c d e f) handles only a, b and c and leaves d, e and f. Explain why, and give two correct ways.
-
Mid to senior
A script must read the last element of an array that may be empty or sparse, on Bash 3.2, 4.1 and 5.3. Compare ${a[-1]}, ${a[${#a[@]}-1]} and ${a[@]: -1}, and choose a form that is correct on all three.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you get one element of an indexed array, count its elements and append one?
${a[2]} reads the element at subscript 2, counting from 0; ${#a[@]} counts them; and a+=(x) appends one.
-
What does ${a[@]:start:count} give?
A copy of count elements beginning at subscript start, which is how a queue can be rotated or a run of items taken.
-
What does ${a[-1]} give, and which Bash versions accept it?
The last element. Reading a negative subscript works from Bash 4.2 and assigning one from 4.3; the Bash 3.2 of macOS answers bad array subscript.
-
What does unset 'a[1]' leave behind?
A sparse array: the other elements keep their subscripts, so the indices have a gap. "${!a[@]}" lists the indices that are set, and a copy b=("${a[@]}") renumbers from 0.
-
When should a loop go over "${!a[@]}" instead of counting from 0?
When the array may be sparse. "${!a[@]}" gives the subscripts that really have values, while counting 0 to ${#a[@]} - 1 misses or invents elements after an unset.
-
Why should unset 'a[i]' be quoted?
Unquoted, a[1] is a glob pattern. With a file named a1 in the folder it matched that name, so the shell ran unset a1 and the array kept every element; the quotes make unset see the subscript.
-
How do "${a[@]}" and "${a[*]}" expand, and does sh have arrays?
"${a[@]}" gives one word per element and "${a[*]}" joins them with the first character of IFS. Arrays are a Bash feature: dash rejects them and ShellCheck flags them as SC3030 in a #!/bin/sh script.
Key takeaways
- An indexed array keeps many values under subscripts counting from 0;
${a[2]}reads one,${#a[@]}counts them,a+=(x)appends, and${a[@]:start:count}slices a run of elements. - Negative subscripts count back from the end,
${a[-1]}being the last element: reading works from Bash 4.2 and assigning from 4.3, but macOS Bash 3.2 answersbad array subscript. unset 'a[i]'leaves a sparse array whose set indices have a gap;"${!a[@]}"lists those indices, and a copyb=("${a[@]}")renumbers the elements from 0.- Quote the subscript in
unset 'a[i]'so[i]cannot act as a glob pattern that matches a file name. "${a[@]}"expands to one word per element and"${a[*]}"joins them with the first character of IFS; arrays are a Bash feature, so dash rejects them and ShellCheck flags them as SC3030 in a#!/bin/shscript.
Terms in this lesson
- array subscript
- The part in brackets that selects one element, as in a[i]; in an indexed array Bash evaluates it as an arithmetic expression, and from Bash 4.2 a negative value counts back from the last element.
- indexed array
- A Bash variable that holds many values at once, each under a whole-number subscript counting from 0; written a=(one two) and read with ${a[i]}. dash has no arrays.
- sparse array
- An indexed array whose set subscripts are not contiguous, usually because unset removed an element without renumbering, so its indices have a gap such as 0 2 3.
References
- Bash Reference Manual: Arrays (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Shell Parameters (the += operator) (Chet Ramey (GNU Bash maintainer))
- Bash NEWS (negative subscripts added in 4.2 and 4.3) (Chet Ramey (GNU Bash maintainer))
- ShellCheck SC2068: double quote array expansions (ShellCheck)
- ShellCheck SC3030: in POSIX sh, arrays are undefined (ShellCheck)
Report a problem with this lesson
Kept only in this browser. Your Learn progress