Bash Error Handling, Traps, and Defensive Scripting
Write fault-tolerant Bash scripts with set -euo pipefail, trap for cleanup, custom error handlers, and retry logic. Lesson 6 of the Linux & Bash Scripting course.

The difference between a script that works in testing and one that survives production is error handling. Without it, a failed command silently passes control to the next line; a pipeline error is swallowed; a script interrupted mid-run leaves behind partial state. This lesson covers the complete error-handling toolkit: set options, trap, custom error handlers, and patterns for graceful recovery.
Previous: Lesson 5 — Functions, Control Flow, and Exit Codes
set Options — The Foundation
Add these three options to every production script, immediately after the shebang:
#!/usr/bin/env bash
set -euo pipefail| Option | Effect |
|---|---|
-e (errexit) | Exit immediately if any command returns a non-zero exit code |
-u (nounset) | Treat unset variables as errors — prevents silent bugs from empty $1 etc. |
-o pipefail | A pipeline fails if any command in it fails, not just the last one |
# Without pipefail — this succeeds even though grep exits 1 (no match)
grep "MISSING" /var/log/app.log | wc -l # prints 0, exit code 0
# With pipefail — grep's exit code 1 propagates
set -o pipefail
grep "MISSING" /var/log/app.log | wc -l # exits with 1Temporary Option Overrides
Sometimes you need to run a command that is allowed to fail:
set -euo pipefail
# Disable -e for one command, then re-enable
set +e
grep "pattern" file.txt
grep_exit=$?
set -e
# Cleaner alternative: use || true to absorb a non-zero exit
grep "pattern" file.txt || true
# Conditional without triggering -e
if grep -q "pattern" file.txt; then
echo "Found"
fiThe trap Command
trap registers a command to run when the shell receives a signal or reaches a specific state.
trap 'command' SIGNAL [SIGNAL...]Cleanup on EXIT
The EXIT pseudo-signal fires whenever the script exits — whether normally, via exit, or due to an error. It is the correct place for cleanup logic:
#!/usr/bin/env bash
set -euo pipefail
readonly TMP_DIR="$(mktemp -d)"
cleanup() {
local exit_code=$?
rm -rf "$TMP_DIR"
if [[ $exit_code -ne 0 ]]; then
echo "Script failed with exit code: $exit_code" >&2
fi
exit "$exit_code"
}
trap cleanup EXIT
# Script body — TMP_DIR is always cleaned up
cp /large/file.tar.gz "$TMP_DIR/"
tar -xzf "${TMP_DIR}/file.tar.gz" -C "$TMP_DIR"
process_extracted_files "$TMP_DIR"ERR Trap — Custom Error Handler
The ERR trap fires whenever a command fails (when -e is set). Use it to add context to failures:
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
local line_number="${BASH_LINENO[0]}"
local command="${BASH_COMMAND}"
echo "ERROR: Command '${command}' failed with exit code ${exit_code} on line ${line_number}" >&2
}
trap on_error ERRSignal Handling — SIGINT and SIGTERM
Handle interrupt and termination signals to allow graceful shutdown:
#!/usr/bin/env bash
set -euo pipefail
SHUTDOWN=false
handle_signal() {
echo "Received shutdown signal — finishing current task..." >&2
SHUTDOWN=true
}
trap handle_signal SIGINT SIGTERM
# Long-running loop that respects shutdown requests
while true; do
if [[ "$SHUTDOWN" == "true" ]]; then
echo "Shutting down gracefully"
exit 0
fi
process_next_job
sleep 1
doneCombining EXIT and ERR Traps
#!/usr/bin/env bash
set -euo pipefail
readonly LOCK_FILE="/var/run/myapp.lock"
readonly TMP_DIR="$(mktemp -d)"
cleanup() {
rm -f "$LOCK_FILE"
rm -rf "$TMP_DIR"
}
error_handler() {
echo "Failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2
# Slack/webhook alert could go here
}
trap cleanup EXIT
trap error_handler ERR
# Prevent concurrent runs
if [[ -f "$LOCK_FILE" ]]; then
echo "Another instance is running (lock: $LOCK_FILE)" >&2
exit 1
fi
touch "$LOCK_FILE"Defensive Patterns
Validate All Inputs
#!/usr/bin/env bash
set -euo pipefail
validate_inputs() {
local env="$1"
local version="$2"
local valid_envs=("production" "staging" "development")
local valid=false
for e in "${valid_envs[@]}"; do
[[ "$env" == "$e" ]] && valid=true && break
done
if [[ "$valid" == "false" ]]; then
echo "ERROR: Invalid environment '${env}'. Valid: ${valid_envs[*]}" >&2
exit 1
fi
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: Version '${version}' is not in semver format (e.g. 1.2.3)" >&2
exit 1
fi
}Require External Commands
Fail fast if a dependency is missing rather than letting the script fail mid-execution:
require_commands() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if (( ${#missing[@]} > 0 )); then
echo "ERROR: Required commands not found: ${missing[*]}" >&2
exit 1
fi
}
require_commands curl jq git docker kubectlRetry Logic with Backoff
retry() {
local max_attempts="$1"
local delay="$2"
shift 2
local command=("$@")
local attempt=0
until "${command[@]}"; do
(( attempt++ ))
if (( attempt >= max_attempts )); then
echo "ERROR: Command failed after ${max_attempts} attempts: ${command[*]}" >&2
return 1
fi
echo "Attempt ${attempt}/${max_attempts} failed. Retrying in ${delay}s..."
sleep "$delay"
(( delay = delay * 2 ))
done
}
# Usage
retry 5 2 curl -sf http://api.example.com/health
retry 3 10 kubectl rollout status deployment/myappAtomic File Writes
Writing directly to a target file leaves it in a corrupt state if the script is interrupted mid-write. Write to a temp file and rename atomically:
write_config() {
local target="$1"
local tmp
tmp="$(mktemp "${target}.XXXXXX")"
# Generate content to temp file
generate_config > "$tmp"
# Validate before deploying
if ! validate_config "$tmp"; then
rm -f "$tmp"
echo "ERROR: Generated config failed validation" >&2
return 1
fi
# Atomic rename — either the whole file is written or nothing
mv "$tmp" "$target"
echo "Config written to $target"
}Locking — Preventing Concurrent Execution
#!/usr/bin/env bash
set -euo pipefail
acquire_lock() {
local lock_file="$1"
local lock_fd=200
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
echo "ERROR: Could not acquire lock on ${lock_file}. Is another instance running?" >&2
exit 1
fi
}
release_lock() {
flock -u 200
}
trap release_lock EXIT
acquire_lock "/var/run/backup.lock"
# ... rest of script runs exclusively ...