Skip to content

01 · Arrays & Associative Arrays

Level 1 dealt only with single-value variables. Real scripts often need to hold a collection of things — a list of filenames, a set of hostnames, a lookup table of config keys. Bash has two kinds of arrays: indexed arrays (ordered, numbered from 0) and associative arrays (unordered key/value maps, like a dictionary).

Creating indexed arrays

# explicit declaration
declare -a fruits

# or just assign directly — bash infers it's an array
fruits=("apple" "banana" "cherry")

echo "${fruits[0]}"     # apple
echo "${fruits[1]}"     # banana
# appending elements
fruits+=("date")
echo "${fruits[@]}"     # apple banana cherry date

# assigning to a specific index (sparse arrays are allowed)
fruits[10]="fig"

Reading arrays

fruits=("apple" "banana" "cherry")

echo "${fruits[@]}"      # all elements: apple banana cherry
echo "${fruits[*]}"      # all elements joined by the first char of IFS
echo "${#fruits[@]}"     # length: 3
echo "${!fruits[@]}"     # all indices: 0 1 2
# looping over an array — always quote "${arr[@]}"
for fruit in "${fruits[@]}"; do
    echo "I like $fruit"
done

As with "$@", quoting "${fruits[@]}" preserves each element as its own word even if it contains spaces — unquoted expansion word-splits and breaks on values like "New York".

Slicing arrays

numbers=(10 20 30 40 50)

echo "${numbers[@]:1:3}"    # 20 30 40   (start at index 1, take 3)
echo "${numbers[@]:2}"      # 30 40 50   (from index 2 to the end)
echo "${numbers[-1]}"       # 50          (last element, bash 4.3+)

Removing elements

fruits=("apple" "banana" "cherry")

unset 'fruits[1]'           # removes banana, LEAVES A GAP at index 1
echo "${fruits[@]}"          # apple cherry
echo "${!fruits[@]}"         # 0 2   <- note the gap

# re-index to close gaps
fruits=("${fruits[@]}")
echo "${!fruits[@]}"         # 0 1

Associative arrays (declare -A)

Associative arrays must be declared before use — unlike indexed arrays, bash can't infer them from a bare assignment.

declare -A capitals

capitals["France"]="Paris"
capitals["Japan"]="Tokyo"
capitals[Germany]="Berlin"    # quotes around the key are optional if it's a single word

echo "${capitals["Japan"]}"   # Tokyo
# building one in a single declaration
declare -A colors=(
    [red]="#ff0000"
    [green]="#00ff00"
    [blue]="#0000ff"
)

echo "${colors[red]}"    # #ff0000

Iterating an associative array

declare -A capitals=(
    [France]="Paris"
    [Japan]="Tokyo"
    [Germany]="Berlin"
)

for country in "${!capitals[@]}"; do
    echo "$country -> ${capitals[$country]}"
done
# order is NOT guaranteed — associative arrays are unordered in bash
# checking whether a key exists
if [[ -v capitals["Italy"] ]]; then
    echo "we have Italy"
else
    echo "no entry for Italy"
fi

-v (bash 4.2+) tests whether a variable — or here, an array key — is set, without triggering an "unbound variable" error under set -u.

A practical example: counting word frequency

#!/usr/bin/env bash
declare -A counts

text="the quick brown fox jumps over the lazy dog the fox runs"

for word in $text; do
    ((counts["$word"]++))
done

for word in "${!counts[@]}"; do
    echo "$word: ${counts[$word]}"
done | sort -t: -k2 -rn
# the: 3
# fox: 2
# ...

How It Actually Works

Bash indexed arrays are not stored as contiguous memory like a C array — internally, bash keeps them as a sparse structure (effectively a linked list of index/value pairs), which is why you can do arr[100]=x on an otherwise empty array without allocating 100 slots, and why ${#arr[@]} (the element count) can differ from one plus the highest index. Each element is itself stored as a string, same as scalar variables — there's no array of integers at the storage layer, only strings that get reinterpreted numerically in arithmetic context.

"${arr[@]}" vs "${arr[*]}" differ in how they interact with word splitting: "${arr[@]}" expands to as many separate, individually-quoted words as there are elements (this is hardcoded special behavior for @ inside double quotes, not a general word-splitting rule), while "${arr[*]}" joins every element into a single string using the first character of $IFS as glue. This is exactly why for x in "${arr[@]}" safely preserves elements containing spaces while for x in ${arr[*]} (or even quoted *) collapses them into one iteration.

Associative arrays (declare -A) are backed by an actual hash table inside bash for O(1)-ish key lookup, distinct from indexed arrays' linked structure — which is also why associative arrays must be declared with -A before use: bash needs to know up front which internal data structure to allocate.

Cheat sheet

Syntax Meaning
arr=(a b c) create an indexed array
declare -A arr declare an associative array (required before use)
${arr[i]} element at index/key i
${arr[@]} all elements
${#arr[@]} number of elements
${!arr[@]} all indices/keys
arr+=(x) append x to an indexed array
unset 'arr[i]' remove element at index/key i
${arr[@]:start:len} slice

Exercise

Write inventory.sh that builds an associative array mapping item names to quantities (e.g. apples=12, bananas=7, oranges=20), prints each item and quantity sorted by quantity descending, and prints the total quantity across all items using a running sum in a loop.