Shell Lesson 6 of 42

Arrays: Indexed, Associative, Slicing, mapfile/readarray & the Cardinality Discipline That Replaces Word-Splitting Hacks Forever

If you’ve spent years writing shell that pretends arrays don’t exist — using space-separated strings as fake lists, parsing the output of ls, or shoving “structured” data into colon-separated KEY=VALUE blobs — this is the lesson that changes how you write shell. Bash has had real indexed arrays since the early 90s and real associative arrays (hashes) since version 4 (released 2009). They are first-class values, fast, and dramatically safer than every string-based hack people use to avoid them.

This lesson covers both kinds of array, the iteration patterns that go with them, the mapfile/readarray builtin for reading lines into an array safely, the all-important "${arr[@]}" vs "${arr[*]}" distinction, and the file-list collection pattern that should replace every for f in $(ls) you’ve ever written.

A few macOS users will hit a wall here: macOS ships with bash 3.2 (it’s been frozen there since 2007 because of GPLv3 licensing). Associative arrays don’t work on stock macOS bash. The fix is brew install bash, then either chsh -s /opt/homebrew/bin/bash or just call your scripts with the new bash. Every example in this lesson assumes bash 4+; we’ll note bash 3.2 limitations where they matter.

In a nutshell

An array is a variable that holds many values instead of one. Think of a variable like a single labelled cardboard box — it holds one thing. An array is a filing cabinet: one label on the front (FRUITS), many drawers inside, each holding one item. There are two kinds of cabinet. An indexed array has drawers numbered 0, 1, 2, … — you pull “drawer 2.” An associative array (a “hash” or “map”) has drawers labelled by name — you pull the drawer marked "role" and get back "engineer". That’s the entire idea; everything else is syntax for putting things in and taking them out.

Why should a beginner care? Because the moment you have “a list of things” — a list of files, users, servers, log lines, arguments — the string-hack way of doing it (FILES="a.txt b.txt c.txt" and hoping spaces never appear) breaks the instant a filename contains a space, a newline, or a *. A real array keeps each item in its own drawer, byte-for-byte, so it simply cannot be mangled by the shell’s word-splitting. Learning arrays is the single biggest jump in safety you will make in this course.

There is one discipline you must build a reflex for, and this whole lesson keeps circling back to it: when you take everything out of the cabinet, you must say how many things you want it to become. Write "${arr[@]}" and you get back N separate items (the right choice almost always — for passing a list of arguments to a command). Write "${arr[*]}" and you get back one glued-together string (right only for printing a summary line). And a bare $arr — the beginner’s favourite mistake — quietly gives you back only the first drawer, not the cabinet. Get that trio straight and you have the hard part.

Level: Beginner → Intermediate · Time: ~40 min. Bring Variables, quoting & IFS and Loops; we lean on both.

