Skip to content

Arrays in Shell


Arrays are how Bash stores more than one value in a single variable — a list of filenames, a set of command-line flags, a lookup table of key-value pairs — and they solve one of the most common workarounds people reach for instead: building a space-separated string and hoping word splitting divides it back up correctly later. It usually doesn’t, and this chapter’s safety section shows exactly why.

Creating Indexed Arrays

An indexed array is created just by assigning parenthesized, space-separated values:

fruits=(apple banana cherry)

Indexing starts at 0, and individual elements can also be assigned directly, including sparse (gapped) indices:

fruits[3]="date"
echo "${fruits[3]}"
date

Note that index 2 (cherry) and index 3 (date) coexist just fine even though nothing was ever assigned to keep them contiguous — Bash arrays don’t require a fixed size or unbroken sequence of indices.

Accessing Elements

fruits=(apple banana cherry)
echo "${fruits[0]}"
echo "${fruits[1]}"
echo "${fruits[-1]}"
apple
banana
cherry

The braces (${...}) are required — $fruits[0] without them expands $fruits on its own (just the first element, as covered below) and leaves [0] as literal text. Negative indices, like -1 above, count backward from the end — a convenient way to reach the last element without knowing the array’s length.

Iterating Over Arrays

fruits=(apple "kiwi fruit" cherry)

for fruit in "${fruits[@]}"; do
    echo "Fruit: $fruit"
done
Fruit: apple
Fruit: kiwi fruit
Fruit: cherry

Quoting "${fruits[@]}" here is doing exactly the same job it’s done everywhere else in this course — without the quotes, "kiwi fruit" would be split into two separate loop iterations at the space. The distinction between @ and * inside that expansion — and why @ is almost always the one you want — gets its own section below.

Array Length

fruits=(apple "kiwi fruit" cherry)
echo "${#fruits[@]}"
echo "${#fruits[1]}"
3
10

