Shell Scripting Bash Debugging Basics May 2026

Shell Scripting Error Handling & Debugging

Build bulletproof scripts with set -euo pipefail, trap ERR for error context, custom error functions, bash -x tracing, PS4 customisation, and ShellCheck static analysis. The difference between hobby and production scripts.

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.

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
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
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"
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[*]}"
}
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
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 "$@"
Key Success Error with context Debug trace
bash — production_script.sh
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.