Bash Scripts That Fail Fast and Clean Up: A Practical Pattern with set -euo pipefail and trap
A concise, production-ready pattern for Bash scripts that abort on any error, treat unset variables as failures, catch pipeline breaks, and guarantee cleanup on exit or signals — using set -euo pipefail and trap.
10 Apr 2026, 06:08 UTC

The Problem: Silent Failures and Leftover Garbage
Most Bash scripts in production environments suffer from two related problems: they continue executing after a command fails, and they leave temporary files or child processes behind when interrupted. A script that copies a database dump, transforms it, and loads it into a warehouse might silently proceed with a corrupted file because gunzip failed but awk still ran. Worse, a SIGINT from an operator or a CI timeout leaves /tmp full of partial outputs and orphaned background jobs.
The fix is a small, well-understood pattern: set -euo pipefail at the top of the script, plus a trap on EXIT, INT, and TERM that runs an idempotent cleanup function. This gives you "fail fast" semantics and guaranteed resource release without scattering error checks throughout your logic.
Desired Outcome
- Any command that exits non-zero aborts the script immediately.
- Referencing an unset variable aborts the script.
- A pipeline fails if any component fails, not just the last one.
- Cleanup code runs on normal exit,
Ctrl-C,kill, orkill -TERM. - Cleanup is safe to run multiple times (idempotent).
Prerequisites
- Bash 3.0 or later (standard on Linux, macOS, WSL, and most containers).
- A script file with execute permission:
chmod +x your-script.sh. - Basic familiarity with shell scripting (variables, functions, exit codes).
The Pattern, Step by Step
1. Shebang and Strict Mode
#!/usr/bin/env bash
set -euo pipefail
Place these two lines at the very top, before any other code. set -e exits on any command failure. set -u treats unset variables as errors. set -o pipefail makes a pipeline return the exit code of the rightmost command that failed (or zero if all succeed).
2. Define an Idempotent Cleanup Function
cleanup() {
# Remove temporary files if they exist
[[ -n "${TMPFILE:-}" ]] && rm -f "$TMPFILE"
# Kill child processes we started
[[ -n "${CHILD_PID:-}" ]] && kill "$CHILD_PID" 2>/dev/null || true
# Release any locks, close file descriptors, etc.
}
readonly -f cleanup
Use [[ -n "${VAR:-}" ]] so the function never fails if a variable was never set. The || true on kill prevents the cleanup itself from triggering set -e if the process is already gone. readonly -f prevents accidental redefinition.
3. Register the Trap
trap cleanup EXIT INT TERM
This runs cleanup on:
EXIT— any shell exit (normal or viaset -e).INT—Ctrl-C(SIGINT).TERM—kill <pid>(SIGTERM), typical in CI/CD and container orchestration.
Note: SIGKILL (signal 9) cannot be trapped. Design cleanup to be safe if it never runs.
4. Create Temporary Resources Safely
TMPFILE="$(mktemp -t myscript.XXXXXX)"
# If you start background jobs, capture their PIDs
some-long-running-command &
CHILD_PID=$!
mktemp creates a unique, secure temporary file. Assign its path to a variable before any command that could fail, so cleanup knows what to remove.
5. Write Main Logic
# Example: download, transform, load
curl -fsSL "$SOURCE_URL" -o "$TMPFILE"
gzip -d -c "$TMPFILE" | transform-data | load-into-warehouse
# If any step fails, script exits and cleanup runs
Because of pipefail, if curl succeeds but transform-data fails, the pipeline returns non-zero and the script aborts. The trap fires and removes $TMPFILE.
Concrete Example: A Complete Script
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
[[ -n "${TMPFILE:-}" ]] && rm -f "$TMPFILE"
[[ -n "${CHILD_PID:-}" ]] && kill "$CHILD_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Configuration (could come from args/env)
SOURCE_URL="https://example.com/data.json.gz"
WAREHOUSE_DSN="postgresql://user:pass@localhost/db"
# Acquire resources
TMPFILE="$(mktemp -t ingest.XXXXXX)"
# Start a sidecar process (e.g., a metrics exporter)
python3 -m http.server 9090 >/dev/null 2>&1 &
CHILD_PID=$!
# Main pipeline — any failure aborts
echo "Downloading $SOURCE_URL..."
curl -fsSL "$SOURCE_URL" -o "$TMPFILE"
echo "Decompressing and loading..."
gzip -d -c "$TMPFILE" | psql "$WAREHOUSE_DSN" -c "COPY raw_data FROM STDIN WITH (FORMAT json)"
echo "Ingest complete."
# cleanup runs automatically via EXIT trap
Expected Checks (How to Verify It Works)
- Happy path: Run with a valid URL and database. Script completes, prints "Ingest complete," and
$TMPFILEis gone. - Command failure: Replace
curlwithfalseor point to a 404 URL. Script exits non-zero;echo $?shows failure;ls /tmp/ingest.*shows no leftovers. - Unset variable: Add
echo "$UNDEFINED_VAR"mid-script. Script aborts with "unbound variable" message; cleanup runs. - Broken pipeline: Use
cat nonexistent.gz | gzip -d | psql ....pipefailcatches thecatfailure; script exits; cleanup runs. - Signal handling: Run script in background (
./ingest.sh &), note PID, thenkill -INT <pid>. Cleanup executes (addecho "cleanup running" >&2in the function to observe).
Limitations and Gotchas
set -e Does Not Trigger In:
- Conditions of
if,while,until. - The left side of
&&or||. - Commands inside
( ... )subshells. - Commands in a pipeline except the last one (unless
pipefailis set).
Workaround: write if ! risky-command; then cleanup; exit 1; fi or risky-command || { cleanup; exit 1; } for custom handling while keeping global set -e.
pipefail Behavior
The pipeline returns the exit code of the rightmost failing command. If cmd1 | cmd2 | cmd3 runs and cmd1 fails but cmd2 and cmd3 succeed, the pipeline exit code is 0. If cmd2 fails, the exit code is cmd2's code. Ensure downstream commands handle partial input gracefully (e.g., psql with COPY will error on truncated JSON).
Traps and SIGKILL
If the OOM killer or kill -9 terminates the script, cleanup never runs. Mitigate by:
- Writing temporary files to a directory that gets cleaned on reboot (e.g.,
/tmpon most systems). - Using
systemdor container runtime cleanup hooks for critical resources. - Making cleanup idempotent so a subsequent run can remove leftovers.
Variable Scope in Traps
The trap runs in the same shell context, so it sees all variables. However, if you source another script that sets its own trap on EXIT, the last trap wins. Use trap 'cleanup; sourced_cleanup' EXIT to chain them, or avoid EXIT traps in sourced libraries.
Static Analysis and Syntax Checks
# Syntax check (no execution)
bash -n your-script.sh
# Deeper static analysis (install shellcheck first)
shellcheck your-script.sh
shellcheck catches common issues like unquoted variables, misuse of set -e in subshells, and missing pipefail. Run it in CI.
Recovery Options
Since the script aborts on the first error, the operator fixes the root cause (bad URL, schema mismatch, permission denied) and re-runs. The script is idempotent by design: temporary files use unique names via mktemp, and the warehouse load uses COPY which can be wrapped in a transaction or staged table for retry safety.
For non-idempotent external calls (e.g., sending an email), wrap only that section:
send_notification || {
echo "Notification failed, continuing" >&2
# Don't exit; log and proceed
}
This preserves global set -e for everything else.
When to Use This Pattern
- Any script that creates temporary files, starts background processes, or acquires locks.
- Scripts run unattended in CI/CD, cron, systemd timers, or container entrypoints.
- Data pipelines where silent corruption is worse than visible failure.
For one-liners, interactive sessions, or scripts that intentionally continue on error (e.g., a cleanup script that tries its best), this pattern is overkill.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.