Arrays & parameter expansion

Lists, defaults, and string surgery without extra tools.

Beginner18 min · lesson 8 of 14

A plain variable in Bash holds one thing. One log path, one server name, one flag. But real ops work comes in lists: three log files to scan, five services to restart, a dozen arguments to hand to a command. Cramming a list into a single string and splitting it back apart later is where a lot of Bash scripts quietly break, and where a filename with a space in it can turn one safe argument into two.

An array is the fix. A coat-check rack at a theater is the everyday version: every coat gets its own numbered hook and its own ticket, so the attendant can hand back exactly the one you want without disturbing the rest. An array (a variable that holds an ordered list of separate values, each sitting in its own numbered slot) does that for your data. This lesson builds arrays, reads them back safely, then uses parameter expansion (Bash's built-in way to cut and reshape the text inside a variable) to do real string work without ever calling sed or awk, the two separate text-processing programs scripts usually shell out to.

Making an array and reading it back

You create an array with parentheses. Each word separated by a space becomes its own element. You read one element by its index (its slot number), and Bash counts from zero, so the first element is number 0, not 1.

~/secopslog — bash
$ arr=(one two three) echo "${arr[0]}" echo "${arr[2]}"
one three

Two special expansions do most of the work. "${arr[@]}" gives you every element as a separate value, and ${#arr[@]} gives you the count. The # here means "how many," the same way it does for the length of a plain string. Keep the double quotes on the [@] form. Without them, Bash re-splits every element on spaces, which is exactly the bug arrays exist to prevent.

~/secopslog — bash
$ arr=(one two three) echo "${arr[@]}" echo "${#arr[@]}"
one two three 3

Adding to arrays and looping

You rarely build the whole list at once. More often you start with something and add to it as the script runs. The += operator appends to the end, and you can add several elements in one go. Then you walk the list with a for loop over "${arr[@]}", handling one element per pass.

script.sh
files=(app.log)
files+=(error.log access.log)
for f in "${files[@]}"; do
echo "checking: $f"
done
output
checking: app.log
checking: error.log
checking: access.log

That loop is the shape of a hundred ops scripts: collect a list of things, then do one action to each. Because each element stays whole, a log file named report 2024.log stays a single item instead of splitting into report and 2024.log.

Associative arrays: lists with named slots

A regular array uses numbers for slots. An associative array (a list where each slot has a name you choose instead of a number, sometimes called a map or a dictionary) lets you look things up by a meaningful key. It works like a phone book: you don't ask for entry number 47, you ask for the entry named 'db'. You have to declare it first with declare -A (the -A stands for associative), or Bash treats the keys as plain numbers and quietly throws your data away.

~/secopslog — bash
$ declare -A owner owner[web]=alice owner[db]=bob echo "${owner[db]}" echo "${!owner[@]}"
bob db web

You read a value by its key, ${owner[db]}. The ${!owner[@]} form (note the exclamation mark) hands you the keys instead of the values, which is how you loop over every entry. One catch: associative arrays don't keep insertion order, so the keys can come back in any order. Never write a script that depends on which one prints first.

Parameter expansion: string surgery without sed or awk

Now the second half. Every ${variable} you write is really parameter expansion, and Bash packs a small toolkit of edits into that same syntax. These run inside the shell with no extra program launched, so they beat piping through sed or awk on speed, and there's no separate mini-language to get right. Start with the safety net: ${var:-default} means "use var, but if it's unset or empty, use this default instead." It reads the value; it does not change it.

~/secopslog — bash
$ echo "Deploying to ${TARGET:-staging}" TARGET=prod echo "Deploying to ${TARGET:-staging}"
Deploying to staging Deploying to prod

That one line means a missing environment variable sends your deploy to staging instead of to an empty string. In a path like rm -rf "/data/$TARGET", an empty value is the difference between deleting one folder and deleting everything under /data. A sane default is a safety control, not a convenience.

Next, trimming the ends. ${var#prefix} bites a matching piece off the front; ${var%suffix} bites it off the back. On a US keyboard the # sits on the left and the % sits on the right, so line them up with front-of-string and back-of-string and you won't mix them up.

~/secopslog — bash
$ name="access.log" echo "${name%.log}" echo "${name#access.}"
access log

Doubling the symbol makes the match greedy, meaning it eats as much as it can. ${var#*/} strips up to the first slash; ${var##*/} strips up to the last one, which leaves the bare filename. ${var%/*} does the mirror image and leaves the directory. That's how you split a path with zero external commands.

~/secopslog — bash
$ path="/var/log/nginx/access.log" echo "${path##*/}" echo "${path%/*}" file="${path##*/}" echo "${file%.*}" echo "${file##*.}"
access.log /var/log/nginx access log

Substitution is ${var/old/new} for the first match, or ${var//old/new} for every match. One handy use is redacting a secret before a connection string lands in a log file, where the plain text would otherwise sit for anyone with read access to grep out.

~/secopslog — bash
$ conn="host=db;port=5432;pass=secret" echo "${conn/pass=secret/pass=****}"
host=db;port=5432;pass=****

Three more you'll reach for constantly: ${var:0:7} takes a substring starting at position 0, seven characters long (perfect for shortening a Git commit hash, the long fingerprint Git stamps on every change); ${#var} is the length; and ${var,,} lowercases while ${var^^} uppercases, which is how you compare what a user typed without caring about their capitalization.

~/secopslog — bash
$ sha="a1b2c3d4e5f6" echo "${sha:0:7}" echo "${#sha}" env="Prod" echo "${env,,}" echo "${env^^}"
a1b2c3d 12 prod PROD

Putting it together: build a safe args array

Here's where both halves meet. You want to run a command with one --service flag per target. Building that as a single string and letting the shell split it is the classic foot-gun. Command injection is what you're guarding against: untrusted text smuggling in extra commands or arguments you never intended, so a target with a space or a semicolon in it can quietly add arguments of its own. An array keeps every piece exactly as you set it, because "${args[@]}" hands each element to the command as one separate argument, safe from word splitting (the shell chopping a value apart on spaces) and glob expansion (the shell turning a * into a list of matching filenames).

script.sh
targets=(web db cache)
args=()
for t in "${targets[@]}"; do
args+=(--service "$t")
done
printf '%s\n' "${args[@]}"
output
--service
web
--service
db
--service
cache

Each flag and each value is its own element, ready to become deploy "${args[@]}" with a guarantee that no value splits or expands into something you didn't put there. That's the whole reason to prefer an array over a space-joined string any time untrusted or user-supplied data is in the mix.

Unquoted array expansion re-splits your data
"${arr[@]}" (with quotes) keeps every element whole. ${arr[@]} (without quotes) re-splits each element on spaces and expands any * as a filename wildcard. A file named 'system report.txt' becomes two words, 'system' and 'report.txt', and a value of '*' turns into a list of every file in the directory. That's a real path to command injection and data loss, not a style nit. Quote array expansions every single time, and quote "$var" the same way.
~/secopslog — bash
$ docs=("system report.txt" "notes.txt") for f in ${docs[@]}; do echo " [$f]"; done
[system] [report.txt] [notes.txt]

Three items came out of a two-item list. Put the quotes back on "${docs[@]}" and you get the two you expected. When the number of loop iterations depends on the contents of your data, whoever controls that data controls your loop.

Quick check
01You have paths=("/tmp/a b.txt" "/tmp/c.txt"). Which loop header processes exactly two files, each kept whole?
Incorrect — Unquoted, so 'a b.txt' splits on its space into two words. You get three iterations, not two.
Correct — The quotes around ${paths[@]} keep each element intact, so the loop runs exactly twice with the full path each time.
Incorrect — The [*] form joins all elements into one single string, so the loop runs only once with both paths mashed together.
Incorrect — Bare ${paths} expands only element 0, and unquoted it then splits on the space into /tmp/a and b.txt. You lose the second file entirely and get the wrong pieces of the first.
02Given `path="/var/log/nginx/access.log"`, which parameter expansion yields exactly `access.log`, the bare filename?
Incorrect — a single `#` is non-greedy and strips only up to the first slash, leaving `var/log/nginx/access.log`.
Incorrect — `%` trims from the back at the last slash, which leaves the directory `/var/log/nginx`.
Correct — doubling the `#` makes the match greedy, stripping everything up to and including the last slash and leaving `access.log`.
Incorrect — `%%` greedily trims from the back starting at the first slash, leaving an empty string.
03A script sets three named entries -- `counts[web]=5`, `counts[db]=9`, `counts[cache]=2` -- but the author forgot to run `declare -A counts` first. What is the result?
Incorrect — Bash does not error here; it silently does the wrong thing, which is what makes the bug dangerous.
Correct — a plain indexed array treats a non-numeric subscript as an arithmetic expression that resolves to 0, so all three writes collide on slot 0.
Incorrect — without `declare -A` you get an ordinary indexed array, not real named keys.
Incorrect — unordered keys are a genuine associative-array trait, but here no associative array was ever created.
What ${...} expansion should I reach for?
You have a value in a variable and need to reshape it
start
It might be empty or unset
${var:-default} supplies a fallback so a missing value can't become an empty string in a path or command
safe
Strip the front or back
${var#prefix} / ${var%suffix} trim one end; double them (## / %%) for a greedy match, e.g. ${path##*/} for the filename
ok
Swap text inside
${var/old/new} replaces the first match, ${var//old/new} replaces all, handy for redacting secrets before they hit a log
ok
Slice, measure, or recase
${var:0:7} substring, ${#var} length, ${var,,} / ${var^^} lower/upper for case-insensitive checks
ok
Match the job to the built-in expansion. All of these run inside the shell with no sed, awk, or extra program launched.

Related