Advanced Bash scripting: arrays, expansions, traps and getopts
Once you can write a basic script, a handful of Bash features make scripts shorter and much more robust: arrays for lists of file names, parameter expansion instead of calls to sed and basename, traps for cleanup, and getopts for real command-line options. Every example here runs in Bash 5.2 and 5.3.
This page assumes you know the material in the Bash scripting introduction. As of November 2026 the current Bash release is 5.3 (July 2025); Debian 13 and Ubuntu 24.04 ship 5.2, Ubuntu 26.04 ships 5.3. Check yours with echo "$BASH_VERSION". The classic Advanced Bash-Scripting Guide, which this address used to point to, is kept by the LDP at tldp.org.
Indexed arrays
An array holds a list of strings, and each element can contain spaces safely. That makes arrays the right tool for lists of file names and for building command lines.
fruits=(apple banana "passion fruit")
fruits+=(cherry) # append
echo "${#fruits[@]}" # 4 elements
echo "${fruits[2]}" # passion fruit (indexes start at 0)
echo "${fruits[-1]}" # cherry (negative counts from the end)
for f in "${fruits[@]}"; do # always quote "${array[@]}"
printf '[%s]\n' "$f"
done
printf '%s\n' "${fruits[@]:1:2}" # slice: banana, passion fruit
unset 'fruits[1]' # remove banana
echo "${!fruits[@]}" # remaining indexes: 0 2 3
"${fruits[@]}" gives one word per element; "${fruits[*]}" joins them into one string separated by the first character of IFS (a space by default). After unset, the indexes have a gap; loop over "${!fruits[@]}" when you need the index.
Filling arrays safely
# One element per line of a file
mapfile -t lines < /etc/shells
echo "Read ${#lines[@]} lines; first: ${lines[0]}"
# One element per file name, even names with spaces or newlines
mapfile -t -d '' confs < <(find /etc -maxdepth 1 -name '*.conf' -print0)
printf '%s\n' "${confs[@]}"
# Build a command line step by step
args=(-a --info=progress2)
[[ -n ${DRY_RUN-} ]] && args+=(--dry-run)
rsync "${args[@]}" ~/Documents/ /mnt/backup/Documents/
mapfile (also called readarray) reads lines into an array; -t removes the trailing newline and -d '' splits on the null byte that find -print0 writes. Avoid files=($(ls)) or files=($(find …)): the output is split on spaces and expanded as patterns.
Associative arrays
Associative arrays (Bash 4.0 and later) map string keys to values. Declare them with declare -A, or Bash treats the variable as an indexed array.
declare -A port=([http]=80 [https]=443 [ssh]=22)
port[dns]=53
echo "${port[https]}" # 443
[[ -v port[ssh] ]] && echo "ssh is known"
unset 'port[http]'
echo "${#port[@]} entries" # 3
for name in "${!port[@]}"; do # keys come out in no particular order
printf '%-6s %s\n' "$name" "${port[$name]}"
done
A typical use is counting. This counts the login shells in /etc/passwd:
declare -A shells=()
while IFS=: read -r _ _ _ _ _ _ shell; do
[[ -n $shell ]] || continue
shells[$shell]=$(( ${shells[$shell]:-0} + 1 ))
done < /etc/passwd
for s in "${!shells[@]}"; do
printf '%5d %s\n' "${shells[$s]}" "$s"
done | sort -rn
On a typical server the output starts with something like 35 /usr/sbin/nologin followed by 8 /bin/bash; your numbers will differ. Sort the output yourself, as above, because Bash does not keep keys in order.
Parameter expansion
Parameter expansion edits a variable's value without starting another program. Taking path=/var/log/nginx/access.log.1:
| Expression | Result | Meaning |
|---|---|---|
${path##*/} | access.log.1 | remove the longest match of */ from the start (like basename) |
${path%/*} | /var/log/nginx | remove the shortest match of /* from the end (like dirname) |
${path%.*} | /var/log/nginx/access.log | drop the last extension |
${path%%.*} | /var/log/nginx/access | drop all extensions |
${path#*.} | log.1 | everything after the first dot |
${path/log/LOG} | /var/LOG/nginx/access.log.1 | replace the first match |
${path//log/LOG} | /var/LOG/nginx/access.LOG.1 | replace every match |
${#path} | 27 | length in characters |
${path:5:3} | log | substring: 3 characters from offset 5 |
${path: -5} | log.1 | the last 5 characters (note the space) |
Defaults and checks, for var unset or empty:
${var:-default}usesdefaultbut leavesvaralone;${var-default}does so only ifvaris unset.: "${PORT:=8080}"assigns 8080 toPORTif it is unset or empty. The:command does nothing except let the expansion happen.: "${API_KEY:?must be set}"stops a script with the messageAPI_KEY: must be set.${var:+text}givestextonly ifvaris set and not empty, which is handy for optional flags.
Case and quoting: with name=ada, ${name^} is Ada and ${name^^} is ADA (, and ,, lowercase). ${var@Q} prints the value quoted so it can be pasted back into a shell: s="it's here" gives 'it'\''s here', which is useful in log messages.
& in replacements changed in Bash 5.2. The patsub_replacement option, on by default since 5.2, makes an unquoted & in the replacement stand for the matched text. With title="Tom and Jerry", ${title/and/&} now gives Tom and Jerry, not Tom & Jerry. Write ${title/and/\&} or quote it, ${title/and/"&"}, to get a literal ampersand in every version.
Process substitution
<(command) runs a command and gives its output a file name (such as /dev/fd/63), so you can pass it to programs that only read files:
diff <(sort old.txt) <(sort new.txt)
comm -13 <(sort -u allowed.txt) <(sort -u seen.txt) # lines only in seen.txt
It also solves a classic puzzle. Each part of a pipeline runs in a subshell, so variables set in a while loop at the end of a pipe are lost:
count=0
printf '%s\n' a b c | while read -r _; do count=$((count + 1)); done
echo "$count" # 0: the loop ran in a subshell
count=0
while read -r _; do count=$((count + 1)); done < <(printf '%s\n' a b c)
echo "$count" # 3: the loop ran in the current shell
In scripts you can also use shopt -s lastpipe, which runs the last part of a pipeline in the current shell when job control is off (as it is in scripts).
Here-documents and here-strings
cat <<EOF
User: $USER
Home: $HOME
Bash: $BASH_VERSION
EOF
cat <<'EOF'
Quoting the word turns expansion off: $HOME $(date)
EOF
# Write a root-owned file without a root shell
sudo tee /etc/sysctl.d/90-swappiness.conf > /dev/null <<'EOF'
vm.swappiness = 10
EOF
read -r first rest <<< "one two three"
echo "$first / $rest" # one / two three
A here-document feeds lines to a command's standard input up to the closing word, which must be alone on its line. With <<-EOF, leading tab characters (not spaces) are removed, so you can indent the block inside a function. A here-string, <<<, feeds one string plus a newline. sudo echo … > /etc/file does not work because your own shell, not sudo, opens the file; sudo tee is the usual fix.
Traps: cleanup and error reports
trap runs a command when the script exits or receives a signal. The EXIT trap is the reliable place for cleanup:
#!/usr/bin/env bash
set -euo pipefail
workdir=$(mktemp -d)
cleanup() {
rm -rf -- "$workdir"
}
trap cleanup EXIT
trap 'echo "Interrupted" >&2; exit 130' INT TERM
sort -u /etc/passwd > "$workdir/sorted"
wc -l < "$workdir/sorted"
The temporary directory is removed whether the script ends normally, fails under set -e, or is stopped with Ctrl+C; the exit 130 in the INT trap still runs the EXIT trap. 130 is the conventional status for "killed by SIGINT" (128 + signal 2).
An ERR trap reports where a failure happened. Add -E (errtrace) so the trap also fires inside functions:
#!/usr/bin/env bash
set -eEuo pipefail
trap 'echo "${BASH_SOURCE[0]}: line $LINENO: \"$BASH_COMMAND\" failed with status $?" >&2' ERR
copy_config() {
cp /etc/hostname /nonexistent/dir/
}
copy_config
echo "not reached"
Saved as err.sh and run with bash err.sh, this prints cp's own error and then err.sh: line 6: "cp /etc/hostname /nonexistent/dir/" failed with status 1 (run as ./err.sh, the file name shows as ./err.sh). Bash 5.3 adds BASH_TRAPSIG, which holds the number of the signal being handled.
Options with getopts
getopts is built into Bash and handles short options, bundled flags (-vn2) and option values. A leading : in the option string lets you print your own error messages.
#!/usr/bin/env bash
# greet.sh - demo of option parsing with getopts
usage() {
cat <<EOF
Usage: ${0##*/} [-v] [-n COUNT] [-o FILE] NAME...
-v verbose messages on standard error
-n COUNT greet each name COUNT times (default 1)
-o FILE write to FILE instead of standard output
-h show this help
EOF
}
verbose=0
count=1
outfile=
while getopts ':vn:o:h' opt; do
case $opt in
v) verbose=1 ;;
n) count=$OPTARG ;;
o) outfile=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Option -$OPTARG needs a value" >&2; usage >&2; exit 2 ;;
\?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
(( $# > 0 )) || { usage >&2; exit 2; }
[[ $count =~ ^[1-9][0-9]*$ ]] || { echo "COUNT must be a positive number" >&2; exit 2; }
if [[ -n $outfile ]]; then
exec > "$outfile" || exit 1 # send all further output to the file
fi
if (( verbose )); then
echo "Greeting $# name(s) $count time(s)" >&2
fi
for (( i = 0; i < count; i++ )); do
for name in "$@"; do
echo "Hello, $name"
done
done
Try ./greet.sh -v -n 2 Ada "Grace Hopper", ./greet.sh -x Ada (unknown option, status 2) and ./greet.sh -- -n (the -- ends options, so -n is a name). getopts does not parse long options such as --count; if you need them, use the getopt program from util-linux or write a while/case loop over "$@".
Returning values from functions
Capturing output with $(…) starts a subshell. Two builtins avoid that:
printf -v stamp '%(%Y-%m-%d %H:%M)T' -1 # current time into $stamp, no date process
echo "$stamp"
split_path() {
local -n _out=$1 # nameref: _out is another name for the caller's variable
IFS=: read -r -a _out <<< "$2"
}
split_path dirs "$PATH"
echo "PATH has ${#dirs[@]} directories; the first is ${dirs[0]}"
Give nameref variables unusual names (like _out) so they cannot clash with the caller's variable name. Bash 5.3 also adds ${ command; }, a command substitution that runs in the current shell without a subshell; use it only in scripts that will never run on 5.2.
Debugging
bash -n script.shchecks the syntax without running anything.bash -x script.shprints each command, after expansion, before running it. Inside a script, wrap just the part you care about inset -x…set +x.- Set
PS4to show file, line and function in each trace line. - Send the trace to a file with
BASH_XTRACEFDso it does not mix with the script's own error output. - Run ShellCheck on every script; it catches most quoting and logic slips before they run.
#!/usr/bin/env bash
PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
exec {trace_fd}> trace.log # open a new file descriptor on trace.log
BASH_XTRACEFD=$trace_fd
greet() {
local who=$1
echo "Hello, $who"
}
set -x
greet "$USER"
set +x
echo "tracing is off again"
PS4 must be in single quotes so it is expanded at every trace line, not once. Saved as debug.sh and run, trace.log contains lines like + debug.sh:11:main: greet ada and + debug.sh:8:greet: echo 'Hello, ada'.
Mistakes to avoid
- Forgetting
declare -A:m[key]=1on an undeclared variable creates an indexed array; the key is read as arithmetic, so most string keys land on index 0. - Unquoted
${array[@]}: elements with spaces split apart (ShellCheck SC2068). - Trusting key order in associative arrays; sort explicitly.
- Using
evalto build variable names. Arrays, associative arrays and namerefs cover nearly every case without the injection risk. - Writing for one Bash version and running on another. If a script needs 5.x, check at the top:
(( BASH_VERSINFO[0] >= 5 )) || { echo "Needs Bash 5" >&2; exit 1; }.
For a structured way through all of this, see the Bash scripting guide. To make your interactive shell show Git branches and exit codes, read the Bash prompt HOWTO.
Sources
- Bash Reference Manual, edition 5.3, GNU Project
- Shell Parameter Expansion, Bash Reference Manual
- Bash Variables (BASH_XTRACEFD, BASH_REMATCH, BASH_TRAPSIG), Bash Reference Manual
- bash(1) manual page (GNU Bash 5.3), man7.org
- Bash NEWS file (features added in each release), Chet Ramey, Case Western Reserve University
- GNU Bash release downloads, ftp.gnu.org
- getopt(1) manual page (util-linux), man7.org
- ShellCheck wiki