Advanced Bash: Arrays, Expansion & Substitution
- Hold a list in an indexed array — build it, count it, index the ends, and iterate it safely
- Reshape strings with parameter expansion instead of forking cut/sed/basename
- Strip prefixes/suffixes, substitute, default, and change case with ${...} operators
- Capture a command's output with command substitution, and feed output as a file with process substitution
- Recognise the quoting rule that keeps arrays and expansions from silently word-splitting
Beyond one string: arrays hold a list
See why a real list beats re-splitting a string every time
A plain variable holds one value. The moment you need several — a set of hostnames, the files to back up, a queue of ports — people reach for a space-separated string and re-split it on every use. That works until a value contains a space, and then it shatters silently.
An indexed array is Bash's real list type: an ordered, numbered collection you build once and then count, index, slice, and iterate — without ever re-parsing. It's the difference between keeping a shopping list on a card versus re-reading a run-on sentence and guessing where each item ends.
Check yourself
What breaks a space-separated string used as a pretend list?
Indexed arrays: build, count, index, iterate
Create an array and pull the pieces out of it
Declare an array with parentheses; elements are separated by whitespace. Then four operators do almost everything:
${arr[i]}reads one element,${#arr[@]}counts them,${arr[@]}expands to all of them, and a negative index counts from the end. Always iterate `"${arr[@]}"` in double quotes — that expands to one word per element, spaces and all.hosts=(web1 db1 "cache node" gw) echo "count: ${#hosts[@]}" echo "first: ${hosts[0]} last: ${hosts[-1]}" for h in "${hosts[@]}"; do echo "- $h"; donecount: 4 first: web1 last: gw - web1 - db1 - cache node - gw
- ${#arr[@]}
- number of elements in the array
- ${arr[-1]}
- last element (negative indices count from the end)
- "${arr[@]}"
- all elements, each as its own word — the quotes are mandatory
Four elements — note 'cache node' is ONE element because it was quoted at build time, and it stays one line in the loop because we iterated "${hosts[@]}" quoted. ${#hosts[@]} is the length; hosts[-1] is the last element without knowing the count. mapfile -t lines < /etc/hostname echo "read ${#lines[@]} line(s); first is '${lines[0]}'"read 1 line(s); first is 'web01'
- mapfile -t
- read stdin/a file into an array, one line per element, no trailing newlines
mapfile -t (a.k.a. readarray) loads a file into an array, one line per element, stripping the trailing newline (-t). This is the correct way to read a file into a list — never 'for line in $(cat file)', which word-splits. Check yourself
How do you get the number of elements in an array named files?
Associative arrays: key → value maps
Use declare -A for lookups, counters, and sets keyed by strings, not numbers
An indexed array is numbered 0, 1, 2… An associative array is keyed by arbitrary strings — a map / dictionary, and the tool you reach for the moment you think "I need to look something up by name." Three classic uses:
- Lookup tables —
config[timeout]=30, then read${config[timeout]}. - Counters — tally occurrences:
count[$word]=$(( ${count[$word]:-0} + 1 )). - Sets / membership — record what you've seen and test it later.
The operators mirror indexed arrays with one crucial addition: `${!map[@]}` gives the keys (note the
!),${map[@]}the values, and${#map[@]}the count. The catch that bites everyone: you must `declare -A map` first. Without it, Bash treats the subscript as arithmetic and quietly collapses everything into index 0 — a map that appears to hold only its last value.declare -A seen for w in apple pear apple fig apple pear; do seen[$w]=$(( ${seen[$w]:-0} + 1 )) done echo "apple: ${seen[apple]} pear: ${seen[pear]} fig: ${seen[fig]}" echo "distinct words: ${#seen[@]}"apple: 3 pear: 2 fig: 1 distinct words: 3
- declare -A
- declare an associative (string-keyed) array — required before first use
- ${!map[@]}
- the KEYS of the map (with !); ${map[@]} gives the values
- ${seen[$w]:-0}
- value for key $w, or 0 if the key isn't set yet
declare -A makes seen a real map; each key counts its word (${seen[$w]:-0} defaults a missing key to 0 before incrementing). Reading by key gives the tally, and ${#seen[@]} is the number of distinct keys — a word-frequency counter in five lines, no sort|uniq -c pipeline. Check yourself
For an associative array named m, what expands to its list of KEYS (not values)?
- Lookup tables —
Parameter expansion: string surgery without subprocesses
Reshape a value using ${...} instead of forking cut/sed/basename
Every
basename,dirname,cut, orsedin a loop forks a process — thousands of times over a big list, that's real wall-clock. Bash can slice strings itself with parameter expansion, no subprocess at all. The operators read oddly at first, but there are only a handful and they pattern-match:${v#pat}/${v##pat}— strip a prefix (shortest / longest match)${v%pat}/${v%%pat}— strip a suffix (shortest / longest)${v/find/repl}/${v//find/repl}— replace first / all${v:-default}— usedefaultifvis unset or empty${#v}— length;${v^^}/${v,,}— upper / lower case
p=/var/log/nginx/access.log echo "dir: ${p%/*}" base=${p##*/} echo "base: $base" echo "stem: ${base%.*} ext: ${base##*.}" echo "upper: ${base^^}"dir: /var/log/nginx base: access.log stem: access ext: log upper: ACCESS.LOG
- ${p%/*}
- strip the shortest /... from the end → the directory
- ${p##*/}
- strip the longest */ from the front → the filename
- ${base%.*}
- strip the extension → the stem
- ${base##*.}
- keep only the extension
A full path split into directory, filename, stem, and extension — plus an uppercase — using nothing but ${...}. %/ removes the shortest /... suffix (the directory); ##/ removes the longest */ prefix (the filename). No basename, no dirname, no fork. name=${1:-anonymous} echo "hello, ${name}" csv="a,b,,c" echo "cleaned: ${csv//,,/,}"hello, anonymous cleaned: a,b,c
- ${1:-anonymous}
- value of $1, or 'anonymous' if unset/empty
- ${v//find/repl}
- replace ALL occurrences (single slash = first only)
${1:-anonymous} supplies a default when the first argument is missing — the one-line way to make a script forgiving. ${csv//,,/,} replaces every empty field (double comma) globally. Two doubled operators, two different jobs. :-is one of a small default family, and each member does a distinct job:- `${v:-default}` — use
defaultifvis unset/empty (v is unchanged). - `${v:=default}` — use and assign
defaultintovif it was unset/empty. - `${v:+alt}` — use
altonly ifvis set (the inverse — handy for "add a flag only when the var exists"). - `${v:?message}` — error out with
messageifvis unset/empty. One line of required-argument validation at the top of a script:: "${API_KEY:?must be set}".
And for a substring,
${v:offset:length}slices by position:${v:0:3}is the first three characters. The same:offset:lengthslices an array —"${arr[@]:1:2}"is two elements starting at index 1.Check yourself
Given f=report.tar.gz, what does ${f%%.} produce, and why does it differ from ${f%.}?
Command & process substitution
Turn a command's output into a value, or into a file
Command substitution
$(cmd)runs a command and drops its stdout in place — the workhorse for capturing a value into a variable. Its lesser-known sibling, process substitution<(cmd), is the trick that unlocks tools which insist on a filename: it hands the command's output to that tool as if it were a file, no temp file to create or clean up.Example — not runnable on your lab box
now=$(date +%F) echo "stamp: $now" diff <(sort a.txt) <(sort b.txt)stamp: 2026-07-16 3c3 < bravo --- > delta
- $(cmd)
- run cmd, substitute its stdout as text
- <(cmd)
- run cmd, present its stdout as a readable file path
$(date +%F) captures today's date into a variable. Then the real magic: diff needs two files, but we compare the SORTED versions of two files without writing any temp files — <(sort a.txt) presents sort's output to diff as a filename. That /dev/fd path is created and removed for you. Check yourself
A tool takes only a filename argument, but you want to feed it the output of
grep ERROR log. What syntax bridges the gap?Now do it — in your live terminal
Build an array, slice its elements with expansion, and capture output
Muscle memory beats memorisation. Build a small array of paths, then reshape each element with parameter expansion and capture a summary — the exact moves the arrays and parameter-expansion labs will grade.
paths=(/etc/hosts /var/log/syslog /srv/app/config.yaml) for p in "${paths[@]}"; do echo "${p##*/} (in ${p%/*})" done- "${paths[@]}"
- each element as one word — the quotes prevent splitting
Iterate the quoted array and, for each path, print the filename (##/) and its directory (%/). Run it, then add a fourth path WITH a space in it and confirm the quoted loop keeps it intact. mapfile -t users < <(cut -d: -f1 /etc/passwd) echo "system has ${#users[@]} accounts; first: ${users[0]}"- < <(cut ...)
- process substitution as mapfile's input — no temp file
Combine all three ideas: process substitution feeds cut's output into mapfile, which builds an array of usernames; ${#users[@]} counts them. Now try ${users[-1]} for the last account. This one line replaces a fragile 'for u in $(...)' loop.
- Indexed arrays are Bash's list type: arr=(a b c); ${#arr[@]} counts, ${arr[0]}/${arr[-1]} index the ends, and you must iterate "${arr[@]}" quoted so spaces don't split elements.
- mapfile -t arr < file (or < <(cmd)) is the correct way to read lines into a list — never 'for x in $(cat file)'.
- Associative arrays (declare -A) are string-keyed maps for lookups, counters, and sets: set map[key]=val, read ${map[key]}, get keys with ${!map[@]}, count with ${#map[@]} — and declare -A FIRST or keys collapse to index 0.
- Parameter expansion reshapes strings with no subprocess: # strips prefixes, % strips suffixes (double for greedy), / substitutes, and the default family :- (use) / := (use+assign) / :+ (alt if set) / :? (error), plus ${v:off:len} slices; ^^ / ,, change case.
- $(cmd) captures output as text; <(cmd) presents output as a file so filename-only tools (diff, comm) can consume a stream.
- The recurring bug across all of these is word-splitting — quote your expansions and prefer arrays over space-joined strings.
| Concept | Syntax |
|---|---|
| Build / index / length | arr=(a b c) · ${arr[i]} · ${#arr[@]} |
| Iterate safely | for x in "${arr[@]}"; do … |
| Read file into array | mapfile -t arr < file |
| Strip prefix / suffix | ${v#pat} / ${v%pat} (double = greedy) |
| Substitute / default / case | ${v//a/b} · ${v:-def} · ${v^^} |
| Capture output / as a file | $(cmd) · <(cmd) |
