By default bash keeps going after a command fails, treats a misspelled variable as an empty string, and hides failures inside pipelines. A script that "worked" can therefore have silently skipped the step that mattered. This lesson shows the options that turn those defaults off, the trap mechanism for cleanup and error reporting, patterns for temporary files and retries, and the tools — shellcheck and set -x — that catch bugs before production does.
Start every script with these lines, then understand each one.
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'| Option | Effect |
|---|---|
| set -e | exit immediately when a command fails (non-zero status) |
| set -u | treat an unset variable as an error instead of empty |
| set -o pipefail | a pipeline fails if any command in it fails, not just the last |
| set -E | ERR traps are inherited by functions and subshells |
| IFS=$'\n\t' | word-split only on newlines and tabs, not spaces |
Without pipefail, curl https://bad.host | tar xz succeeds because tar exits 0 on empty input. Without -u, rm -rf "$BUILD_DR/" with a typo in the variable name expands to rm -rf /.
set -e has exceptions you must know. A failing command does not exit the script when it is:
if, while or until condition;&& or ||;pipefail (except the last command);!.This means commands that are allowed to fail must be written explicitly:
grep -q "pattern" file || true # ignore a missing match
if ! systemctl is-active app; then # fine: inside a condition
systemctl start app
fi
count=$(grep -c ERROR app.log || true) # grep -c exits 1 when count is 0And a subtle trap: local var=$(cmd) and export VAR=$(cmd) never trigger -e, because the status of local/export is what counts. Split them into two lines.
trap registers a command to run when the shell receives a signal or reaches a pseudo-event. EXIT fires however the script ends, which makes it the right place for cleanup.
tmp=$(mktemp -d)
cleanup() {
local status=$?
rm -rf "$tmp"
exit "$status"
}
trap cleanup EXIT
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
trap 'echo "interrupted"; exit 130' INT TERM$LINENO and $BASH_COMMAND give a precise location without a debugger. For services that reload on SIGHUP, trap reload_config HUP is the same mechanism.
Never invent temp file names; mktemp creates a unique file or directory with safe permissions. Write output to a temp file in the destination directory and mv it into place, because mv within one filesystem is atomic — readers see either the old file or the complete new one.
tmp=$(mktemp /etc/app/config.json.XXXXXX)
generate_config > "$tmp"
jq empty "$tmp" # validate before replacing
mv "$tmp" /etc/app/config.jsonFail before doing any work if a dependency, permission or input is missing.
require() { command -v "$1" > /dev/null || { echo "missing: $1" >&2; exit 127; }; }
require jq; require rsync
[[ $EUID -eq 0 ]] || { echo "run as root" >&2; exit 77; }
[[ -r "$INPUT" ]] || { echo "cannot read $INPUT" >&2; exit 66; }
: "${API_TOKEN:?API_TOKEN must be set}"Network calls fail transiently. Retry with backoff and cap the wait with timeout.
retry() {
local attempts="$1" delay="$2"; shift 2
local n=1
until "$@"; do
(( n >= attempts )) && return 1
echo "attempt $n failed, retrying in ${delay}s" >&2
sleep "$delay"; (( n++ )); (( delay *= 2 ))
done
}
retry 5 2 curl -sf https://api.example.com/health
timeout 30s ./slow-migration.sh || echo "migration timed out" >&2bash -x script.sh # print every command as it runs
set -x; risky_part; set +x # trace only a section
PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x script.sh # include file and line in the trace
bash -n script.sh # syntax check without executingshellcheck is a static analyser that catches unquoted variables, useless cat, wrong test operators and dozens of other pitfalls. Run it in CI.
sudo apt install shellcheck
shellcheck deploy.shIn deploy.sh line 12:
rm -rf $BUILD_DIR/
^--------^ SC2086: Double quote to prevent globbing and word splitting.Each warning carries a code (SC2086) whose full explanation appears in the editor extension and in the project's wiki. Fix every warning or suppress it deliberately with # shellcheck disable=SC2086 and a comment explaining why.
set -e and assuming every failure is now handled; the exceptions above still apply.trap ... EXIT but forgetting to preserve $?, so the script always exits 0.2>/dev/null instead of handling them.#!/bin/sh with bash-only features like [[ ]], arrays or pipefail.What does `set -o pipefail` change?
set -Eeuo pipefail and understand the cases where -e does not apply.trap cleanup EXIT runs however the script ends; trap ... ERR with $LINENO pinpoints failures.mktemp for temporary files and write-then-mv for atomic replacement.shellcheck on every script and bash -x when behaviour is unclear.Next lesson: Archives and Compression: tar, gzip and zip — bundle and shrink files for transfer, backups and releases.