Skip to main content
Bash intermediate Lesson 14 of 22

Bash Error Handling

Write robust scripts with set -euo pipefail, trap ERR, exit codes, meaningful error messages, and safe cleanup patterns.

The Golden Header

The single most impactful change you can make to a Bash script is adding a proper error-handling header. By default, Bash continues executing even after a command fails, uses empty strings for unset variables without warning, and reports a pipeline as successful even if intermediate commands fail. These defaults cause silent data corruption and hard-to-debug failures. The golden header opts out of all of them.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

What each flag does:

set -e          # Exit immediately if any command fails (returns non-zero exit code)
set -u          # Treat any reference to an unset variable as an error
set -o pipefail # A pipeline fails if ANY command in it fails, not just the last one
IFS=$'\n\t'     # Word splitting only on newlines and tabs — prevents space-splitting bugs

The difference without these flags:

# Without set -e: this prints "success" even though mkdir failed
mkdir /root/no_permission
echo "success"   # runs anyway — you have no idea the mkdir failed!

# With set -e: script exits at mkdir failure with a clear error
set -e
mkdir /root/no_permission
echo "success"   # never reached

Exit Codes

Exit codes are the universal language of success and failure in Unix. Every command and script communicates its outcome through its exit code. Writing scripts that set exit codes correctly makes them composable — other scripts and CI systems can check whether yours succeeded.

# Check the exit code of the last command
ls /nonexistent
echo $?   # 2

