TechEarl

Bash Arrays: Indexed, Associative, and Iteration Patterns

Bash array reference: indexed and associative declaration, the [@] vs [*] quoting gotcha, iterating values and indexes, appending, slicing, deleting, mapfile/readarray for reading lines, and the macOS Bash 3.2 vs Linux Bash 4+ differences.

Ishan KarunaratneIshan Karunaratne⏱️ 8 min readUpdated
Bash arrays reference: declaration, indexing, [@] vs [*] quoting, iteration, appending, slicing, mapfile/readarray for lines, IFS-based string splitting, plus macOS Bash 3.2 limits.

Bash arrays come in two flavors: indexed (integer keys, the default) and associative (string keys, Bash 4+ only). Both are first-class but the syntax has just enough rough edges to bite you: the difference between ${arr[@]} and ${arr[*]}, the must-quote-or-it-breaks rule, the way unset arr[2] leaves a sparse array rather than shifting indexes. Below is the full reference: declaration, indexing, iteration with for in "${arr[@]}", appending, slicing, deleting, splitting strings on a delimiter, reading a file into an array with mapfile, and the macOS Bash 3.2 limitations that catch cross-platform scripts.

How do I create a Bash array?

A Bash indexed array holds an ordered list of values under integer keys starting at 0. Declare with arr=(value1 value2 value3) or assign by index: arr[0]="a", arr[1]="b". Read a single element with "${arr[0]}", all elements as separate quoted words with "${arr[@]}", the array length with ${#arr[@]}, and the list of indexes with "${!arr[@]}". To iterate, use for x in "${arr[@]}"; do ...; done — the double quotes and [@] are both required; the unquoted or [*] forms collapse elements joined by $IFS and break on values containing spaces. Associative arrays (string keys) require Bash 4+ and are declared with declare -A arr; arr[key]="value". Append with arr+=(new_element), slice with ${arr[@]:1:3}, delete an element with unset arr[2], and delete the whole array with unset arr. For loop syntax that consumes the array, see Bash For Loops and Bash While Loops.

Jump to:

Indexed arrays: the basics

bash
# Declare and initialize in one go
fruits=("apple" "banana" "cherry")

# Or assign by index
colors[0]="red"
colors[1]="green"
colors[2]="blue"

# Read a single element
echo "${fruits[0]}"        # apple
echo "${fruits[2]}"        # cherry

# Read all elements as separate words
echo "${fruits[@]}"        # apple banana cherry

# Length of the array
echo "${#fruits[@]}"       # 3

# Length of a single element
echo "${#fruits[0]}"       # 5 (length of "apple")

A few things that catch newcomers:

  • No spaces around =. fruits = ("apple") is a command invocation, not an assignment. Write fruits=("apple").
  • Indexes start at 0. Not 1.
  • Sparse arrays are legal. arr[0]="a"; arr[5]="f" is valid; indexes 1-4 are simply unset.
  • The braces matter. $fruits[0] (no braces) expands as $fruits followed by the literal string [0]. Always use ${fruits[0]}.

The [@] vs [*] and quoting gotcha

The single biggest source of array bugs. Four expansion forms, four different behaviors:

ExpansionBehavior
"${arr[@]}"Each element as one quoted word. What you usually want.
${arr[@]}Each element split on $IFS. Breaks on elements containing spaces.
"${arr[*]}"All elements joined into ONE string with the first character of $IFS between them.
${arr[*]}Like [*] quoted, then split on $IFS. Almost never what you want.

Concrete example:

bash
items=("hello world" "foo bar" "baz")

# ✅ Correct — 3 iterations
for x in "${items[@]}"; do
    echo "Item: $x"
done

# ❌ Wrong — 5 iterations (every word treated separately)
for x in ${items[@]}; do
    echo "Item: $x"
done

The rule: "${arr[@]}" (with the double quotes) is the only correct form for iterating an array. Memorise it. The unquoted form is the #1 source of "but it worked on my machine" Bash bugs.

The [*] form has one legitimate use: joining an array into a single string with a custom separator.

bash
parts=("usr" "local" "bin")
IFS='/' echo "${parts[*]}"   # usr/local/bin

Setting IFS=/ and using [*] joins with /. Note the IFS assignment is scoped to the single echo command — it doesn't persist.

Iterating values and indexes

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

# Iterate values
for fruit in "${fruits[@]}"; do
    echo "Fruit: $fruit"
done

# Iterate indexes
for i in "${!fruits[@]}"; do
    echo "$i: ${fruits[$i]}"
done

# Iterate with C-style counter (works for dense arrays only)
for ((i = 0; i < ${#fruits[@]}; i++)); do
    echo "$i: ${fruits[$i]}"
done

The "${!arr[@]}" form (note the !) gives you the list of valid indexes. Prefer this over the C-style counter — it works correctly on sparse arrays too.

For the loop forms themselves, see Bash For Loops and Bash While Loops.

Length, appending, and slicing

bash
arr=("a" "b" "c")

# Length
echo "${#arr[@]}"          # 3

# Append a single element
arr+=("d")                 # arr is now (a b c d)

# Append multiple elements
arr+=("e" "f")             # arr is now (a b c d e f)

# Concatenate two arrays
more=("g" "h")
all=("${arr[@]}" "${more[@]}")   # all is (a b c d e f g h)

# Slice: start at index 2, take 3 elements
echo "${arr[@]:2:3}"       # c d e

# Slice: from index 2 to the end
echo "${arr[@]:2}"         # c d e f

# Last element
echo "${arr[-1]}"          # f  (Bash 4.3+ negative indexing)

+=() is the canonical append operator. Don't write arr[${#arr[@]}]=value — that works for dense arrays but breaks on sparse ones.

Slicing returns a list, so it can be passed to for or assigned to another array:

bash
arr=("a" "b" "c" "d" "e" "f")
middle=("${arr[@]:1:3}")    # (b c d)
for x in "${middle[@]}"; do echo "$x"; done

Deleting elements and arrays

bash
arr=("a" "b" "c" "d")

# Delete one element by index
unset 'arr[2]'             # arr is now (a b _ d) — index 2 is unset, NOT shifted

# Verify the sparse state
echo "${arr[@]}"           # a b d
echo "${#arr[@]}"          # 3 (count of set elements)
echo "${!arr[@]}"          # 0 1 3 (indexes 0, 1, 3 — 2 is gone)

# Delete the entire array
unset arr
echo "${arr[@]}"           # empty

Quote the index argument to unset. Without quotes, arr[2] is subject to globbing and pathname expansion if any file matches the pattern. unset 'arr[2]' is safe.

If you want to delete an element AND re-index, rebuild the array:

bash
arr=("a" "b" "c" "d")
unset 'arr[1]'
arr=("${arr[@]}")          # rebuild — indexes are now contiguous 0, 1, 2
echo "${arr[@]}"           # a c d
echo "${!arr[@]}"          # 0 1 2

Associative arrays (Bash 4+)

Associative arrays use string keys instead of integer indexes. They require Bash 4 or later — meaning NOT macOS's default Bash 3.2.

bash
# Declaration is mandatory (no implicit init like indexed arrays)
declare -A versions

versions[node]="20"
versions[python]="3.12"
versions[go]="1.22"

# Or initialize in one go
declare -A tools=([editor]="vim" [shell]="bash" [pager]="less")

# Read by key
echo "${versions[node]}"           # 20

# Iterate keys
for tool in "${!versions[@]}"; do
    echo "$tool: ${versions[$tool]}"
done

# Iterate values
for ver in "${versions[@]}"; do
    echo "Version: $ver"
done

# Check if a key exists
if [[ -v versions[node] ]]; then
    echo "node version is set"
fi

The declare -A step is mandatory. Without it, versions[node]=20 treats node as an arithmetic expression evaluating to 0 and assigns to index 0 of an indexed array. That's a silent data-loss bug — the first associative-array gotcha.

For an example of associative arrays driving a case statement dispatch, pair this with the conditional patterns from the if/else reference.

Reading lines into an array with mapfile

mapfile (also spelled readarray) reads input into an array, one line per element. Bash 4+ only.

bash
# Read every line of a file into the array
mapfile -t lines < file.txt
echo "${lines[0]}"         # first line
echo "${#lines[@]}"        # number of lines

# Read from a command's output
mapfile -t users < <(cut -d: -f1 /etc/passwd)

# Strip blank lines and comments
mapfile -t config < <(grep -v '^#\|^$' app.conf)

The -t flag strips the trailing newline from each line (you almost always want this).

For files with NUL-delimited records (from find -print0, for example):

bash
mapfile -d '' -t files < <(find . -name "*.log" -print0)

This is the safe way to load filenames that may contain spaces or newlines into an array. The same -print0 / -d '' pair shows up in the Bash For Loops safe-file-iteration section.

Without mapfile (Bash 3.2 fallback):

bash
# Bash 3.2 portable: read into array one line at a time
lines=()
while IFS= read -r line; do
    lines+=("$line")
done < file.txt

Slower for very large files but works everywhere.

Splitting a string into an array

CSV strings, colon-separated paths, comma-delimited inputs — Bash splits with read -ra:

bash
csv="apple,banana,cherry"
IFS=',' read -ra fruits <<< "$csv"
echo "${fruits[0]}"        # apple
echo "${fruits[1]}"        # banana
echo "${fruits[2]}"        # cherry

Three things going on:

  • <<< is a here-string. It feeds the string as stdin to read.
  • IFS=',' sets the field separator just for this read command (doesn't persist).
  • -ra combines -r (raw, no backslash interpretation) and -a (read into array).

For multi-character delimiters, read doesn't help directly — use parameter expansion or awk:

bash
# Split on "::"
input="apple::banana::cherry"
IFS=':' read -ra parts <<< "${input//::/:}"     # collapse :: to : first

For splitting $PATH into entries:

bash
IFS=':' read -ra path_entries <<< "$PATH"
for entry in "${path_entries[@]}"; do
    echo "PATH entry: $entry"
done

Common pitfalls

1. Unquoted ${arr[@]}. Breaks on elements containing spaces. Always "${arr[@]}".

2. Using [*] when you mean [@]. "${arr[*]}" collapses to one string. Use [*] only when you specifically want a single joined string.

3. Missing declare -A on associative arrays. Without it, arr[key]=value silently writes to index 0 of an indexed array because Bash evaluates key as arithmetic.

4. unset arr[2] leaves a hole. It doesn't shift later indexes down. Rebuild with arr=("${arr[@]}") if you need contiguous indexes.

5. Unquoted unset arr[2]. Without quotes, the bracket pattern can be glob-expanded against the working directory. Use unset 'arr[2]'.

6. Space around = in declaration. arr = (a b c) is a command invocation; arr=(a b c) is an assignment. Bash assignment forbids whitespace around =.

7. $arr without index returns only arr[0]. echo "$arr" prints the first element, not the whole array. Use "${arr[@]}".

8. mapfile on macOS Bash 3.2. mapfile/readarray don't exist. Use the while IFS= read -r line; do arr+=("$line"); done fallback.

9. Pipe subshell scoping with arrays. cat file | while read x; do arr+=("$x"); donearr is lost after the loop because the pipe creates a subshell. Use process substitution: while read x; do arr+=("$x"); done < <(cat file). Same bug as covered in Bash While Loops.

macOS Bash 3.2 vs Linux Bash 4+

macOS ships Bash 3.2 (frozen since 2007 due to GPLv3 licensing). Linux distros ship Bash 4 or 5. Several array features only work on Bash 4+:

FeatureBash 3.2 (macOS default)Bash 4+ (Linux, brew)
Associative arrays (declare -A)errorworks
mapfile / readarraynot availableworks
Negative indexing (${arr[-1]})not availableworks (4.3+)
${!arr[@]} for indexesworksworks
declare -p arr to dumpworksworks

If your script needs Bash 4+ features and might run on macOS, either install modern Bash via Homebrew (brew install bash, gives you 5.x at /opt/homebrew/bin/bash or /usr/local/bin/bash) or write portable fallbacks. The fallbacks: while read; arr+=() instead of mapfile, ${arr[$((${#arr[@]}-1))]} instead of ${arr[-1]}, and an indexed-array workaround for associative arrays (parallel arrays with matching index, or a single string with delimited key=value pairs).

If you can require Bash 4+, the shebang should reflect that:

bash
#!/usr/bin/env bash
if (( BASH_VERSINFO[0] < 4 )); then
    echo "This script requires Bash 4.0 or later. Current: $BASH_VERSION" >&2
    exit 1
fi

Debugging Bash arrays

bash
# Dump the array's full declaration
declare -p arr

# Trace expansions
set -x
echo "${arr[@]}"
set +x

# Static analysis catches every array pitfall above
shellcheck script.sh

declare -p arr prints the exact declaration syntax that would recreate the array — including sparse indexes for indexed arrays and key-value pairs for associative arrays. It's the single best debugging tool for "why is my array doing that".

For automating array-driven workflows in production, the How to Export All MySQL Databases script uses arrays to hold the list of databases pulled from mysql -e 'SHOW DATABASES', and the How to Export and Import PuTTY Settings workflow uses associative-style key/value pairs (via parallel arrays on macOS) to map session names to host details.

What to do next

FAQ

TagsBashShell ScriptingLinuxarraysassociative arraysmapfile
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

AWS IAM policy examples by use case: S3 read-only with prefix, S3 read-write with delete denied, EC2 admin scoped to a region via aws:RequestedRegion, Lambda execute and read env vars but not write, iam:PassRole for service-linked roles, MFA-required via aws:MultiFactorAuthPresent, IP-restricted via aws:SourceIp, VPC-endpoint-only via aws:SourceVpce, tag-based prod-vs-dev isolation via aws:ResourceTag, plus the anatomy of a policy document and IAM Access Analyzer for least-privilege validation.

AWS IAM Policy Examples: S3, EC2, Lambda, and Least-Privilege Patterns

A working library of AWS IAM policy examples: S3 read-only with prefix, EC2 admin scoped to a region, Lambda execute-but-not-write, MFA-required, IP-restricted, VPC-endpoint-only, tag-based prod-vs-dev isolation, and the iam:PassRole pattern. Plus the anatomy of a policy document and how Access Analyzer narrows over-permissive Resource: "*" grants.

Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.