Robust Scripts · about 40 min

Error handling patterns

Why this matters

Strict mode stops a script when something goes wrong. Good error handling goes further: it checks the things that are likely to go wrong before acting, tells the person exactly what happened in one clear line, and returns a status that other programs can act on. The difference shows up the first time someone else (or you, in six months) runs your script with a typo in an argument. Compare:

head: invalid number of lines: 'ten'

with

backup.sh: KEEP must be a positive integer: ten

The first is an internal detail leaking out of line 47; the second tells you which tool, which input and what is expected. This lesson collects the patterns that produce the second kind.

Pattern 1: messages to stderr, named after the script

Every process starts with three open file descriptors: 0 (stdin), 1 (stdout) and 2 (stderr), all usually connected to the terminal (module 5). Data goes to 1, messages for humans go to 2:

echo "backup.sh: cannot read config: $config" >&2

>&2 makes this one echo write to file descriptor 2. Because the streams are separate, `backup.sh

list.txt,backup.sh | sortandfiles=$(backup.sh)` all keep the messages out of the data

and still show them to the person at the terminal.

Start messages with the script name, the way ls: cannot access ... does. Hard-coding it is fine; ${0##*/} gives the name the script was invoked with, minus any directory part.

Pattern 2: a die function

Error handling code repeats, so wrap it:

die() {
  local status=$1
  shift
  echo "${0##*/}: $*" >&2
  exit "$status"
}

shift drops the first argument (the status), and $* joins the rest into one string with spaces. Calling it reads naturally, and the || makes the failure "tested", so it works the same with or without set -e:

[ -r "$config" ]    || die 1 "cannot read config: $config"
mkdir -p "$dest"    || die 1 "cannot create $dest"
command -v gzip >/dev/null || die 3 "gzip is not installed"

A variant takes no status and always exits 1; choose one and use it everywhere.

Note: exit inside a function exits the whole script, not just the function, unless the function is running in a subshell, for example as $(die ...) or in a pipeline. A die inside a command substitution only ends that subshell: x=$(get_value || die 1 "...") sets x and carries on. Check the assignment instead: x=$(get_value) || die 1 "...".

Pattern 3: validate input first

Check everything you can before changing anything. A script that fails on argument two after deleting half the files of argument one is worse than one that refuses to start.

usage() { echo "usage: ${0##*/} COUNT FILE" >&2; exit 2; }

[ $# -eq 2 ] || usage
count=$1 file=$2

case $count in
  ''|*[!0-9]*) die 2 "COUNT must be a positive integer: $count" ;;
esac
[ "$count" -gt 0 ]  || die 2 "COUNT must be a positive integer: $count"
[ -r "$file" ]      || die 1 "cannot read file: $file"

Why the case? [ "$count" -gt 0 ] on a non-number prints [: abc: integer expression expected and returns 2, which is a confusing message for the user. The pattern *[!0-9]* matches any string containing a character that is not a digit, and '' matches the empty string, so together they reject everything that is not a plain whole number. Only then is the numeric test safe.

The usual checks:

CheckTest
argument count[ $# -eq 2 ]
non-empty[ -n "$x" ]
whole numbercase with the patterns '' and *[!0-9]* (see above)
file readable[ -r "$f" ], directory: [ -d "$d" ]
writable destination[ -w "$dir" ]
tool availablecommand -v jq >/dev/null

Pattern 4: test the command, not $?

You will see this a lot:

grep -q "$user" users.txt
if [ $? -ne 0 ]; then
  echo "unknown user"
fi

It works, but it is fragile (anything inserted between the two lines changes $?) and under set -e the script dies at the grep before reaching the check. Test the command directly:

if ! grep -q "$user" users.txt; then
  echo "unknown user"
fi

Same for cd, the most important command to check:

cd "$workdir" || die 1 "cannot cd to $workdir"

When you need the status value itself (to distinguish "no match" from "error"), capture it in a tested position:

status=0
grep -q "$pattern" "$file" || status=$?
case $status in
  0) echo "found" ;;
  1) echo "not found" ;;
  *) die 2 "grep failed on $file" ;;
esac

|| status=$? both records the status and marks the failure as handled, so it is safe under set -e.

Pattern 5: meaningful exit statuses

Your script's status is its interface to other programs. A consistent scheme:

StatusUse for
0success
1general runtime failure (file missing, command failed)
2usage error: wrong arguments (matches bash builtins and many GNU tools)
3+specific failures callers may want to distinguish; document them in --help

Avoid 126-165 and 255, which the shell uses for "not executable", "not found" and "killed by signal".

Putting it together