${#fruits[@]} gives the number of elements in the array. ${#fruits[1]} — note the specific index instead of @ — gives the character length of that one element’s string value ("kiwi fruit" is 10 characters). These look similar but answer completely different questions; mixing them up is an easy typo to make.

Adding And Removing Elements

Append with +=:

fruits=(apple banana)
fruits+=("cherry")
echo "${fruits[@]}"
apple banana cherry

Remove a specific element with unset, quoting the subscript:

unset 'fruits[1]'
echo "${fruits[@]}"
echo "${#fruits[@]}"
apple cherry
2

unset leaves a gap rather than shifting later elements down — the array becomes sparse, the same as if you’d assigned a non-contiguous index directly. Quoting the subscript ('fruits[1]', not fruits[1]) avoids an obscure but real hazard: an unquoted [1] is technically valid glob syntax, and in a directory that happens to contain a file whose name matches it, pathname expansion could rewrite the argument before unset ever sees it.

"${array[@]}" vs. "${array[*]}" — Why The Difference Matters

Both @ and * expand to all elements of an array, but quoted, they behave completely differently:

fruits=(apple "kiwi fruit" cherry)

echo "Using @:"
for item in "${fruits[@]}"; do
    echo "  [$item]"
done

echo "Using *:"
for item in "${fruits[*]}"; do
    echo "  [$item]"
done
Using @:
  [apple]
  [kiwi fruit]
  [cherry]
Using *:
  [apple kiwi fruit cherry]

"${fruits[@]}" expands to each element as its own separate, fully preserved word — three loop iterations, exactly matching the array’s actual contents. "${fruits[*]}" instead joins every element into a single string, separated by the first character of IFS (a space, by default) — one loop iteration, with the whole array flattened into it.

This is almost never what you want inside a loop or when passing array contents on to another command. Default to "${array[@]}", quoted, essentially always; reach for "${array[*]}" only in the rare case where you deliberately want everything joined into one string.

Associative Arrays

Indexed arrays use numbers; associative arrays use arbitrary string keys instead — Bash’s equivalent of a dictionary or hash map. Unlike indexed arrays, they must be explicitly declared first with declare -A:

declare -A capitals
capitals[France]="Paris"
capitals[Japan]="Tokyo"
capitals[Kenya]="Nairobi"

echo "${capitals[Japan]}"
Tokyo

Iterate over keys with ${!capitals[@]} — the ! prefix here means “give me the keys/indices, not the values”:

for country in "${!capitals[@]}"; do
    echo "$country -> ${capitals[$country]}"
done
France -> Paris
Japan -> Tokyo
Kenya -> Nairobi

Note

Associative array key order isn’t guaranteed to match insertion order — if you need a specific, predictable order, sort the keys explicitly rather than relying on iteration order matching how you wrote the assignments.

${!array[@]} also works on ordinary indexed arrays, and is genuinely useful there for sparse arrays, where it gives you only the indices that actually exist rather than assuming a contiguous 0 to length-1 range.

Command Arrays — Building Argument Lists Safely

One of the most practical uses for arrays is building up a command’s arguments piece by piece, instead of assembling a string and hoping it splits back apart correctly later:

src="/data/reports"
dst="/backup/reports"
exclude_pattern="build dir"

cmd=(rsync -av --exclude="$exclude_pattern" "$src" "$dst")

Each element of cmd is one complete, exact argument — including --exclude=build dir as a single element, space and all, because array elements never undergo word splitting the way an expanded string does. Run it with:

"${cmd[@]}"

Quoted exactly like that, each array element is handed to the command as its own separate, intact argument — this is the safe alternative to string-building a command and letting Bash re-split it, and it’s the subject of this chapter’s Shell-Safety Considerations section, worked through in full below.

Common Operations

Slice a range of elements with ${array[@]:offset:length}:

numbers=(10 20 30 40 50)
echo "${numbers[@]:1:3}"
20 30 40

Check whether a specific key or index is set (as opposed to simply empty) with -v:

declare -A capitals
capitals[France]="Paris"

if [[ -v capitals[France] ]]; then
    echo "France is set"
fi

if [[ ! -v capitals[Germany] ]]; then
    echo "Germany is not set"
fi
France is set
Germany is not set

Best Practices

  • Quote "${array[@]}" essentially everywhere — in loops, when passing to a command, when reassigning to another array. Treat an unquoted array expansion as a deliberate, rare exception, not a default.
  • Declare associative arrays explicitly with declare -A before using them — unlike indexed arrays, Bash won’t infer this for you, and assigning a string key to an undeclared array silently creates an indexed array with unexpected numeric behavior instead.
  • Use command arrays for any command whose arguments include variables that might contain spaces — this is the direct, safe replacement for string-building a command and hoping word splitting divides it back up the way you intended.
  • Use ${!array[@]} for keys or indices, ${array[@]} for values — the ! prefix is easy to forget, but the two answer genuinely different questions.

Shell-Safety Considerations

Building a command’s arguments as a plain string, rather than as an array, is a direct extension of the word-splitting hazard this course has covered since Chapter 2 — and it’s an easy pattern to reach for by default, since it looks perfectly ordinary.

Naive (broken) version — arguments assembled into a single string:

exclude_pattern="build dir"
opts="--exclude=$exclude_pattern"

printf '[%s]\n' $opts
[--exclude=build]
[dir]

$opts was left unquoted when expanded into printf’s arguments, so it went through ordinary word splitting — the space inside "build dir" split what was meant to be one argument into two. In a real command (rsync $opts ..., for instance), this would send --exclude=build and dir as two separate, wrong arguments instead of the one correct --exclude=build dir — silently changing what the command actually does, with no error at all.

Corrected version — the same value built as a command array instead:

exclude_pattern="build dir"
opts=(--exclude="$exclude_pattern")

printf '[%s]\n' "${opts[@]}"
[--exclude=build dir]

One argument, exactly as intended — because array elements are never subject to word splitting when expanded with "${opts[@]}", regardless of what characters they contain. This is precisely why command arrays are the recommended approach anytime a command’s arguments are built up from variables rather than typed literally: the array preserves argument boundaries correctly no matter what’s inside each value.

Hands-On: Building And Using Arrays

1. Set up a working directory.

mkdir -p ~/shell-course/ch9
cd ~/shell-course/ch9

2. Create an indexed array and practice basic access.

fruits=(apple "kiwi fruit" cherry)
echo "${fruits[0]}"
echo "${fruits[-1]}"
echo "${#fruits[@]}"

3. Append and remove elements.

fruits+=("date")
echo "${fruits[@]}"

unset 'fruits[1]'
echo "${fruits[@]}"
echo "${#fruits[@]}"

4. Compare @ and * directly.

fruits=(apple "kiwi fruit" cherry)

echo "--- @ ---"
for item in "${fruits[@]}"; do
    echo "[$item]"
done

echo "--- * ---"
for item in "${fruits[*]}"; do
    echo "[$item]"
done

5. Build and iterate an associative array.

declare -A capitals
capitals[France]="Paris"
capitals[Japan]="Tokyo"
capitals[Kenya]="Nairobi"

for country in "${!capitals[@]}"; do
    echo "$country -> ${capitals[$country]}"
done

6. Reproduce the string-vs-array argument-splitting bug, then fix it.

exclude_pattern="build dir"

echo "--- broken: string ---"
opts="--exclude=$exclude_pattern"
printf '[%s]\n' $opts

echo "--- fixed: array ---"
opts=(--exclude="$exclude_pattern")
printf '[%s]\n' "${opts[@]}"

7. Clean up.

cd ~
rm -rf ~/shell-course/ch9

You’ve now created, indexed, iterated, and modified both indexed and associative arrays, seen exactly why @ beats * inside quotes, and confirmed directly — not just in theory — why building command arguments as an array avoids a word-splitting bug that a plain string can’t. The next chapter covers functions: packaging reusable logic, handling arguments, and sourcing shared function libraries across scripts.

Last updated on