Advanced Bash scripting: arrays, expansions, traps and getopts

Updated November 2026

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:

Parameter expansion with path=/var/log/nginx/access.log.1
ExpressionResultMeaning
${path##*/}access.log.1remove the longest match of */ from the start (like basename)
${path%/*}/var/log/nginxremove the shortest match of /* from the end (like dirname)
${path%.*}/var/log/nginx/access.logdrop the last extension
${path%%.*}/var/log/nginx/accessdrop all extensions
${path#*.}log.1everything after the first dot
${path/log/LOG}/var/LOG/nginx/access.log.1replace the first match
${path//log/LOG}/var/LOG/nginx/access.LOG.1replace every match
${#path}27length in characters
${path:5:3}logsubstring: 3 characters from offset 5
${path: -5}log.1the last 5 characters (note the space)

Defaults and checks, for var unset or empty:

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

#!/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

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