Most bash scripts fail silently — a command fails, the script keeps running, and you discover the problem three steps later from a corrupted database or missing file. Proper error handling makes failures loud, immediate, and informative. This is the single biggest quality gap between beginner and production scripts.
1
set -euo pipefail — the essential safety net
BASH
#!/usr/bin/env bash
# Every production script starts with this line:
set -euo pipefail
# What each flag does:
# -e (errexit) — exit immediately if any command fails (non-zero exit)
# -u (nounset) — treat unset variables as errors
# -o pipefail — pipeline fails if ANY command in it fails
# ── Without set -e ────────────────────────────────────────
rm /nonexistent/file # fails silently — script continues!
echo "This still runs" # runs even after failure above
# ── With set -e ───────────────────────────────────────────
set -e
rm /nonexistent/file # script STOPS here
echo "This never runs" # never reached
# ── Without set -u ────────────────────────────────────────
echo "${UNSET_VAR}" # prints empty string silently
# ── With set -u ───────────────────────────────────────────
set -u
echo "${UNSET_VAR}" # ERROR: UNSET_VAR: unbound variable
# ── Without pipefail ──────────────────────────────────────
cat /missing/file | grep "pattern"
echo "Exit: $?" # 0! grep succeeded on empty input
# ── With pipefail ─────────────────────────────────────────
set -o pipefail
cat /missing/file | grep "pattern"
echo "Exit: $?" # non-zero: cat failed
2
trap ERR — catch errors with context
BASH
#!/usr/bin/env bash
set -euo pipefail
# ── Error handler with full context ──────────────────────
error_handler() {
local exit_code=$?
local line="${1}"
local command="${2}"
echo "" >&2
echo "════════════════════════════════════════" >&2
echo " ERROR in ${BASH_SOURCE[1]}" >&2
echo " Line : ${line}" >&2
echo " Command : ${command}" >&2
echo " Exit : ${exit_code}" >&2
echo " Time : $(date '+%Y-%m-%d %H:%M:%S')" >&2
echo "════════════════════════════════════════" >&2
}
trap 'error_handler "${LINENO}" "${BASH_COMMAND}"' ERR
# ── Stack trace ───────────────────────────────────────────
print_stack() {
echo " Call stack:" >&2
local i
for (( i=1; i<${#FUNCNAME[@]}; i++ )); do
printf " [%d] %s() in %s:%d\n" \
"${i}" "${FUNCNAME[$i]}" \
"${BASH_SOURCE[$i]}" "${BASH_LINENO[$((i-1))]}" >&2
done
}
trap 'error_handler "${LINENO}" "${BASH_COMMAND}"; print_stack' ERR
3
Custom error functions
BASH
#!/usr/bin/env bash
set -euo pipefail
# ── Error functions ───────────────────────────────────────
die() {
echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2
exit 1
}
die_if_empty() {
[[ -n "${1:-}" ]] || die "${2:-value is required}"
}
require() {
command -v "${1}" >/dev/null 2>&1 \
|| die "Required command not found: ${1}"
}
assert_file() {
[[ -f "${1}" ]] || die "Required file missing: ${1}"
[[ -r "${1}" ]] || die "File not readable: ${1}"
}
assert_dir() {
[[ -d "${1}" ]] || die "Required directory missing: ${1}"
[[ -w "${1}" ]] || die "Directory not writable: ${1}"
}
# ── Usage ─────────────────────────────────────────────────
require mysql
require jq
require curl
assert_file "/etc/myapp/config.env"
assert_dir "/var/log/myapp"
DB_HOST="${DB_HOST:-}"
die_if_empty "${DB_HOST}" "DB_HOST environment variable must be set"
echo "All checks passed"
4
bash -x tracing and PS4 customisation
BASH
# ── bash -x — trace every command ────────────────────────
bash -x myscript.sh # prints each command before running
bash -xv myscript.sh # also print script as it's read
# ── Enable/disable tracing mid-script ────────────────────
set -x # turn on tracing
sensitive_function
set +x # turn off (hide sensitive section)
# ── PS4 — customise trace prefix ─────────────────────────
# Default PS4 is "+" — not very useful
# Custom PS4 shows file, line number, and function
export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}] '
set -x
# Now traces look like:
# + [deploy.sh:42:backup_db] mysql -h prod-db-01 ...
# ── bash -n — syntax check without running ────────────────
bash -n myscript.sh # check syntax only — does not execute
echo "Syntax: $?" # 0 = OK, non-zero = syntax error
# ── BASH_LINENO and BASH_SOURCE for debug info ───────────
debug_info() {
echo " DEBUG: Called from ${BASH_SOURCE[1]}:${BASH_LINENO[0]}"
echo " DEBUG: Function stack: ${FUNCNAME[*]}"
}
5
ShellCheck — static analysis
BASH
# Install ShellCheck
apt install shellcheck # Debian/Ubuntu
brew install shellcheck # macOS
# Run ShellCheck
shellcheck myscript.sh # check one script
shellcheck lib/*.sh bin/*.sh # check all scripts
shellcheck -S warning myscript.sh # warnings and above only
# ShellCheck in CI — fails build if issues found
shellcheck --severity=error bin/*.sh || exit 1
# Disable specific warnings inline
# shellcheck disable=SC2086
echo ${unquoted_var} # SC2086: double quote to prevent globbing
6
Production error handling template
BASH
#!/usr/bin/env bash
# production_script.sh — Complete error handling template
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME=$(basename "${0}")
readonly LOG_FILE="/var/log/myapp/${SCRIPT_NAME%.sh}.log"
START_TIME=$(date +%s)
# ── Logging ───────────────────────────────────────────────
_log() { printf "[%s] %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "${LOG_FILE}"; }
log() { _log "INFO $*"; }
warn() { _log "WARN $*"; }
err() { _log "ERROR $*" >&2; }
die() { err "$*"; exit 1; }
# ── Error trap ────────────────────────────────────────────
on_error() {
err "Script failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}"
err "Exit code: $?"
}
trap on_error ERR
# ── Cleanup ───────────────────────────────────────────────
cleanup() {
local elapsed=$(( $(date +%s) - START_TIME ))
log "Script finished in ${elapsed}s (exit: $?)"
rm -f "${TMPDIR:-/tmp}/${SCRIPT_NAME}.$$"*
}
trap cleanup EXIT
# ── Main ──────────────────────────────────────────────────
main() {
log "Starting ${SCRIPT_NAME}"
# Validate environment
command -v mysql >/dev/null || die "mysql client not found"
[[ -n "${DB_HOST:-}" ]] || die "DB_HOST not set"
log "Environment validated"
log "Main work done successfully"
}
main "$@"
Terminal output
Key
Success
Error with context
Debug trace
vriddh@prod-01:~/scripts$./production_script.sh
[2026-05-01 10:14:02] INFO Starting production_script.sh
[2026-05-01 10:14:02] ERROR DB_HOST not set
[2026-05-01 10:14:02] INFO Script finished in 0s (exit: 1)
vriddh@prod-01:~/scripts$DB_HOST=prod-db-01 ./production_script.sh
[2026-05-01 10:14:05] INFO Starting production_script.sh
[2026-05-01 10:14:05] INFO Environment validated
[2026-05-01 10:14:06] INFO Main work done successfully
[2026-05-01 10:14:06] INFO Script finished in 1s (exit: 0)
█
✔ Error handling checklist — Every production script must have:
set -euo pipefail on line 2 · trap cleanup EXIT for temp file cleanup · trap on_error ERR with $BASH_LINENO and $BASH_COMMAND · validated required env vars and commands at startup · all errors to stderr (>&2) · meaningful exit codes. Run shellcheck before every commit.