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:
exitinside 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. Adieinside a command substitution only ends that subshell:x=$(get_value || die 1 "...")setsxand 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:
| Check | Test |
|---|---|
| argument count | [ $# -eq 2 ] |
| non-empty | [ -n "$x" ] |
| whole number | case with the patterns '' and *[!0-9]* (see above) |
| file readable | [ -r "$f" ], directory: [ -d "$d" ] |
| writable destination | [ -w "$dir" ] |
| tool available | command -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:
| Status | Use for |
|---|---|
| 0 | success |
| 1 | general runtime failure (file missing, command failed) |
| 2 | usage 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
- Error text on stdout.
files=$(myscript)then contains the error text as if it were data. - No exit status. Printing "error" and continuing, or ending with
exit 0after an error, hides the failure from callers and from&&. [ "$n" -gt 0 ]on unchecked input:[: abc: integer expression expected.if [ $? -ne 0 ]after a command underset -e. The script already died at the command.dieinside$(...). It exits only the subshell; the script carries on with an empty value.- Vague messages. "Error!" or "failed". Say what, which input, and if possible what to do.
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
- Messages to stderr, prefixed with the script name; data to stdout.
- Wrap error exits in a
diefunction and usecond || die .... - Validate arguments and inputs before changing anything; check numbers with
casefirst. - Test commands directly with
if !or||instead of inspecting$?afterwards. - Use a consistent status scheme: 0 success, 1 failure, 2 usage.
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.