#!/usr/bin/env bash
set -euo pipefail

die()   { echo "${0##*/}: $*" >&2; exit 1; }
usage() { echo "usage: ${0##*/} SRC DEST" >&2; exit 2; }

[ $# -eq 2 ] || usage
src=$1 dest=$2
[ -d "$src" ]  || die "source is not a directory: $src"
mkdir -p "$dest" || die "cannot create destination: $dest"

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

tar -C "$src" -czf "$tmp/backup.tgz" . || die "tar failed"
mv "$tmp/backup.tgz" "$dest/backup-$(date +%F).tgz"
echo "${0##*/}: backed up $src to $dest" >&2

Validation up front, a trap for cleanup, every important command checked with a clear message.

Common mistakes

Exercises

A quiz on why stderr exists, a script whose whole job is careful validation (stderr messages are compared exactly by a test), and a small fix of the unchecked-cd bug.

Recap

Going further: read the EXIT STATUS sections of grep --help and diff --help for examples of tools whose non-zero statuses are not errors. Then write a warn function next to die that prints without exiting.

Exercises

Exercise 1 · quiz

Why stderr?

Why should a script print its error messages to standard error (>&2) rather than standard output? Select all that apply.

Answer
  • So that script > out.txt still shows errors on the terminal instead of hiding them in the file.

  • So that a pipeline like script | sort does not mix error text into the data being processed.

  • So that callers can capture data with $(script) without capturing the error text.

stdout is for the script's data, stderr for messages to the human. Keeping them apart means redirections, pipes and command substitution all carry only data, while errors still reach the person. Writing to stderr has no effect on the exit status; you still need exit 1 (or another non-zero number) so that scripts and && chains calling yours can tell it failed.

Exercise 2 · write code

Validate before you act

Write script.sh COUNT FILE, which prints the first COUNT lines of FILE, with careful error handling. Define a die function that prints script.sh: MESSAGE to stderr and exits with a given status, and use it for every error.

Situationstderr (exactly)exit
not exactly 2 argumentsusage: script.sh COUNT FILE2
COUNT is not a positive whole number (digits only, not 0)script.sh: COUNT must be a positive integer: X2
FILE cannot be readscript.sh: cannot read file: F1
success: first COUNT lines on stdoutnothing0

X and F are the values that were passed. Nothing should be printed on stdout when there is an error. The checker also runs a test that compares your stderr messages exactly.

File: script.sh

Starting point

count=$1
file=$2
head -n "$count" "$file"

Example with arguments 2 fruit.txt

expected output

apple
banana

Example with arguments 10 fruit.txt

expected output

apple
banana
cherry
date
Hint 1

Check $# first. Then check COUNT with a case pattern: ''|*[!0-9]*) matches empty or anything containing a non-digit.

Hint 2

[ -r "$file" ] tests that the file exists and is readable.

Hint 3

die() { local status=$1; shift; echo "script.sh: $*" >&2; exit "$status"; } then [ -r "$file" ] || die 1 "cannot read file: $file".

Reference solution
die() {
  local status=$1
  shift
  echo "script.sh: $*" >&2
  exit "$status"
}

if [ $# -ne 2 ]; then
  echo "usage: script.sh COUNT FILE" >&2
  exit 2
fi
count=$1
file=$2

case $count in
  ''|*[!0-9]*) die 2 "COUNT must be a positive integer: $count" ;;
esac
[ "$count" -gt 0 ] || die 2 "COUNT must be a positive integer: $count"
[ -r "$file" ] || die 1 "cannot read file: $file"

head -n "$count" "$file"

Exercise 3 · write code

Handle the failure yourself

script.sh DIR must change into DIR and print the number of entries in it (ls -A | wc -l) as DIR: N entries. The starter has the classic bug: if cd fails, it counts the wrong directory.

Rewrite it so that when cd fails, it prints skipping DIR to stdout (it is a report line, not an error) and exits with status 1, without counting anything. The checker provides a directory box holding three files.

Use if ! cd ...; then ... fi or cd ... || { ...; }. Note that cd prints its own error to stderr; you may silence it with 2>/dev/null.

File: script.sh

Starting point

cd "$1"
echo "$1: $(ls -A | wc -l) entries"

Example with arguments box

expected output

box: 3 entries

Example with arguments nowhere

expected output

skipping nowhere
Hint 1

if ! cd "$1"; then runs the block only when cd fails.

Hint 2

Inside the block: echo "skipping $1" then exit 1.

Reference solution
if ! cd "$1" 2>/dev/null; then
  echo "skipping $1"
  exit 1
fi
echo "$1: $(ls -A | wc -l) entries"