🛠️ ITBlogs.pro

Write Safer Bash Scripts: set -euo pipefail, trap and ShellCheck

Oct 16, 2026 · Bash · 3 min read

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"