LinuxXXXV · Shell Scripting for SysadminsError handling
Error handling - set -euo pipefail and trap
What you'll learn
- Use set -euo pipefail correctly
- Trap signals for cleanup
- Distinguish error from success exit codes
- Write production-grade error handling
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
Shell scripts fail in unexpected ways. Error handling makes failures predictable and recoverable. This lesson covers the production patterns.
set -euo pipefail
At the top of every production script:
set -euo pipefail
-e: exit on any command failure.-u: error on undefined variables.-o pipefail: pipeline fails if any command in the pipe fails.
Without these, scripts continue past errors and produce unpredictable results.
Caveats
set -e is not a guarantee. It is a set of rules with real
exemptions, and the exemptions are where production scripts
silently carry on after a failure. Know all of them:
-
Conditionals. A command whose status is tested is exempt: the condition of
ifandwhile, anything to the left of&&or||, and anything under!. This is by design — the script is already handling that status. -
Any command followed by
||.cmd || trueis the idiom for “failure is expected here”, and it works because of the rule above. -
Arithmetic that evaluates to zero.
((count++))on acountof0returns the old value,0, which the shell reads as a failure status of 1 — andset -ekills the script. A counter increment is the usual victim:set -e count=0 ((count++)) # exits the script: the expression evaluated to 0 count=$((count + 1)) # safe: an assignment, not an arithmetic command ((count++)) || true # also safe, if you want the ++ form -
Assignment from a command substitution. When the substitution is part of a
local,declare,exportorreadonly, the status you see is the declaration’s, not the command’s — the failure is discarded andset -enever fires:set -e bad() { local out=$(false); echo "still running, \$? is $?"; } # prints 0 good() { local out; out=$(false); echo "never reached"; } # exitsShellCheck flags this as
SC2155. Declare on one line, assign on the next. -
Subshells whose status is consumed.
( cmd_a; cmd_b ) || handlerputs the whole subshell to the left of||, soset -eis suspended for everything inside it. -
set -uand the-expansions.${VAR-default}and${VAR:-default}are exactly the forms that are safe underset -u— supplying a default is their purpose, and neither triggers an unbound-variable error. It is the bare$VARthat aborts. Use${VAR:?message}where the variable is mandatory and you want a named error rather than a generic one.
For commands where failure is expected:
# Allow failure
cmd || true
# Or: check explicitly
if ! cmd; then
handle_error
fi
Which stage of the pipeline failed?
set -o pipefail tells you that a pipeline failed. It does not
tell you where, and the exit status you get is the last
non-zero one, not the first. ${PIPESTATUS[@]} holds the status
of every stage:
set -o pipefail
restic backup /data | tee /var/log/backup.log | gzip > /var/log/backup.log.gz
status=("${PIPESTATUS[@]}")
for i in "${!status[@]}"; do
(( status[i] == 0 )) || echo "pipeline stage $i exited ${status[i]}" >&2
done
PIPESTATUS is rebuilt by every command, including the echo
you were about to use to debug it. Copy it into an array on the
line immediately after the pipeline, before anything else runs:
false | true | false
echo "checking..." # this overwrites PIPESTATUS
echo "${PIPESTATUS[@]}" # 0 - the status of the echo above
Without pipefail, that pipeline exits 0 because only the last
stage counts. With pipefail it exits 1, and PIPESTATUS shows
1 0 1 — which is what tells you the backup itself failed, not
just the compression.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell builtins |
| 126 | Command cannot execute |
| 127 | Command not found |
| 128+N | Killed by signal N |
| 130 | Ctrl-C (SIGINT, 128+2) |
| 137 | SIGKILL (128+9) |
| 143 | SIGTERM (128+15) |
Use specific exit codes in production scripts:
E_OK=0
E_USAGE=1
E_RUNTIME=2
E_NOT_FOUND=3
[[ $# -eq 1 ]] || { err "Usage: $0 <file>"; exit $E_USAGE; }
trap
Catch signals for cleanup:
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# Now use $TMPDIR; cleanup runs on script exit
trap '...' EXIT runs on any exit (success, error, signal).
Multiple traps:
cleanup() {
rm -rf "$TMPDIR"
log "Cleaned up"
}
trap cleanup EXIT INT TERM
Error reporting
Log errors with context:
log() {
echo "[$(date -Is)] $*" >&2
}
err() {
local rc=$?
log "ERROR: $*" "rc=$rc" "line=${BASH_LINENO[0]}/>"
exit 1
}
${BASH_LINENO[0]}/> is the current line number. Useful in
debugging.
Common patterns
Cleanup on failure
cleanup() {
rm -rf "$TMPDIR"
}
trap cleanup EXIT INT TERM
Log to syslog
log() {
logger -t my-script "$*"
}
Retry on transient failures
retry() {
local max_attempts=3
local delay=5
local attempt=1
while ((attempt <= max_attempts)); do
if "$@"; then
return 0
fi
log "Attempt $attempt failed; retrying in $delay seconds"
sleep "$delay"
((attempt++))
done
err "Failed after $max_attempts attempts: $*"
}
retry curl -fsSL https://example.com/install.sh
Checkpoint
checkpoint() {
local name="$1"
echo "$name" >> /var/lib/my-script/checkpoint
}
if grep -q "step-3" /var/lib/my-script/checkpoint; then
log "Resuming from step 3"
else
log "Starting from beginning"
fi
Knowledge check
Knowledge check · 3 questions
Q1. What does set -o pipefail do?
Q2. Under set -e, a command that fails inside an if condition does not abort the script.
Q3. Which of the following are valid error handling patterns? Select all that apply.
Passing score: 75%. Answers are checked in this browser.