Write Safer Bash Scripts: set -euo pipefail, trap and ShellCheck
Bash is forgiving by default, and that's the problem. A typo in a variable name can turn rm -rf "$DIR/" into rm -rf /. A few habits make scripts fail loudly instead of doing damage.
Start every script like this
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
| Option | What it does |
|---|---|
-e |
Exit as soon as a command fails |
-u |
Treat unset variables as errors (catches typos) |
-o pipefail |
A pipeline fails if any stage fails, not just the last |
-E |
Makes ERR traps work inside functions |
IFS=$'\n\t' |
Split words on newlines and tabs only, not spaces |
set -e is not magic
It does not trigger inside if, &&/|| lists, or while conditions, and it can surprise you in subshells. Use it as a safety net, not as your whole error handling. When a failure is acceptable, say so explicitly:
grep -q pattern file || true
Quote everything
rm -rf "$BUILD_DIR"/* # right
rm -rf $BUILD_DIR/* # wrong: breaks on spaces, and is catastrophic if the variable is empty
Use "$@" to pass arguments through, and "${arr[@]}" for arrays. Use [[ ... ]] for tests.
Guard dangerous variables
Make the script stop if a variable is empty:
: "${BUILD_DIR:?BUILD_DIR is not set}"
rm -rf "${BUILD_DIR:?}/"*
${VAR:?} aborts if VAR is unset or empty, which is exactly what you want before a rm -rf.
Clean up with trap
Temporary files should disappear even when the script fails or is interrupted:
tmp=$(mktemp -d)
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT
mktemp creates a unique, safe file or directory. Never use a fixed name such as /tmp/data.txt. Another user could plant a symlink there.
Report errors with context:
trap 'echo "Error on line $LINENO: $BASH_COMMAND" >&2' ERR
Validate input
usage() { echo "Usage: $0 <source> <dest>" >&2; exit 2; }
[[ $# -eq 2 ]] || usage
[[ -d "$1" ]] || { echo "Not a directory: $1" >&2; exit 1; }
Send errors to stderr (>&2) and use meaningful exit codes. Never build commands from untrusted input with eval.
Prevent two copies from running at once
exec 9>/var/lock/myjob.lock
flock -n 9 || { echo "Already running" >&2; exit 1; }
Or from cron: flock -n /var/lock/myjob.lock /path/to/script.sh.
Handle filenames with spaces and newlines safely
# Don't parse ls. Use globs:
for f in /data/*.csv; do [[ -e "$f" ]] || continue; process "$f"; done
# For find, use null separators:
find /data -type f -name '*.log' -print0 | xargs -0 -r gzip
# or:
while IFS= read -r -d '' f; do echo "$f"; done < <(find /data -type f -print0)
Add a dry-run mode
DRY_RUN=${DRY_RUN:-0}
run() { if (( DRY_RUN )); then echo "[dry-run] $*"; else "$@"; fi; }
run rm -f "$old_file"
Run it first with DRY_RUN=1 ./cleanup.sh.
Check your script with tools
bash -n script.sh # syntax check only
bash -x script.sh # trace every command as it runs
shellcheck script.sh # static analysis, finds most mistakes
Install ShellCheck with sudo apt install shellcheck, or paste code into shellcheck.net. It explains each warning and is the single best habit to build.
A template to copy
#!/usr/bin/env bash
set -Eeuo pipefail
log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
die() { log "ERROR: $*" >&2; exit 1; }
[[ $# -ge 1 ]] || die "Usage: $0 <target>"
target=$1
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
log "Starting on $target"
# ... your work here ...
log "Done"