Skip to content

Bash Arrays: Indexed, Associative, Append, and Safe Iteration

basharraysassociative-arraysscripting
4 min read

Quick Answer

A bash array holds a list where every element stays one unit, whatever characters it contains. Declare one with arr=(web-01 web-02 db-prod), append with arr+=(new-host), read the count with ${#arr[@]}, and iterate over a double-quoted ${arr[@]} expansion, which is what keeps an element containing a space intact. A space-separated string iterated with for h in $SERVERS splits on every space and glob character, so one hostname with a space becomes two nonexistent hosts and the script reports success for both. Associative arrays, declared with declare -A, map keys to values for lookups such as port[nginx]=80. This page covers indexed and associative arrays, safe iteration, slicing, reading a file into an array with mapfile, and passing arrays into functions. Arrays need bash 4 or later, the default on every current Linux distribution; macOS ships bash 3.2, so install a newer bash from Homebrew there.

Arrays and strict mode

The safe-template guide covers the nounset rule that made "${arr[@]}" on an empty array a folklore error before bash 4.4, and the header every script iterating a list should start with. Full guide: The Safe Bash Script Template.

A sysadmin stored a fleet's server list the obvious way: a space-separated string, SERVERS="web-01 web-02 db-prod", iterated with for host in $SERVERS. It worked for two years. Then the cloud provider assigned a new instance the display name web-prod 01 — with a space, which their naming policy permitted — and the next maintenance run looped over web-prod and 01 as two separate hosts. The script tried to SSH into both, retried on failure, and generated 47 connection attempts to nonexistent hostnames in under a minute. AWS flagged it as a possible compromised host and locked the account pending review. The root cause was not the space in the hostname. It was storing a list as a string. An array would have made that space completely invisible, because each element of an array stays one element no matter what characters it contains.

Declaring indexed arrays

Two syntaxes, both producing a real array:

bash
# Parentheses form — a known list: fruits=("apple" "dragon fruit" "kiwi") # Assign-by-index form — building it up: servers[0]="web-01" servers[1]="db-prod 02" servers[2]="cache-03"

The middle elements contain spaces, and that is fine — dragon fruit and db-prod 02 are each a single element. The same characters in a space-separated string would have been two items.

The critical distinction: [@] vs [*]

This is the difference that prevents the outage above:

bash
servers=("web-01" "db-prod 02" "cache-03") # RIGHT: each element stays one word, even with the embedded space. for host in "${servers[@]}"; do echo "→ $host" # three lines: web-01 / db-prod 02 / cache-03 done

"${servers[@]}" in double quotes expands to one word per element. "${servers[*]}" instead joins every element into a single string separated by the first character of IFS (a space by default). For iterating or passing arguments to a command, you want [@] essentially always. Reserve [*] for the rare case where you want one display string — for example echo "Checking: ${servers[*]}". Drop the quotes on either form and you are back to uncontrolled word-splitting, so keep the double quotes.

Appending

Grow an array with the += compound assignment:

bash
failures=() # start empty failures+=("web-01: timeout") # append one element failures+=("db-prod 02: refused")

Each += adds exactly one element regardless of spaces inside it. The tempting shortcut list="$list new item" flattens everything back into a string and reintroduces the exact bug arrays exist to eliminate. Always append with +=( ).

Length and validation

${#array[@]} is the element count — and it is genuinely useful for guarding input:

bash
required_args=2 if [[ "${#@}" -lt "$required_args" ]]; then echo "$CROSS Need at least $required_args arguments, got ${#@}" exit 1 fi

Watch the trap here: ${#array[@]} counts elements, but ${#array} (no [@]) gives the string length of the first element. They look almost identical and mean entirely different things. To count, always include [@].

Slicing

Extract a sub-range with ${array[@]:start:length}:

bash
items=("a" "b" "c" "d" "e") echo "${items[@]:1:3}" # b c d — start at index 1, take 3 elements

Useful for paging through a long list or skipping a known header element.

Associative arrays

Bash 4+ supports key/value maps via declare -A. The canonical use is a lookup table — service names to the ports they should be listening on:

bash
declare -A service_ports service_ports[nginx]=80 service_ports[postgres]=5432 service_ports[redis]=6379 # Iterate keys with "${!map[@]}", values with "${map[@]}". for svc in "${!service_ports[@]}"; do echo "$svc should listen on ${service_ports[$svc]}" done

"${!service_ports[@]}" (note the !) expands to the keys; "${service_ports[$key]}" fetches a value. This replaces brittle parallel arrays or case statements with a direct, readable map. Associative arrays require bash 4.0+, so they will not work on the system bash that ships on macOS (3.2) — relevant if your script runs on mixed CI runners.

Iterating by index, deleting, and passing to functions

Sometimes you need the index, not just the value — to modify an element in place or report its position. "${!array[@]}" expands to the list of indices:

bash
releases=("v1.0" "v1.1" "v1.2") for i in "${!releases[@]}"; do echo "slot $i${releases[$i]}" done

Deleting with unset leaves a gap rather than renumbering. unset 'releases[1]' removes index 1, but indices 0 and 2 remain, so the array is now sparse and a later ${releases[1]} is empty. To compact it back to contiguous indices, reassign through [@]:

bash
unset 'releases[1]' # removes the element but leaves a hole at index 1 releases=("${releases[@]}") # re-pack: indices become 0,1 again

Passing an array into a function needs care, because "$@" flattens every argument into one flat list and the receiving function cannot tell where one array ended. Pass it by name with a nameref instead:

bash
print_all() { local -n arr_ref="$1" # nameref: receive the array by name, no flattening local item for item in "${arr_ref[@]}"; do echo "→ $item" done } print_all releases # pass the NAME, not "${releases[@]}"

The nameref (local -n, bash 4.3+) hands the whole array to the function without copying it through positional parameters, so elements with spaces survive and you avoid the classic bug of an array silently collapsing into a string at the function boundary.

Complete production script

A service health checker that demonstrates the whole point of arrays: it stores the service list as an array, checks each one, collects every failure into a second array, and reports all of them at the end — rather than stopping at the first failure and hiding the rest.

bash
#!/bin/bash # Script: health-check.sh # Purpose: Without collecting failures, the script stops at the first dead service and hides the others. # Usage: ./health-check.sh set -euo pipefail CHECK="✓" CROSS="✗" # Service name → expected port. Associative array = readable, no parallel lists. declare -A services services[nginx]=80 services[postgres]=5432 services[redis]=6379 failures=() # indexed array; we append every failure and report them together for svc in "${!services[@]}"; do port="${services[$svc]}" # Quote everything; a value with a space would otherwise split the test. if timeout 2 bash -c "echo > /dev/tcp/127.0.0.1/$port" 2>/dev/null; then echo "$CHECK $svc up on port $port" else echo "$CROSS $svc DOWN on port $port" failures+=("$svc (port $port)") # collect, do not abort fi done # Report the complete failure set, using ${#failures[@]} to decide the exit code. if [[ "${#failures[@]}" -gt 0 ]]; then echo "$CROSS ${#failures[@]} service(s) failed: ${failures[*]}" exit 1 fi echo "$CHECK All ${#services[@]} services healthy"

The associative array maps each service to its port with no parallel-array bookkeeping. The indexed failures array accumulates problems so a single dead service does not mask three others — ${failures[*]} prints them as one readable line and ${#failures[@]} drives the exit code. Stopping at the first failure is how you spend an hour fixing nginx and then discover postgres was also down the whole time; collecting failures into an array is how you see the full picture in one run.

Raw script, MIT licensed: scripts/bash-arrays.sh on GitHub

PAID RESOURCE — $9

The Production Bash Toolkit

An operational script system + a 31-function shared library + a 52-page field guide. The production layer the free snippets don't cover.

Get the Toolkit →
curl -O bashlib-starter.sh

Get the bashlib starter

Ten functions I source into every script on my own boxes — strict-mode setup, an ERR trap that names the failing line, lock and timeout wrappers, and cleanup that runs on every exit path. One email, no sequence.

BashSnippets logo

Written by Travis

Creator of BashSnippets.xyz

bashsnippets.xyz/about

Related Snippets

Frequently Asked Questions

faq — snippet

What is the difference between ${array[@]} and ${array[*]} in bash?

Quoted, "${array[@]}" expands to one word per element, so iteration and argument passing keep each element intact. "${array[*]}" joins all elements into a single string using the first character of IFS as the separator. Use [@] for loops and passing arguments; use [*] only when you deliberately want one joined string.

faq — snippet

How do I append to an array in bash?

Use the += compound operator: array+=("new value"). It appends one element even if the value contains spaces. Avoid array="$array new" — that turns the array into a flat string and reintroduces the word-splitting bug arrays exist to prevent.

faq — snippet

How do I get the length of a bash array?

${#array[@]} gives the number of elements. Note that ${#array} (without [@]) gives the string length of the first element instead, which is a common mistake. To count, always include [@].

faq — snippet

What is an associative array in bash and when do I need one?

An associative array (bash 4+, declared with declare -A) maps arbitrary string keys to values instead of using numeric indices. Use it for lookups: service name to port, username to home directory, hostname to IP. Iterate keys with "${!map[@]}" and access values with "${map[$key]}".