# Return a meaningful exit code from a function
validate_input() {
  local input="$1"
  if [[ -z "$input" ]]; then
    echo "ERROR: input cannot be empty" >&2
    return 1
  fi
  if [[ ${#input} -lt 3 ]]; then
    echo "ERROR: input too short (min 3 chars)" >&2
    return 1
  fi
  return 0   # explicit success
}

if ! validate_input "$1"; then
  exit 1
fi

Conventional exit codes:

CodeMeaning
0Success
1General error
2Misuse (bad arguments)
126Command found but not executable
127Command not found
128+NFatal signal N (e.g., 130 = Ctrl+C, 143 = SIGTERM)

Error Messages

Error messages must go to stderr, not stdout. Stdout is for data — the output that other scripts and pipelines consume. Mixing error messages into stdout corrupts the data stream and makes pipelines behave unpredictably.

# Bad — error message goes to stdout, gets mixed with real output
echo "ERROR: file not found"

# Good — error goes to stderr, kept separate from data output
echo "ERROR: file not found" >&2

# A consistent die() function — print to stderr and exit
die() {
  local msg="$1"
  local code="${2:-1}"
  echo "ERROR: $msg" >&2
  exit "$code"
}

# Usage — single line guard clauses
[[ -f "$config" ]] || die "Config file not found: $config"
[[ $# -ge 2 ]]     || die "Usage: $0 <src> <dst>" 2

A more informative error function that includes the script name and line number:

error() {
  echo "[ERROR] $(basename "$0"):${BASH_LINENO[0]}: $*" >&2
}

warn() {
  echo "[WARN]  $(basename "$0"):${BASH_LINENO[0]}: $*" >&2
}

trap ERR

trap 'handler' ERR fires whenever a command exits with a non-zero code while set -e is active. It gives you a hook to log exactly which command failed and on which line — information that is otherwise lost when the script exits.

#!/usr/bin/env bash
set -euo pipefail

on_error() {
  local exit_code=$?
  local line_no="${BASH_LINENO[0]}"
  local command="${BASH_COMMAND}"
  echo "ERROR: command '${command}' failed with exit code ${exit_code} at line ${line_no}" >&2
}

trap on_error ERR

echo "Starting..."
cp /nonexistent /tmp/   # triggers on_error, then script exits
echo "This never runs"

trap EXIT — Guaranteed Cleanup

trap 'cleanup' EXIT is the most important trap. It runs whenever the script exits — whether it succeeds, fails, or is interrupted. This is how you guarantee that temporary files, lock files, and other resources are always cleaned up, even when things go wrong.

#!/usr/bin/env bash
set -euo pipefail

TMPDIR=""
LOCKFILE="/var/run/myscript.lock"

cleanup() {
  local exit_code=$?
  echo "Cleanup: exit code was $exit_code" >&2
  # Only remove TMPDIR if it was actually created
  [[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"
  rm -f "$LOCKFILE"
}

# Register the trap BEFORE creating any resources
trap cleanup EXIT

TMPDIR=$(mktemp -d)
touch "$LOCKFILE"

# ... do real work ...
echo "Work done"
# cleanup() runs automatically here, whether we reached this line or not

Handling set -e Exceptions

Some commands legitimately return non-zero exit codes that are not errors. grep returns 1 when there are no matches. diff returns 1 when files differ. set -e does not distinguish between “command failed” and “command returned non-zero for a valid reason.” You need to handle these explicitly.

set -e

# grep returns 1 if no match — use || true to say "that's acceptable"
match=$(grep "pattern" file.txt || true)

# diff returns 1 if files differ — acceptable in some contexts
diff file1.txt file2.txt || true

# Use if to check without triggering set -e (conditions are exempt)
if grep -q "pattern" file.txt; then
  echo "Found"
fi

# Temporarily disable set -e for a risky block, then re-enable
set +e
risky_command
result=$?
set -e
if [[ $result -ne 0 ]]; then
  echo "risky_command failed with $result"
fi

Handling Unset Variables

With set -u, accessing an unset variable is a fatal error. This catches a large class of bugs where a typo in a variable name or a missing argument would silently produce an empty string and cause subtle failures downstream.

set -u

echo "$UNDEFINED"   # Error: UNDEFINED: unbound variable — caught immediately

# The fix: provide defaults for variables that are genuinely optional
name="${USERNAME:-anonymous}"
config="${CONFIG_FILE:-/etc/myapp/config.yml}"

# Test before using — the -v test checks if a variable is set
if [[ -v MY_VAR ]]; then
  echo "MY_VAR is set: $MY_VAR"
fi

Robust Argument Parsing

Scripts that accept arguments should validate them thoroughly at the start, before any real work begins. This produces clear error messages instead of confusing failures deep in the script logic.

#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat >&2 <<EOF
Usage: $(basename "$0") [OPTIONS] <input_file>

Options:
  -o, --output FILE   Output file (default: stdout)
  -v, --verbose       Enable verbose output
  -h, --help          Show this help

Examples:
  $(basename "$0") data.csv
  $(basename "$0") -o result.txt -v data.csv
EOF
  exit 2
}

VERBOSE=false
OUTPUT=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    -o|--output)
      [[ $# -gt 1 ]] || die "Option $1 requires an argument"
      OUTPUT="$2"; shift 2 ;;
    -v|--verbose)
      VERBOSE=true; shift ;;
    -h|--help)
      usage ;;
    -*)
      echo "Unknown option: $1" >&2; usage ;;
    *)
      break ;;
  esac
done

[[ $# -eq 1 ]] || usage
INPUT_FILE="$1"
[[ -f "$INPUT_FILE" ]] || die "Input file not found: $INPUT_FILE"

$VERBOSE && echo "Input: $INPUT_FILE"

Timeouts

Scripts that call external services can hang indefinitely if those services are slow or unresponsive. Always set timeouts on network calls and long-running commands to ensure your script does not block a CI pipeline or deployment.

# timeout command — abort if command runs longer than 30 seconds
timeout 30 curl -sf "http://api.example.com/health"

# Custom timeout using a background job and a watchdog
run_with_timeout() {
  local timeout="$1"; shift
  local cmd=("$@")

  "${cmd[@]}" &
  local pid=$!

  # Watchdog: kill the main job after the timeout
  (
    sleep "$timeout"
    kill "$pid" 2>/dev/null
  ) &
  local watchdog=$!

  wait "$pid"
  local result=$?
  kill "$watchdog" 2>/dev/null
  wait "$watchdog" 2>/dev/null
  return $result
}

if ! run_with_timeout 30 ./long_running_task.sh; then
  die "Task timed out or failed"
fi

Retry Logic

Transient failures are common in distributed systems — network blips, services momentarily overloaded, DNS timeouts. A retry function with exponential backoff handles these gracefully without hard-coding sleep values throughout your script.

retry() {
  local max_attempts="$1"
  local delay="$2"
  shift 2
  local cmd=("$@")
  local attempt=1

  until "${cmd[@]}"; do
    if (( attempt >= max_attempts )); then
      echo "ERROR: command failed after $max_attempts attempts: ${cmd[*]}" >&2
      return 1
    fi
    echo "Attempt $attempt failed. Retrying in ${delay}s..." >&2
    sleep "$delay"
    (( attempt++ ))
    (( delay *= 2 ))   # exponential backoff — double the wait each time
  done
}

# Retry curl up to 5 times, starting with a 2-second delay
retry 5 2 curl -sf "http://api.example.com/data" -o output.json

What’s Next

The next tutorial covers regex in Bash — the =~ operator, BASH_REMATCH, and practical pattern matching with grep and sed.

Frequently Asked Questions

What does set -euo pipefail do?
set -e exits on any command failure. set -u treats unset variables as errors. set -o pipefail makes a pipeline fail if any stage fails (not just the last command). Together they catch most common silent failure modes.
Why does set -e cause unexpected exits in my script?
set -e exits on any non-zero exit code. Commands used in conditions (if, while, || checks) are exempt. But commands like grep returning 1 for no match, or cd failing, will exit the script. Be explicit: use || true to allow a command to fail, or check with if.
What should the exit code of my script be?
0 for success, 1 for general errors, 2 for misuse (wrong arguments), and 126/127 for command not found or not executable. Avoid codes above 125 for your own errors as they conflict with signal-related codes.