Arrays & parameter expansion
Lists, defaults, and string surgery without extra tools.
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.
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.
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.
files=(app.log)files+=(error.log access.log)for f in "${files[@]}"; doecho "checking: $f"done
checking: app.logchecking: error.logchecking: 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.
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.
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.
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.
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.
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.
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).
targets=(web db cache)args=()for t in "${targets[@]}"; doargs+=(--service "$t")doneprintf '%s\n' "${args[@]}"
--serviceweb--servicedb--servicecache
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.
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.