Bash Error Handling and Writing Robust Scripts

Advanced
13 min

Bash Error Handling and Writing Robust Scripts

By default bash keeps going after a command fails, treats a misspelled variable as an empty string, and hides failures inside pipelines. A script that "worked" can therefore have silently skipped the step that mattered. This lesson shows the options that turn those defaults off, the trap mechanism for cleanup and error reporting, patterns for temporary files and retries, and the tools — shellcheck and set -x — that catch bugs before production does.

The Strict Mode Header

Start every script with these lines, then understand each one.

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

| Option | Effect | |---|---| | set -e | exit immediately when a command fails (non-zero status) | | set -u | treat an unset variable as an error instead of empty | | set -o pipefail | a pipeline fails if any command in it fails, not just the last | | set -E | ERR traps are inherited by functions and subshells | | IFS=$'\n\t' | word-split only on newlines and tabs, not spaces |

Without pipefail, curl https://bad.host | tar xz succeeds because tar exits 0 on empty input. Without -u, rm -rf "$BUILD_DR/" with a typo in the variable name expands to rm -rf /.

Living with set -e

set -e has exceptions you must know. A failing command does not exit the script when it is:

  • part of an if, while or until condition;
  • on the left of && or ||;
  • in a pipeline without pipefail (except the last command);
  • negated with !.

This means commands that are allowed to fail must be written explicitly:

bash
grep -q "pattern" file || true # ignore a missing match if ! systemctl is-active app; then # fine: inside a condition systemctl start app fi count=$(grep -c ERROR app.log || true) # grep -c exits 1 when count is 0

And a subtle trap: local var=$(cmd) and export VAR=$(cmd) never trigger -e, because the status of local/export is what counts. Split them into two lines.

trap: Cleanup and Error Reporting

trap registers a command to run when the shell receives a signal or reaches a pseudo-event. EXIT fires however the script ends, which makes it the right place for cleanup.

bash
tmp=$(mktemp -d) cleanup() { local status=$? rm -rf "$tmp" exit "$status" } trap cleanup EXIT trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR trap 'echo "interrupted"; exit 130' INT TERM

$LINENO and $BASH_COMMAND give a precise location without a debugger. For services that reload on SIGHUP, trap reload_config HUP is the same mechanism.

Defensive Patterns: Temp Files, Prerequisites and Retries

Temporary Files and Atomic Writes

Never invent temp file names; mktemp creates a unique file or directory with safe permissions. Write output to a temp file in the destination directory and mv it into place, because mv within one filesystem is atomic — readers see either the old file or the complete new one.

bash
tmp=$(mktemp /etc/app/config.json.XXXXXX) generate_config > "$tmp" jq empty "$tmp" # validate before replacing mv "$tmp" /etc/app/config.json

Checking Prerequisites Early

Fail before doing any work if a dependency, permission or input is missing.

bash
require() { command -v "$1" > /dev/null || { echo "missing: $1" >&2; exit 127; }; } require jq; require rsync [[ $EUID -eq 0 ]] || { echo "run as root" >&2; exit 77; } [[ -r "$INPUT" ]] || { echo "cannot read $INPUT" >&2; exit 66; } : "${API_TOKEN:?API_TOKEN must be set}"

Retries and Timeouts

Network calls fail transiently. Retry with backoff and cap the wait with timeout.

bash
retry() { local attempts="$1" delay="$2"; shift 2 local n=1 until "$@"; do (( n >= attempts )) && return 1 echo "attempt $n failed, retrying in ${delay}s" >&2 sleep "$delay"; (( n++ )); (( delay *= 2 )) done } retry 5 2 curl -sf https://api.example.com/health timeout 30s ./slow-migration.sh || echo "migration timed out" >&2

Debugging

bash
bash -x script.sh # print every command as it runs set -x; risky_part; set +x # trace only a section PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x script.sh # include file and line in the trace bash -n script.sh # syntax check without executing

shellcheck

shellcheck is a static analyser that catches unquoted variables, useless cat, wrong test operators and dozens of other pitfalls. Run it in CI.

bash
sudo apt install shellcheck shellcheck deploy.sh
bash
In deploy.sh line 12: rm -rf $BUILD_DIR/ ^--------^ SC2086: Double quote to prevent globbing and word splitting.

Each warning carries a code (SC2086) whose full explanation appears in the editor extension and in the project's wiki. Fix every warning or suppress it deliberately with # shellcheck disable=SC2086 and a comment explaining why.

Common Mistakes

  • Adding set -e and assuming every failure is now handled; the exceptions above still apply.
  • Cleaning up with trap ... EXIT but forgetting to preserve $?, so the script always exits 0.
  • Writing directly to the destination file, leaving a truncated file if the script dies mid-way.
  • Silencing errors with 2>/dev/null instead of handling them.
  • Using #!/bin/sh with bash-only features like [[ ]], arrays or pipefail.
Quick Quiz
Question 1 of 3

What does `set -o pipefail` change?

Key Takeaways

  • Start scripts with set -Eeuo pipefail and understand the cases where -e does not apply.
  • trap cleanup EXIT runs however the script ends; trap ... ERR with $LINENO pinpoints failures.
  • Use mktemp for temporary files and write-then-mv for atomic replacement.
  • Validate dependencies, permissions and required variables before doing work; retry network calls with backoff.
  • Run shellcheck on every script and bash -x when behaviour is unclear.

Next lesson: Archives and Compression: tar, gzip and zip — bundle and shrink files for transfer, backups and releases.

Bash Error Handling and Writing Robust Scripts - Linux & Command Line | CodeYourCraft | CodeYourCraft