Shell arrays end to end: on the left, file lines and command output (find -print0) are the SOURCE; mapfile -t / readarray with a -d '' NUL buffer LOADs them byte-safely into an array; that array is STOREd as one of two kinds — an indexed array of ordered integer slots (declare -a, sparse) or an associative array keyed by string (declare -A, bash 4+); you then TRANSFORM the whole collection in one expansion (slice a[@]:o:l, or map ${a[@]##/}, or sort/dedupe via printf into sort); and finally EXPAND it under the cardinality discipline — "${a[@]}" yields N separate words, "${a[]}" yields one IFS-joined string, and a bare $a is only element 0; numbered badges mark loading safely, the two array kinds, whole-array transforms, the @-vs-* choice, and the bare-name trap

Read the diagram left to right: raw lines are loaded by mapfile into an array, stored as either an indexed or associative collection, transformed as a whole without per-element forks, and finally expanded — where the @ vs * choice decides whether you get many arguments or one string.

Prerequisites & what you’ll be able to do

Before this lesson you should be comfortable with simple variables and ${VAR} expansion, the difference quotes make (word-splitting), and IFS from Variables, quoting & IFS; basic for loops from Loops; and local scope from Functions & local scope. If parameter expansion (${VAR##*/}, ${VAR:-default}, ${VAR:offset:length}) is fuzzy, skim the variables lesson first — arrays reuse all of it.

After this lesson you will be able to:


1. Indexed arrays: the basics

An indexed array is an ordered collection of strings, indexed by non-negative integers starting at 0. Bash arrays are sparse — you can have indices 0, 1, 5, 100 with nothing in between, and the array still works.

Declaration and assignment

# Implicit declaration with the parentheses syntax
FRUITS=(apple banana cherry)

# Explicit declaration
declare -a FRUITS=(apple banana cherry)

# Empty array
EMPTY=()

# With explicit indices (allows sparse arrays)
SPARSE=([0]=zero [5]=five [10]=ten)

# With computed values
TODAY=$(date +%a)
DAYS=(Mon Tue Wed Thu Fri Sat Sun "$TODAY")

The parentheses-with-spaces syntax is the canonical form. Each space-separated token becomes one element. The same word-splitting and quoting rules from L2 apply — quote elements that contain spaces:

NAMES=(alice bob "carol jones" david)
echo "${#NAMES[@]}"          # 4 — four elements
echo "${NAMES[2]}"           # carol jones

If you wrote NAMES=(alice bob carol jones david) (no quotes around "carol jones"), you’d get five elements with carol and jones as separate tokens.

Accessing elements

FRUITS=(apple banana cherry)

echo "${FRUITS[0]}"          # apple — first element (index 0!)
echo "${FRUITS[1]}"          # banana
echo "${FRUITS[2]}"          # cherry
echo "${FRUITS[-1]}"         # cherry — bash 4.3+: negative indices count from end
echo "${FRUITS[-2]}"         # banana
echo "${FRUITS}"             # apple — same as ${FRUITS[0]}, NOT all elements!

The last form is a notorious pitfall. $FRUITS and ${FRUITS} and ${FRUITS[0]} are all the same — they give you the first element, not the array. To get all elements, you need the special [@] or [*] index. We’ll cover the difference between those in the next section.

Getting all elements

FRUITS=(apple banana cherry)

echo "${FRUITS[@]}"          # apple banana cherry — all elements as separate words
echo "${FRUITS[*]}"          # apple banana cherry — all elements joined by IFS[0]
echo "${#FRUITS[@]}"         # 3 — number of elements
echo "${!FRUITS[@]}"         # 0 1 2 — list of indices (note the leading !)

The [@] form expands to one shell-word per element. The [*] form joins all elements with the first character of IFS (default: a space). When unquoted, the difference is invisible. When quoted, the difference is everything. Section 4 of this lesson is dedicated to that distinction — don’t skim it.

Appending

FRUITS=(apple banana)
FRUITS+=(cherry)             # append one
FRUITS+=(date elderberry)    # append several
echo "${FRUITS[@]}"          # apple banana cherry date elderberry

The += operator with (...) appends. The parentheses are essential — without them, += does string concatenation on the first element only:

FRUITS+=cherry               # WRONG: appends "cherry" to ${FRUITS[0]}, giving "applecherry"
echo "${FRUITS[0]}"          # applecherry

Always use arr+=(value) to append, not arr+=value.

Modifying and deleting

FRUITS=(apple banana cherry)
FRUITS[1]="berry"            # replace index 1
echo "${FRUITS[@]}"          # apple berry cherry

unset 'FRUITS[1]'            # remove index 1
echo "${FRUITS[@]}"          # apple cherry
echo "${#FRUITS[@]}"         # 2
echo "${!FRUITS[@]}"         # 0 2 — note: 1 is gone, 2 didn't shift down (sparse!)

unset 'FRUITS[1]' removes the element but does not renumber the remaining elements. The array is now sparse. This is sometimes surprising. To get a “compact” array after deletion:

FRUITS=("${FRUITS[@]}")      # rebuild without gaps
echo "${!FRUITS[@]}"         # 0 1 — now indices are contiguous

The quoting around 'FRUITS[1]' matters: bash’s globbing might expand [1] as a character class against files in your current directory, breaking the unset. Always quote the argument to unset for arrays.

To delete the entire array:

unset FRUITS                 # delete the variable entirely
FRUITS=()                    # set to empty (variable still defined as array)

2. Iterating over arrays

The canonical iteration form:

FRUITS=(apple banana cherry)

for fruit in "${FRUITS[@]}"; do
  echo "$fruit"
done

Notice the double quotes around "${FRUITS[@]}". This expansion produces one quoted shell-word per array element, preserving every byte of every element including spaces, tabs, newlines. This is the only correct iteration form for arrays.

Without quotes:

NAMES=(alice "bob jones" carol)
for n in ${NAMES[@]}; do      # WRONG: bash word-splits each element, giving 4 names
  echo "$n"
done
# Output:
# alice
# bob
# jones
# carol

With quotes:

for n in "${NAMES[@]}"; do    # CORRECT: 3 names preserved
  echo "$n"
done
# Output:
# alice
# bob jones
# carol

This is the same lesson as L2 — quoting suppresses word splitting. Arrays don’t change the rule.

Iterating with index access

FRUITS=(apple banana cherry)

for i in "${!FRUITS[@]}"; do
  echo "${i}: ${FRUITS[$i]}"
done
# Output:
# 0: apple
# 1: banana
# 2: cherry

${!FRUITS[@]} expands to the list of indices. Useful when you need both the index and the value (like Python’s enumerate).

Iterating with a counter

FRUITS=(apple banana cherry)
for ((i=0; i<${#FRUITS[@]}; i++)); do
  echo "${i}: ${FRUITS[$i]}"
done

C-style loop for when you want to skip elements, walk in reverse, or process in pairs:

# Process in pairs of two
PAIRS=(name alice age 30 role engineer)
for ((i=0; i<${#PAIRS[@]}; i+=2)); do
  echo "${PAIRS[$i]} = ${PAIRS[$i+1]}"
done

For most cases, for x in "${arr[@]}" is cleaner. Drop to indexed iteration only when you need the index for arithmetic.


3. Associative arrays (bash 4+ hashes)

An associative array (also called a “hash” or “map”) indexes by string keys rather than integer positions. You declare them with declare -A:

declare -A USER

USER[name]="alice"
USER[age]=30
USER[role]="engineer"

echo "${USER[name]}"         # alice
echo "${USER[age]}"          # 30

Or with the parentheses form:

declare -A CAPITALS=(
  [USA]=Washington
  [UK]=London
  [France]=Paris
  [Japan]=Tokyo
)

echo "${CAPITALS[USA]}"      # Washington
echo "${CAPITALS[Japan]}"    # Tokyo

Critical: you must declare -A before assigning. Without declare -A, bash treats USER[name]=alice as an indexed-array assignment with the string name evaluated as an arithmetic expression (which gives 0). You’d get USER[0]=alice — silently wrong.

# WRONG — without declare -A first
USER[name]="alice"           # assigns to USER[0] because "name" arithmetic-evaluates to 0
echo "${USER[anything]}"     # also "alice" — every key looks like 0
echo "${USER[name]}"         # alice (still index 0)

# RIGHT
declare -A USER
USER[name]="alice"
echo "${USER[name]}"         # alice
echo "${USER[role]:-unknown}"  # unknown

This is one of the most common bugs in associative-array code. Always declare -A first. When in doubt, do it explicitly at the top of your function:

process_user() {
  declare -A user            # local-scoped associative array
  user[name]="$1"
  user[age]="$2"
  # ...
}

In bash 4.4+ you can combine: declare -A is implicitly local inside a function, but local -A user is more explicit and works in all bash 4.x.

Iterating

Iteration is the same shape as indexed arrays:

declare -A CAPITALS=(
  [USA]=Washington
  [UK]=London
  [France]=Paris
)

# Iterate over keys
for country in "${!CAPITALS[@]}"; do
  echo "${country}: ${CAPITALS[$country]}"
done

The ${!ARR[@]} form gives the list of keys for both indexed and associative arrays — for indexed, those are integers; for associative, they’re strings.

Key order is unspecified. Bash does not guarantee any particular ordering for associative-array iteration. If you need sorted output:

for country in $(echo "${!CAPITALS[@]}" | tr ' ' '\n' | sort); do
  echo "${country}: ${CAPITALS[$country]}"
done

Or, more robustly with a real array:

mapfile -t SORTED_KEYS < <(printf '%s\n' "${!CAPITALS[@]}" | sort)
for country in "${SORTED_KEYS[@]}"; do
  echo "${country}: ${CAPITALS[$country]}"
done

Membership test

declare -A FLAGS=([verbose]=1 [debug]=1)

if [[ -v FLAGS[verbose] ]]; then
  echo "verbose is set"
fi

if [[ -n "${FLAGS[debug]+x}" ]]; then
  echo "debug exists (POSIX-friendly form)"
fi

The -v test (bash 4.2+) checks “is this variable/key set” — works for both regular variables and array keys. The ${VAR+x} form is the older trick: expands to the literal x if VAR is set, empty if not.

Counting

echo "${#CAPITALS[@]}"       # number of key-value pairs

Removing a key

unset 'CAPITALS[USA]'        # delete one key
echo "${!CAPITALS[@]}"       # USA is gone

The same quoting rule applies: quote the argument to prevent globbing.


4. The "${arr[@]}" vs "${arr[*]}" distinction — read this carefully

This is the single most-misunderstood piece of array syntax in bash. It also appears for "$@" vs "$*" (which we covered in L2), and for the same reason. The rule is clean once you see it.

ARR=(a b "c d" e)
Expansion Behaviour
${ARR[@]} Splits each element on IFS; effectively gives 5 words: a b c d e
${ARR[*]} Joins all elements with first char of IFS, then splits on IFS; same 5 words
"${ARR[@]}" Each element becomes one quoted word; 4 args: a, b, c d, e
"${ARR[*]}" All elements joined by first char of IFS; 1 quoted word: a b c d e

The rule:

Practical examples:

ARGS=(--verbose --port 8080 "--name=Alice Smith")

# CORRECT — 4 separate arguments, "--name=Alice Smith" stays intact
my-tool "${ARGS[@]}"

# WRONG — 1 argument: "--verbose --port 8080 --name=Alice Smith"
my-tool "${ARGS[*]}"

# WRONG — 5+ arguments because "--name=Alice Smith" word-splits
my-tool ${ARGS[@]}

When in doubt: use "${arr[@]}". The [*] form is correct only when you specifically want a single string.

Joining elements with a custom separator

The first character of IFS controls the join character for "${arr[*]}". So you can join with any character:

ARR=(one two three)

IFS=','      ; echo "${ARR[*]}"    # one,two,three
IFS=' | '    ; echo "${ARR[*]}"    # one |two |three   (only first char of IFS used)
IFS=$'\n'    ; echo "${ARR[*]}"    # one<NL>two<NL>three

The first-character rule is annoying — you can’t join with a multi-character separator this way. For multi-char joins, use printf or a loop:

join_by() {
  local sep="$1"; shift
  local out=""
  local first=1
  local elem
  for elem in "$@"; do
    if (( first )); then
      out="$elem"
      first=0
    else
      out+="${sep}${elem}"
    fi
  done
  printf '%s' "$out"
}

ARR=(one two three)
join_by ' | ' "${ARR[@]}"     # one | two | three

This is a common helper to keep around.


5. Slicing and substring operations

Bash supports slicing on arrays with the same syntax as substring extraction on strings (covered in L2).

ARR=(zero one two three four five)

echo "${ARR[@]:1:3}"         # one two three — start at index 1, take 3 elements
echo "${ARR[@]:2}"           # two three four five — start at index 2 to end
echo "${ARR[@]: -2}"         # four five — last 2 (note the leading space!)

The slice syntax: ${arr[@]:OFFSET:LENGTH}. Both are arithmetic expressions. Negative offsets count from the end (need a leading space to disambiguate from :-default).

For associative arrays, slicing on [@] doesn’t quite work the same way (key order is unspecified), but slicing on the list of keys does:

declare -A USER=([name]=alice [age]=30 [role]=engineer)

KEYS=("${!USER[@]}")           # snapshot keys into an indexed array
echo "${KEYS[@]:0:2}"          # first two keys (whatever those are)

String operations on array elements

All the parameter-expansion operators from L2 work on array elements — and on the entire array at once with [@].

PATHS=(/var/log/a.log /var/log/b.log /tmp/c.log)

# Apply a transformation to every element
echo "${PATHS[@]##*/}"         # a.log b.log c.log — basename of every element
echo "${PATHS[@]%.log}"        # /var/log/a /var/log/b /tmp/c — strip .log suffix
echo "${PATHS[@]/log/LOG}"     # /var/LOG/a.log /var/LOG/b.log /tmp/c.LOG — replace first "log" each
echo "${PATHS[@]//log/LOG}"    # /var/LOG/a.LOG /var/LOG/b.LOG /tmp/c.LOG — replace all "log"s

This is enormously powerful. You can do basename, dirname, replacement, case folding, and length operations across an entire array in one expansion, with no fork. It’s much faster than piping through sed or awk.

# Fast: bash builtin, no fork
LOWERED=("${ARR[@]}")
LOWERED=("${LOWERED[@],,}")    # all lowercase

# Slow: forks one awk per element
LOWERED=()
for x in "${ARR[@]}"; do
  LOWERED+=("$(echo "$x" | tr '[:upper:]' '[:lower:]')")
done

In a loop with thousands of iterations, the bash-builtin form can be 100× faster.


6. mapfile (also called readarray) — the right way to load files into arrays

mapfile -t ARR < FILE reads a file line by line and stores each line as one array element. The -t flag strips trailing newlines from each line (almost always what you want).

mapfile -t LINES < /etc/hostname
echo "${LINES[0]}"           # the hostname
echo "${#LINES[@]}"          # 1

mapfile -t USERS < users.txt
for u in "${USERS[@]}"; do
  echo "Processing user: $u"
done

mapfile is the modern, byte-safe replacement for while read line; do arr+=("$line"); done and the various arr=( $(cat file) ) hacks people write. It handles every line correctly: leading whitespace, trailing whitespace, embedded glob characters — every edge case is handled by being byte-exact and not subject to word splitting.

Reading from a command instead of a file

mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend | awk '{print $1}')
echo "${#SERVICES[@]}"

The < <(cmd) is process substitution (covered in L4 and revisited in L7). It runs cmd and presents its output as a file the array can read from. The whole thing happens in the parent shell, so the array is populated correctly (no subshell trap from L4).

Useful mapfile flags

The NUL-separated form is essential for filename-safe collection:

mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)
for f in "${FILES[@]}"; do
  echo "Processing: $f"
done

This handles every legal filename, including ones with newlines or weird characters. This is the right way to collect a list of files into an array — far better than parsing find’s output as text.

readarray is exactly the same builtin as mapfile. Bash defines them as aliases. Use whichever name you prefer; this course uses mapfile.


7. Sorting, deduplicating, and the read-into-array idioms

Bash has no built-in sort. You shell out to sort:

NAMES=(charlie alice bob alice david)

# Sort alphabetically, in place
mapfile -t SORTED < <(printf '%s\n' "${NAMES[@]}" | sort)
echo "${SORTED[@]}"          # alice alice bob charlie david

# Sort and dedupe
mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | sort -u)
echo "${UNIQUE[@]}"          # alice bob charlie david

# Numeric sort
NUMBERS=(10 2 30 4 100)
mapfile -t SORTED_NUM < <(printf '%s\n' "${NUMBERS[@]}" | sort -n)
echo "${SORTED_NUM[@]}"      # 2 4 10 30 100

# Reverse sort
mapfile -t SORTED_REV < <(printf '%s\n' "${NAMES[@]}" | sort -r)

The printf '%s\n' "${arr[@]}" idiom is worth memorising — it prints each array element on its own line, byte-exact. Combined with mapfile -t ... < <(...), it gives you a clean “transform array via Unix tool” pipeline.

Deduplication preserving original order

NAMES=(charlie alice bob alice david bob)

mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | awk '!seen[$0]++')
echo "${UNIQUE[@]}"          # charlie alice bob david

awk '!seen[$0]++' is a famous one-liner: tracks each line in seen, prints it the first time only. Preserves original order, unlike sort -u.

Membership test (linear scan)

Bash has no built-in “is this element in the array” check. The simple form:

contains() {
  local needle="$1"; shift
  local x
  for x in "$@"; do
    [[ "$x" == "$needle" ]] && return 0
  done
  return 1
}

FRUITS=(apple banana cherry)
if contains "banana" "${FRUITS[@]}"; then
  echo "found"
fi

This is O(N) — fine for arrays under a few thousand elements. For larger collections where membership tests are frequent, use an associative array as a set:

declare -A FRUIT_SET=([apple]=1 [banana]=1 [cherry]=1)

if [[ -n "${FRUIT_SET[banana]:-}" ]]; then
  echo "found"
fi

O(1) lookup. The associative array is the right choice for set semantics.


8. The file-list collection pattern

This is the canonical pattern this course wants you to know cold. It replaces every “iterate over files” hack you’ve ever seen.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Step 1: collect files into an array, NUL-safe
mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)

# Step 2: report the count before doing anything destructive
echo "Found ${#FILES[@]} log files"

# Step 3: iterate safely
for f in "${FILES[@]}"; do
  echo "Processing: $f"
  # do something with $f
done

# Step 4: bulk operations are also safe
gzip -- "${FILES[@]}"

Why this is the right shape:

  1. find -print0 + mapfile -d '' handles every legal filename (including newlines, glob chars, spaces).
  2. -type f filters out directories and weird filesystem entries before we even look at them.
  3. The intermediate array is inspectable. You can echo "${#FILES[@]}" to see how many you got, before doing anything destructive. This is the single most-important guard against accidental mass-modification.
  4. gzip -- "${FILES[@]}" invokes gzip once with all filenames as arguments, instead of one fork per file. Hugely faster for many files.

Compare to the wrong forms you may have written before:

for f in $(ls *.log); do gzip "$f"; done           # WRONG: spaces, globs, fork-per-file
for f in *.log; do gzip "$f"; done                 # OK for one directory; no recursion; doesn't handle empty match
find . -name '*.log' -exec gzip {} \;              # forks per file; slower
find . -name '*.log' | while read f; do ...; done  # subshell trap from L4

The array-pattern form gets all of these right and is the canonical advanced-shell idiom.


9. Real-world example: a service-status report

#!/usr/bin/env bash
# service-report.sh — print a sorted table of services with their statuses
set -euo pipefail
IFS=$'\n\t'

# 1. Collect service names into an indexed array
mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend --no-pager \
  | awk '{print $1}' | sort)

# 2. Build a parallel associative array of statuses
declare -A STATUS
for svc in "${SERVICES[@]}"; do
  STATUS[$svc]=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
done

# 3. Print a summary
COUNT_RUNNING=0
COUNT_FAILED=0
COUNT_OTHER=0
printf '%-50s  %s\n' "Service" "Status"
printf '%-50s  %s\n' "-------" "------"
for svc in "${SERVICES[@]}"; do
  printf '%-50s  %s\n' "$svc" "${STATUS[$svc]}"
  case "${STATUS[$svc]}" in
    active)   (( COUNT_RUNNING++ )) ;;
    failed)   (( COUNT_FAILED++ )) ;;
    *)        (( COUNT_OTHER++ )) ;;
  esac
done

# 4. Footer
echo
echo "Running: $COUNT_RUNNING"
echo "Failed:  $COUNT_FAILED"
echo "Other:   $COUNT_OTHER"

# 5. Exit non-zero if anything is failed
(( COUNT_FAILED == 0 ))

Things to notice:

This is the shape of structured shell code with arrays. It’s clean, fast, byte-safe, and readable.


10. Pitfalls and edge cases

Bash 3.2 (macOS) — no associative arrays

If your script must run on stock macOS bash, you can’t use declare -A. Workarounds:

The cleanest fix for any non-trivial script is brew install bash and shebang as #!/usr/bin/env bash (which finds /opt/homebrew/bin/bash ahead of /bin/bash). Don’t try to write portable shell that supports bash 3.2; it’s not worth the contortions.

The [*] vs [@] pitfall, again

ARR=(one "two three" four)

cmd ${ARR[@]}        # 4 args: one two three four
cmd ${ARR[*]}        # 4 args: one two three four (same — IFS-split after expansion)
cmd "${ARR[@]}"      # 3 args: one, two three, four
cmd "${ARR[*]}"      # 1 arg:  "one two three four"

Default to "${ARR[@]}". Use "${ARR[*]}" only when you specifically want a single joined string.

${ARR} is not the array

ARR=(one two three)
echo "${ARR}"        # "one" — first element only
echo "${ARR[@]}"     # "one two three" — all elements

A bare $ARR or ${ARR} is ${ARR[0]}. This is a constant source of bugs; if you mean “the array”, spell it "${ARR[@]}".

Spaces around = in declare -A

The same rule as L2 — no spaces around =. But there’s a twist for arrays:

declare -A USER=([name]=alice)            # WORKS
declare -A USER = ([name]=alice)          # WRONG: bash interprets this differently

# Inside an array assignment, no quotes around the key in [...]:
declare -A USER=([name]="Alice Smith")    # WORKS

# Quoted key works too:
declare -A USER=(["name"]="Alice Smith")  # WORKS

When in doubt, write the elements one per line for clarity:

declare -A USER=(
  [name]="Alice Smith"
  [age]=30
  [role]=engineer
)

Iterating over associative arrays gives keys, not values

declare -A CAPITALS=([USA]=Washington [UK]=London)

for c in "${CAPITALS[@]}"; do        # Washington London (values, in indeterminate order)
  echo "$c"
done

for c in "${!CAPITALS[@]}"; do       # USA UK (keys)
  echo "$c -> ${CAPITALS[$c]}"
done

Almost always you want ${!ARR[@]} (keys) and look up the value by key. Pure value iteration is rare for hashes.

Sorting is always external

There is no built-in array sort in bash. You always go through sort. The pattern is:

mapfile -t SORTED < <(printf '%s\n' "${UNSORTED[@]}" | sort [-options])

For numeric sort, sort -n. For reverse, sort -r. For sort-and-dedupe, sort -u. For preserving original order while deduping, awk '!seen[$0]++'.


11. Fourteen array idioms to memorise

# 1. Declare and populate
ARR=(one two three)

# 2. Append
ARR+=(four)
ARR+=(five six)

# 3. Length
COUNT="${#ARR[@]}"

# 4. All elements (the canonical iteration)
for x in "${ARR[@]}"; do
  echo "$x"
done

# 5. List of indices / keys
for i in "${!ARR[@]}"; do
  echo "${i}: ${ARR[$i]}"
done

# 6. Slice
FIRST_TWO=("${ARR[@]:0:2}")

# 7. Last element (bash 4.3+)
LAST="${ARR[-1]}"

# 8. Apply transformation across array
LOG_PATHS=(/var/log/a.log /var/log/b.log)
BASENAMES=("${LOG_PATHS[@]##*/}")     # a.log b.log

# 9. Read file lines into array
mapfile -t LINES < file.txt

# 10. Read NUL-separated input (filename-safe)
mapfile -d '' -t FILES < <(find /path -type f -print0)

# 11. Sort
mapfile -t SORTED < <(printf '%s\n' "${ARR[@]}" | sort)

# 12. Deduplicate, preserving order
mapfile -t UNIQUE < <(printf '%s\n' "${ARR[@]}" | awk '!seen[$0]++')

# 13. Associative array (set membership)
declare -A SET=([apple]=1 [banana]=1)
[[ -n "${SET[banana]:-}" ]] && echo "in set"

# 14. Forwarding to a command
my-command "${ARR[@]}"

Internalise these and you’ve replaced 90% of the string-hack code you’d otherwise write.


12. What you must internalise before lesson 7

If any felt fuzzy, re-read. With L1–L6 you have all of bash’s data-handling primitives. L7 takes you into I/O — file descriptors, redirection, here-docs, process substitution.


Going deeper: internals, namerefs, serialization & the traps that only bite in production

Everything above makes you productive. This section makes you dangerous — the internals and edge cases that separate someone who uses arrays from someone who can debug a colleague’s broken array code at 2 a.m.

Why a bare name is element 0 (the internal reason)

$ARR giving you ${ARR[0]} is not a special case bash bolted on — it is the rule. In bash, ARR and ARR[0] name the same storage cell; the subscript simply defaults to 0 when you omit it. There is genuinely no such thing as “the scalar value of an array” — an array is its set of subscripted cells, and a nameless reference picks the zero cell. Once you internalise “the name alone is always subscript 0,” the whole family of bugs (echo $PATH_ARRAY, [[ -n $ARR ]] to test emptiness, x=$ARR) stops surprising you. To ask “is this array empty?” you check the count, never the bare name:

(( ${#ARR[@]} == 0 )) && echo "empty"    # correct emptiness test
[[ -z "$ARR" ]]        && echo "empty"    # WRONG: only tests whether element 0 is empty

The empty-array + set -u trap (a real production landmine)

This one is subtle and version-dependent, and it has broken countless set -euo pipefail scripts. On bash before 4.4, expanding "${arr[@]}" (or "${arr[*]}") when the array is empty, under set -u (nounset), raises unbound variable and aborts the script:

set -u
EMPTY=()
for x in "${EMPTY[@]}"; do echo "$x"; done
# bash 3.2 / 4.0–4.3:  bash: EMPTY[@]: unbound variable   → script dies
# bash 4.4+:           loop body simply runs zero times   → fine

(That error is reproducible on stock macOS bash 3.2 today.) If you support older bash, guard the expansion. There are two idioms, and the difference between them matters:

# Idiom A — the "+" alternate form: expands to NOTHING when unset/empty, so zero iterations. CORRECT.
for x in ${EMPTY[@]+"${EMPTY[@]}"}; do echo "[$x]"; done

# Idiom B — the ":-" default form: for a truly EMPTY array this injects ONE empty element!
for x in "${EMPTY[@]:-}"; do echo "[$x]"; done     # prints one line: []

Idiom B is fine for “print a placeholder” but wrong for forwarding argumentscmd "${ARGS[@]:-}" passes one empty string argument to cmd when you meant to pass none, which many tools misread. For argument-forwarding on old bash, use idiom A: cmd ${ARGS[@]+"${ARGS[@]}"}. On bash 4.4+ you don’t need either — "${arr[@]}" on an empty array is simply zero words. This is the strongest single reason to standardise on bash 4.4+ and stop supporting 3.2.

Passing arrays to and from functions

Shell functions receive positional parameters — plain strings — so you cannot pass an array as a single argument by value; it flattens. There are three correct techniques, in increasing power:

1. Pass the expanded elements, rebuild inside. The array becomes the function’s "$@":

show_all() {
  local -a items=("$@")          # rebuild a local copy from positional params
  printf 'got %d items\n' "${#items[@]}"
  printf ' - %s\n' "${items[@]}"
}
ARR=(alice "bob jones" carol)
show_all "${ARR[@]}"             # quoting keeps "bob jones" as one item

Simple and portable, but it loses sparse indices and associative keys (it re-numbers from 0), and you can’t return a modified array this way.

2. Namerefs (declare -n / local -n, bash 4.3+) — operate on the caller’s array in place, by name. This is how you “return” an array:

# Fill the caller's array with the first N even numbers
make_evens() {
  local -n out="$1"             # nameref: out is an alias for whatever array the caller named
  local n="$2" i
  out=()
  for ((i=0; i<n; i++)); do out+=("$((i*2))"); done
}
declare -a RESULT
make_evens RESULT 4
echo "${RESULT[@]}"             # 0 2 4 6   (populated in the caller, no subshell, no serialization)

Namerefs also let you pass an associative array into a function without flattening — local -n map="$1" then iterate "${!map[@]}". Two caveats: namerefs are bash 4.3+ (portability flag), and you must avoid a name collision between the nameref variable and the caller’s variable name, or bash warns about a circular reference — a common gotcha is naming the nameref the same as the argument (local -n arr=arr).

3. Serialize with declare -p. declare -p ARR prints a re-executable declaration; capture it, ship it (across a ssh, into a file, through an eval) and rebuild:

ARR=(a b "c d")
snapshot="$(declare -p ARR)"    # declare -a ARR='([0]="a" [1]="b" [2]="c d")'
unset ARR
eval "$snapshot"                # rebuilt exactly, keys/sparseness preserved
echo "${#ARR[@]} ${ARR[2]}"     # 3 c d

This is the only method that faithfully preserves sparse indices and associative keys. The cost is eval: never eval a declare -p string built from untrusted input — it’s arbitrary code execution. Use it for your own snapshots, not for data that crossed a trust boundary. For plain data that must cross a boundary safely, prefer a NUL-delimited stream (printf '%s\0') read back with mapfile -d ''.

Copying an associative array does not copy its keys

A trap that produces silent data loss:

declare -A SRC=([name]=alice [role]=eng)
declare -A DST=("${SRC[@]}")     # WRONG: expands to VALUES only → DST[alice]... nonsense/loss

"${SRC[@]}" yields the values with no keys, so rebuilding an associative array from them is meaningless. To copy an associative array, loop over keys, or serialize:

declare -A DST
for k in "${!SRC[@]}"; do DST["$k"]="${SRC[$k]}"; done   # correct key-preserving copy

# or, one line via declare -p with a rename (bash 4.4+ prints the -A flag):
eval "$(declare -p SRC | sed 's/ SRC=/ DST=/')"          # careful: your own trusted data only

For indexed arrays, NEW=("${OLD[@]}") is fine (and intentionally compacts a sparse array).

The @-transformation operators (bash 4.4+)

Bash 4.4 added parameter transformations that are gold for array-heavy code — all bash 4.4+, so flag portability:

ARGS=(--flag "two words" 'a$b')
printf '%s ' "${ARGS[@]@Q}"      # '--flag' 'two words' 'a$b'   → safe to paste back

Performance and scale

Attributes: readonly, integer, and global arrays

declare flags compose with -a/-A:

declare -ra FROZEN=(prod staging dev)     # readonly array — assignment later is an error
declare -ai COUNTS=(1 2 3)                # integer array: COUNTS[0]+=5 does arithmetic
declare -gA REGISTRY                      # -g: create a GLOBAL assoc array from inside a function

declare -g matters inside functions: a plain declare -A there is function-local and vanishes on return, which surprises people building a “global registry” hash from a helper. And declare -ra (readonly) is a nice guard for lookup tables that must never be mutated — including protecting them from a later accidental unset.

IFS hygiene around [*]

Because "${arr[*]}" joins on the first character of IFS, any code that changes IFS to join then forgets to restore it will corrupt later word-splitting. Scope the change instead of mutating global IFS:

csv_of() { local IFS=,; echo "$*"; }      # local IFS restored automatically on return
csv_of "${ARR[@]}"                        # one,two,three   — global IFS untouched

A local IFS inside a function is the clean way to get a custom join without a save/restore dance.


Practice challenges

Six graded exercises, escalating from a warm-up to production-grade edge cases. Run each yourself in a bash 4+ shell first (on macOS: brew install bash and run with /opt/homebrew/bin/bash script.sh), then open the solution. The italic line tells you what the exercise is really drilling.

Challenge 1 — Build, count, last element (beginner)

Create an indexed array of three colours. Print how many elements it has, then print the last element without hard-coding the index 2.

<details> <summary>Solution</summary>

COLOURS=(red green blue)
echo "${#COLOURS[@]}"     # 3
echo "${COLOURS[-1]}"     # blue   (negative index, bash 4.3+)

On bash older than 4.3, use "${COLOURS[${#COLOURS[@]}-1]}" instead of [-1].

</details>

Why: ${#arr[@]} is the count and [-1] is the last element — the two most common “quick facts” you pull from an array.

Challenge 2 — Iterate without mangling a spaced element (beginner)

Given NAMES=(alice "bob jones" carol), print each name on its own line so the output is exactly three lines and bob jones stays intact.

<details> <summary>Solution</summary>

NAMES=(alice "bob jones" carol)
for n in "${NAMES[@]}"; do
  echo "$n"
done
# alice
# bob jones
# carol

Drop the quotes (for n in ${NAMES[@]}) and you get four lines — bob and jones split apart. The quotes are the whole exercise.

</details>

Why: "${arr[@]}" is the only correct iteration form; this is the single most important array reflex.

Challenge 3 — Load usernames from /etc/passwd into an array (intermediate)

Read the first colon-separated field (the username) of every line in /etc/passwd into an array and print the count. Do it byte-safely, not with arr=( $(...) ).

<details> <summary>Solution</summary>

mapfile -t USERS < <(cut -d: -f1 /etc/passwd)
echo "Found ${#USERS[@]} users"
printf ' - %s\n' "${USERS[@]}"

cut -d: -f1 extracts field 1; mapfile -t stores one username per element and strips newlines. mapfile is bash 4+ — on bash 3.2 the portable fallback is USERS=(); while IFS= read -r u; do USERS+=("$u"); done < <(cut -d: -f1 /etc/passwd).

</details>

Why: the file-into-array reflex, and knowing mapfile’s portability floor plus its while read fallback.

Challenge 4 — Basenames of every path, no per-element fork (intermediate)

Given PATHS=(/var/log/a.log /etc/nginx/nginx.conf /tmp/x.txt), produce a new array NAMES holding just the filenames (a.log, nginx.conf, x.txt) without calling basename (no subprocess per element).

<details> <summary>Solution</summary>

PATHS=(/var/log/a.log /etc/nginx/nginx.conf /tmp/x.txt)
NAMES=("${PATHS[@]##*/}")          # strip the longest leading */  from each element
printf '%s\n' "${NAMES[@]}"        # a.log / nginx.conf / x.txt

${PATHS[@]##*/} applies the “remove longest prefix matching */” expansion across every element in one shot — pure bash, zero forks. Wrapping it in ( … ) collects the results into a real array.

</details>

Why: whole-array parameter expansion is the fast, fork-free way to transform a collection.

Challenge 5 — Word-frequency counter with an associative array (advanced)

Read words from standard input (whitespace-separated) and print the top 5 most frequent words with their counts, highest first. Use an associative array as the counter.

<details> <summary>Solution</summary>

#!/usr/bin/env bash
set -euo pipefail
declare -A COUNT
while read -r -a words; do          # -a reads the line into an indexed array, split on IFS
  for w in "${words[@]}"; do
    (( COUNT["$w"]++ ))             # first touch creates the key at 0, then increments
  done
done
# print "count word", numeric-sort descending, take 5
for w in "${!COUNT[@]}"; do
  printf '%d\t%s\n' "${COUNT[$w]}" "$w"
done | sort -rn | head -5

Run: printf 'a b a c a b\n' | ./freq.sh3 a, 2 b, 1 c. declare -A is mandatory (bash 4+); (( COUNT["$w"]++ )) relies on an unset key arithmetic-evaluating to 0 on first touch. Sorting is external because bash has no built-in sort.

</details>

Why: the canonical “count things by key” pattern — associative array as a histogram, plus the external-sort reflex.

Challenge 6 — NUL-safe argument forwarding (advanced)

Collect every *.conf file under /etc (recursively) into an array in a way that survives spaces and newlines in filenames, print the count, then pass the whole list as one argument vector to ls -l (a single ls invocation, not one per file). Then explain in one line why "${FILES[*]}" would break it.

<details> <summary>Solution</summary>

#!/usr/bin/env bash
set -euo pipefail
mapfile -d '' -t FILES < <(find /etc -type f -name '*.conf' -print0)
echo "Found ${#FILES[@]} conf files"
(( ${#FILES[@]} )) && ls -l -- "${FILES[@]}"      # one ls, all files as separate args

find -print0 emits NUL-delimited names; mapfile -d '' -t (bash 4.4+ for -d) stores each exactly. ls -l -- "${FILES[@]}" forwards each filename as its own argument — the -- stops a name starting with - being read as an option. Why "${FILES[*]}" breaks it: [*] joins every filename into one string separated by IFS, so ls receives a single argument like /etc/a.conf /etc/b.conf and reports “No such file or directory.” Cardinality is the whole point — [@] forwards N arguments, [*] collapses to one.

</details>

Why: combines the three production reflexes — find -print0 + mapfile -d '' for filename safety, the count guard before acting, and "${arr[@]}" for correct argument cardinality.


Common beginner mistakes

These are misconceptions — wrong mental models that produce wrong code — as opposed to the symptom-oriented pitfalls in §10. Each is a belief to unlearn, followed by the model that replaces it.


Glossary


What’s next

Lesson 7 covers I/O redirection in depth: file descriptors (0, 1, 2 and beyond), the < > >> 2> &> 2>&1 operators, here-docs (<<EOF), here-strings (<<<), tee, exec for FD remapping, and process substitution as the elegant alternative to temporary files. Bring everything from L1–L6 — every redirection is a process-and-quoting decision. Continue to I/O redirection, FDs, here-docs & process substitution.

shellbasharraysassociative-arraysmapfilereadarrayslicingindexed-arraysfundamentalslinux
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments