Bash Arrays and Parameter Expansion
Two bash features remove entire categories of bug: arrays, which stop word splitting destroying your data, and parameter expansion, which does string manipulation without launching a process.
Arrays
files=("report one.pdf" "report two.pdf" "notes.txt")
echo "${files[0]}" # first element
echo "${files[@]}" # all elements, one word each
echo "${#files[@]}" # number of elements
echo "${!files[@]}" # the indices
The reason to use them:
# Wrong: splits on spaces, four "files" instead of three
files="report one.pdf report two.pdf notes.txt"
for f in $files; do echo "$f"; done
# Right
for f in "${files[@]}"; do echo "$f"; done
Always quote "${array[@]}". Unquoted, it word splits and you are back where you started.
At versus star
"${files[@]}" # one word per element <- almost always this
"${files[*]}" # one word, joined by the first IFS character
The star form is occasionally useful for building a joined string and is otherwise a bug waiting to happen.
Building arrays safely
# Append
files+=("another file.txt")
# From find, handling any filename including newlines
mapfile -d '' files < <(find . -name '*.log' -print0)
# From command output, one per line
mapfile -t lines < <(some-command)
mapfile -t strips the trailing newline from each element, which you nearly always want.
Associative arrays
declare -A config
config[host]=example.com
config[port]=8080
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
Bash 4 and later. Not available in POSIX sh or the bash 3 that older macOS shipped, so avoid them in scripts that must be portable.
Parameter expansion
String operations without leaving the shell.
Defaults
name="${1:-world}" # use default if unset or empty
: "${LOGDIR:=/var/log}" # assign the default too
: "${API_KEY:?must be set}" # exit with a message if unset
The :? form is excellent at the top of a script. It fails immediately with a clear message rather than proceeding with an empty value, which pairs well with set -u.
Removing prefixes and suffixes
path=/var/log/nginx/access.log
echo "${path##*/}" # access.log longest prefix match
echo "${path#*/}" # var/log/... shortest
echo "${path%/*}" # /var/log/nginx shortest suffix match
echo "${path%%.*}" # /var/log/nginx/access longest
One character means shortest match, two means longest. # works from the front, % from the back. The mnemonic that helps: on a keyboard # is left of %, and it operates on the left of the string.
This replaces basename and dirname in loops, where avoiding a process launch per iteration is measurable.
Substitution
s="hello world world"
echo "${s/world/there}" # replace first
echo "${s//world/there}" # replace all
echo "${s/#hello/goodbye}" # only at the start
echo "${s/%world/planet}" # only at the end
Inside a loop this is far faster than piping to sed, and it avoids quoting the pattern through another program.
Substrings and case
s="abcdefgh"
echo "${s:2}" # cdefgh
echo "${s:2:3}" # cde
echo "${s: -3}" # fgh note the space before the minus
echo "${s^^}" # ABCDEFGH
echo "${s,,}" # abcdefgh
echo "${s^}" # Abcdefgh, first character only
The space before the negative offset is required. Without it, ${s:-3} is the default-value form and means something completely different.
Length
echo "${#s}" # length of a string
echo "${#files[@]}" # number of array elements
Where this pays off
# Rename a directory of files without forking per file
for f in *.jpeg; do
mv -- "$f" "${f%.jpeg}.jpg"
done
# Build command arguments conditionally, safely
args=(--verbose)
[[ -n "$CONFIG" ]] && args+=(--config "$CONFIG")
[[ "$DRY_RUN" == 1 ]] && args+=(--dry-run)
mytool "${args[@]}"
That second pattern is the correct way to build a variable argument list. Constructing a string and expanding it unquoted is the wrong way, and it breaks the moment a value contains a space.
Frequently Asked Questions
Why use an array instead of a space-separated string?
Because a string is split on whitespace when expanded, so any element containing a space becomes two elements. An array preserves element boundaries exactly, which is the only reliable way to handle filenames or arguments that may contain spaces.
What is the difference between the at and star forms in quotes?
Quoted at-sign expands to one word per element, which is what you want. Quoted star expands the whole array to a single word joined by the first character of IFS. Use the at-sign form in essentially every case.
What does the hash prefix do in parameter expansion?
A single hash gives the length of a variable or the number of elements in an array. Two hashes in the middle of an expansion strip the longest matching prefix pattern, which is unrelated despite using the same character.
How do I give a variable a default value?
Use the colon-dash form, which substitutes the default when the variable is unset or empty without changing it. Colon-equals assigns the default as well, which is what you want when later code reads the variable again.
Are associative arrays available in bash?
Yes, from bash 4 onward, declared with declare -A. They are not available in POSIX sh or in the bash 3 that older macOS shipped, so scripts that must be portable cannot use them.
Why is parameter expansion better than calling sed?
It runs inside the shell with no process launch, which is dramatically faster inside a loop. It also avoids quoting the pattern through another program, which removes a common source of subtle bugs.