Bash Arrays and Parameter Expansion

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.