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 Karunaratne⏱️ 8 min readUpdated
Share thisCopied
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

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

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.