Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Bash & Python Automation By James Joyner IV · · 8 min read

Bash Arrays and Associative Arrays: The Right Way to Hold State in Ops Scripts

Most flaky Bash scripts fall apart the moment they handle a list with a space in it. Indexed and associative arrays fix that — here's how to use them properly.

  • #bash
  • #python
  • #arrays
  • #shell-scripting
  • #automation
  • #devops

I have debugged the same Bash bug more times than I’d like to admit: a script loops over a “list” of hostnames stored in a plain string, everything works in the demo, and then someone adds a server named web prod 01 and the whole thing detonates. The fix is almost always the same — stop pretending strings are lists. Bash has had real arrays for years, and associative arrays since version 4. Once you internalize them, a whole class of word-splitting bugs disappears.

This is the guide I wish someone had handed me before I shipped a deployment script that treated $SERVERS as a space-delimited string.

Why a space-delimited string is a trap

The classic pattern looks innocent:

SERVERS="web01 web02 db01"
for s in $SERVERS; do
  ssh "$s" uptime
done

This works right up until a value contains whitespace, a glob character, or an empty element. Unquoted $SERVERS undergoes word splitting and pathname expansion. A value of * will expand to every file in your current directory. That’s not a hypothetical — I’ve watched it happen during a maintenance window.

The correct primitive is an indexed array.

Indexed arrays: lists that survive whitespace

Declare and iterate with the array-safe quoting idiom:

servers=("web01" "web prod 02" "db01")

for s in "${servers[@]}"; do
  echo "Connecting to: $s"
done

The magic is "${servers[@]}" — quoted, with @. That expands to one word per element, preserving spaces inside elements and never globbing. Memorize that exact spelling; "${servers[*]}" (star) joins everything into one string, which is almost never what you want in a loop.

A few patterns I reach for constantly:

servers+=("cache01")          # append
echo "${#servers[@]}"         # length
echo "${servers[0]}"          # first element
unset 'servers[1]'            # delete an element (leaves a gap)
servers=("${servers[@]}")     # re-index to close gaps

To build an array safely from command output — say, a list of pod names — use mapfile (a.k.a. readarray) so newlines, not spaces, delimit elements:

mapfile -t pods < <(kubectl get pods -o name)
echo "Found ${#pods[@]} pods"

The -t strips the trailing newline from each line, and the process substitution < <(...) keeps the array in the current shell rather than a subshell, which matters because a piped while read loop runs in a subshell and loses your array.

Associative arrays: the lookup table you’ve been faking with case statements

Before Bash 4, people emulated maps with giant case blocks or by encoding keys into variable names with eval. Don’t. Declare a real map with declare -A:

declare -A region_endpoint
region_endpoint["us-east-1"]="https://api.use1.internal"
region_endpoint["eu-west-1"]="https://api.euw1.internal"

region="eu-west-1"
echo "${region_endpoint[$region]}"

The declare -A is mandatory — without it, Bash treats the subscript as an arithmetic expression and silently stores everything under index 0. That’s the single most common associative-array bug, and it fails quietly.

Iterating over keys and values:

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

Note "${!map[@]}" for keys and "${map[@]}" for values. The ! prefix gives you keys — the same syntax that gives you indices in a regular array.

A real example: counting and de-duplicating

Associative arrays make a beautiful “set” and a beautiful counter. Here’s a tally of HTTP status codes from a log, no sort | uniq -c pipeline required:

declare -A status_count
while read -r _ _ _ status _; do
  ((status_count["$status"]++))
done < access.log

for code in "${!status_count[@]}"; do
  printf '%s: %d\n' "$code" "${status_count[$code]}"
done

Using a key’s presence as a set membership check is a clean way to filter duplicates while preserving first-seen order:

declare -A seen
unique=()
for host in "${all_hosts[@]}"; do
  if [[ -z "${seen[$host]:-}" ]]; then
    seen["$host"]=1
    unique+=("$host")
  fi
done

The :- guards against set -u (nounset) blowing up on an unset key. If you run with set -euo pipefail — and you should — that guard is not optional.

Passing arrays around (the part nobody documents)

Bash doesn’t pass arrays to functions by value cleanly. You have two sane options. For read access, pass elements as arguments and reconstruct:

print_all() {
  local items=("$@")
  for i in "${items[@]}"; do echo " - $i"; done
}
print_all "${servers[@]}"

For Bash 4.3+, use a nameref to operate on the caller’s array in place:

add_default_port() {
  local -n arr=$1          # nameref to the array named in $1
  for i in "${!arr[@]}"; do
    arr[$i]="${arr[$i]}:8080"
  done
}
add_default_port servers

Namerefs are the cleanest way to mutate an array without globals, but they’re a Bash-ism — if you need POSIX sh, you don’t have arrays at all, which is itself a strong signal to switch to Python.

When to stop and reach for Python

Arrays handle lists and flat maps well. The moment you need nested structures — a list of dicts, a map of maps, anything resembling JSON — Bash will fight you. That’s the boundary. If you find yourself encoding structure into key strings like server_us-east-1_port, you’ve outgrown the shell. Pipe your data to a small Python script or parse it with jq instead.

I keep a personal rule: flat list or flat lookup, Bash arrays are perfect and faster to write. Nested or relational, it’s Python. Knowing where that line is has saved me from a lot of eval-based regret.

For more battle-tested patterns — and the prompts I use to have AI review array-quoting before it ships — see the Bash & Python automation category and our prompt library.

Code examples are illustrative. Test array handling against inputs with spaces, globs, and empty elements before trusting a script in production.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.