Writing Robust Bash Scripts: set -euo pipefail, Traps, and Exit Codes
AI generated
Bash · Shell Scripting · Error Handling · DevOps
Writing Robust Bash Scripts
Using set -euo pipefail, Traps, and Exit Codes Correctly

Many shell scripts fail silently: a command returns exit code 1, the pipeline keeps going, and monitoring sees nothing. set -euo pipefail, ERR traps with LINENO, and clean exit codes are the foundation of every Bash script that needs to run reliably in production and react instantly and traceably when something goes wrong.

12 min read set -euo pipefail · ERR trap · LINENO · Exit Codes · pipefail Bash 4.x · 5.x · Linux · macOS

1. Why Silent Failures Are the Biggest Problem in Bash Scripts

The most dangerous error in a Bash script is not the one that crashes it: it is the one that goes unnoticed. Without explicit error handling, a shell script keeps running by default even when a command exits with code 1 or higher. In practice this means a backup script that compresses nothing yet still prints "Backup successful" at the end. Or a deployment script that skips a critical step because a preparation command failed. In both cases monitoring sees exit code 0, everything green, even though the operation failed completely.

Writing robust Bash scripts primarily means configuring the shell to react loudly and immediately to failures instead of quietly carrying on. The combination of set -euo pipefail, an ERR trap that logs the failure location using LINENO, and clean exit codes for every possible outcome is the foundation without which no script can run reliably in production. This article analyzes each of these building blocks in detail, including the often unknown limits and pitfalls.

The good news: this kind of error handling costs virtually no performance and adds only minimal effort to write. The bad pattern, no error handling at all where failures get lost in the noise, costs hours of debugging once something goes wrong in production. Writing robust Bash scripts is not optional, it is mandatory for any shell code that runs automated and unattended.

2. set -e: Immediate Abort on Non-Zero Exit Codes

The set -e flag (long form: set -o errexit) tells the shell to terminate the script immediately whenever a simple command returns a non-zero exit code. Without set -e, Bash ignores failed commands by default, a behavior that makes sense for interactive shells but leads to hard-to-trace error states in automated scripts. Anyone who wants to write robust Bash scripts starts every file with this option.

Important: set -e only applies to simple commands in certain contexts. Inside an if condition, after &&, and after ||, a non-zero exit code is not treated as an error but as a conditional result: set -e deliberately does not trigger there. This is part of the POSIX specification, not a Bash weakness. Anyone unaware of this distinction ends up writing scripts that behave incorrectly even though set -e is set: if failing_command; then does not terminate the script, because the exit code is evaluated as the condition.

A common source of confusion is function calls inside conditions. if my_function; then also disables set -e inside the function for the duration of the call. This behavior changed in Bash 4.0, but older versions behave differently. For maximum predictability when writing robust Bash scripts, write conditions with explicit return value checks instead of relying on set -e to cover every failure case.

3. set -u: Treating Unset Variables as Hard Errors

The set -u flag (long form: set -o nounset) treats any expansion of an unset variable as an error. Without this option, Bash silently expands unset variables to an empty string, often with catastrophic consequences. The classic example: rm -rf "$TARGET_DIR/" effectively becomes rm -rf / once TARGET_DIR is empty. With set -u the script aborts with a clear error message before the command ever runs.

When writing robust Bash scripts with set -u, you still need to account for default values on optional parameters. ${variable:-default} and ${variable:?error message} both work correctly even with set -u: the default expansion returns the fallback value without triggering an error, while the error expansion :? explicitly prints its own message and exits the script, which is ideal for required parameters. Arrays have a special rule: ${array[@]} on an empty array triggers an error under set -u; the variant ${array[@]+"${array[@]}"} is the safe alternative.


#!/usr/bin/env bash
# robust-foundation.sh - Complete error handling foundation
set -euo pipefail
IFS=$'\n\t'

# --- Mandatory environment variables with set -u ---
# :? aborts with custom message if variable is missing or empty
readonly DEPLOY_ENV="${DEPLOY_ENV:?Variable DEPLOY_ENV must be set (e.g. staging, production)}"
readonly DEPLOY_TARGET="${DEPLOY_TARGET:?Variable DEPLOY_TARGET (hostname or path) must be set}"

# Optional variables with safe defaults, compatible with set -u
readonly LOG_LEVEL="${LOG_LEVEL:-INFO}"
readonly DRY_RUN="${DRY_RUN:-0}"
readonly MAX_RETRIES="${MAX_RETRIES:-3}"

# Safe empty-array expansion, avoids set -u error on empty array
declare -a extra_flags=()
all_args=("--env" "$DEPLOY_ENV" "${extra_flags[@]+"${extra_flags[@]}"}")

echo "Deploying to $DEPLOY_TARGET in $DEPLOY_ENV mode"
echo "Args: ${all_args[*]}"

4. set -o pipefail: Don't Lose Errors in Pipes

Without set -o pipefail, the exit code of the last command in a pipe determines the overall status, regardless of whether earlier commands failed. This is one of the trickiest problems when writing robust Bash scripts. Take the example generate_data | process | store: if generate_data fails with exit code 1, store may still return exit code 0, and set -e without pipefail sees no error at all. With set -o pipefail, the pipe's exit code becomes the maximum of every exit code in the chain.

The PIPESTATUS variable holds the individual exit codes of every command in a pipeline as an array, and it is useful for precise error diagnostics even without pipefail. ${PIPESTATUS[0]} is the exit code of the first command, ${PIPESTATUS[1]} the second, and so on. Combined with pipefail and an ERR trap, writing robust Bash scripts lets you pinpoint exactly where in a pipeline a failure occurred. Watch out: PIPESTATUS gets overwritten by every new command, so save it to a variable immediately after the pipeline.

5. ERR Trap and LINENO: Pinpointing Exactly Where Errors Happen

The ERR trap runs before set -e terminates the script, which makes it the ideal place for detailed error logging. Combined with the Bash variables LINENO (current line number), BASH_LINENO (array of line numbers in the call stack), and FUNCNAME (array of function names), writing robust Bash scripts lets you print a full stack trace for every failure. That makes debugging long scripts noticeably faster.

The ERR trap does not automatically receive the exit code of the failed command; it has to be read via $?. That is an important detail: $? inside the trap handler still holds the exit code of the failed command before the trap runs. This value should be saved to a local variable right away, since it gets overwritten by any further commands inside the trap handler. The combination of line number, function name, and exit code delivers complete error context for every failure in the script.


#!/usr/bin/env bash
# err-trap-demo.sh - ERR trap with full stack trace and LINENO
set -euo pipefail
IFS=$'\n\t'

# --- Logging helpers ---
readonly TS_FORMAT="%Y-%m-%dT%H:%M:%S"
log_info()  { printf "[%s] [INFO]  %s\n" "$(date +"$TS_FORMAT")" "$*"; }
log_error() { printf "[%s] [ERROR] %s\n" "$(date +"$TS_FORMAT")" "$*" >&2; }

# --- ERR trap: captures exit code, line, function stack ---
on_error() {
  local exit_code=$?
  local line_number=${1:-$LINENO}

  log_error "--- Script failed ---"
  log_error "Exit code : $exit_code"
  log_error "Line      : $line_number"

  # Build call stack from BASH_LINENO / FUNCNAME arrays
  local i
  for ((i = 1; i < ${#FUNCNAME[@]}; i++)); do
    log_error "  at ${FUNCNAME[$i]}() line ${BASH_LINENO[$((i - 1))]}"
  done
}

# Pass current line number into the trap handler at registration time
trap 'on_error $LINENO' ERR

# --- EXIT trap: always runs, cleanup resources ---
cleanup() {
  local exit_code=$?
  [[ -n "${TMP_FILE:-}" ]] && rm -f "$TMP_FILE"
  log_info "Script finished with exit code $exit_code"
}
trap cleanup EXIT

# --- Example: create temp file safely ---
TMP_FILE="$(mktemp /tmp/deploy.XXXXXX)"
log_info "Working with temp file: $TMP_FILE"

# This line will trigger the ERR trap and show the exact line number
false   # intentionally failing command, line 42

6. EXIT Trap: Cleanup for Every Termination Scenario

The EXIT trap runs whenever the script terminates: through normal completion, through set -e, through an explicit exit N, or through a received signal. That makes it the most reliable way to release resources and perform cleanup. When writing robust Bash scripts, an EXIT trap is one of the first things implemented right after set -euo pipefail. Lock files, temporary files, open file descriptors, and mounted directories can all be cleaned up reliably this way, no matter how the script ends.

An important detail: multiple trap commands for the same signal overwrite each other, the last one wins. Anyone writing modular scripts with library functions needs to coordinate traps carefully. The usual pattern is a single central cleanup function that contains every cleanup step and is registered exactly once for EXIT. Alternatively, you can inspect existing traps with trap -p EXIT and prepend the new action. The EXIT trap receives the script's actual exit code in $?, so the cleanup function can distinguish between a normal end and a failure and send different notifications accordingly.

Signals like SIGINT and SIGTERM need their own traps, because although the EXIT trap still runs afterward, the original signal semantics get lost otherwise. The correct pattern when writing robust Bash scripts: map SIGINT and SIGTERM to a handler function that performs a clean exit with the correct exit code (128 + signal number), and leave the actual cleanup work to the EXIT trap.

7. Exit Codes: Meaning, Conventions, and Custom Codes

Exit codes are the only communication channel between a shell script and whoever called it, whether that is a CI pipeline, a cron job, a monitoring system, or a parent script. Exit code 0 means success. Exit code 1 is the generic error. Exit codes 2 through 125 are conventionally reserved for application-specific errors. Exit codes 126 and 127 are reserved by the shell for "not executable" and "not found". Exit codes 128+N mean the script was terminated by signal N. When writing robust Bash scripts, using these conventions cleanly matters a great deal for integration with monitoring systems.

Custom exit code constants make scripts self-documenting and considerably ease error diagnosis. readonly E_CONFIG_MISSING=10; readonly E_NETWORK_ERROR=11; readonly E_PERMISSION_DENIED=12: when monitoring receives exit code 11 it immediately knows there is a network problem, without reading the log. These constants should be defined at the top of the script and mapped to meaningful messages inside the cleanup function. Scripts with documented exit codes are noticeably easier to integrate into automation frameworks and alerting systems.


#!/usr/bin/env bash
# exit-codes.sh - Documented exit codes for monitoring integration
set -euo pipefail

# --- Exit code constants (2-125 are application-specific) ---
readonly E_OK=0
readonly E_GENERAL=1
readonly E_CONFIG_MISSING=10
readonly E_NETWORK_TIMEOUT=11
readonly E_PERMISSION_DENIED=12
readonly E_LOCK_ACQUIRED=13
readonly E_VALIDATION_FAILED=14

# --- Cleanup with exit code context ---
cleanup() {
  local code=$?
  case $code in
    $E_OK)               echo "[INFO] Deployment completed successfully" ;;
    $E_CONFIG_MISSING)   echo "[ALERT] Deployment aborted: configuration missing" >&2 ;;
    $E_NETWORK_TIMEOUT)  echo "[ALERT] Deployment aborted: network timeout" >&2 ;;
    $E_LOCK_ACQUIRED)    echo "[WARN] Another deployment is already running" >&2 ;;
    *)                   echo "[ERROR] Deployment failed with unexpected code $code" >&2 ;;
  esac
}
trap cleanup EXIT

# --- Acquire exclusive lock ---
exec 9>/var/lock/deploy.lock
flock -n 9 || exit $E_LOCK_ACQUIRED

# --- Config validation ---
[[ -f "/etc/deploy/config.env" ]] || exit $E_CONFIG_MISSING
# shellcheck source=/dev/null
source /etc/deploy/config.env

# --- Network reachability check ---
curl --silent --max-time 5 --head "$DEPLOY_HOST" > /dev/null \
  || exit $E_NETWORK_TIMEOUT

echo "All checks passed, starting deployment"
exit $E_OK

8. Pitfalls: When set -euo pipefail Doesn't Help

The biggest misconception when writing robust Bash scripts with set -euo pipefail is that many developers believe it makes their error handling complete. It does not. There are a number of contexts where non-zero exit codes deliberately do not trigger these flags. Besides conditional contexts (if, while, until) and after logical operators (&&, ||), this also applies to commands inside arithmetic expressions (( )) and test expressions [[ ]], where a non-zero result is semantically not an error.

A particularly sneaky case: local result=$(failing_command). The local builtin always returns exit code 0, no matter what the subshell does, because the declaration itself succeeded. As a result the failure of failing_command is completely swallowed and set -e never triggers. The correct pattern when writing robust Bash scripts: split the declaration and the assignment into two separate lines. local result; result="$(failing_command)": this way set -e applies to the assignment, not the declaration. ShellCheck (SC2155) explicitly warns against the combined pattern.

Another pitfall: set -e is not automatically inherited by subshells started with ( ). In a $(command) substitution, however, the flag is inherited. For subshell blocks, set -e has to be repeated explicitly. Anyone including scripts via source inherits the flags of the calling script, but library files meant to be executed directly must set the flags themselves. These nuances make writing robust Bash scripts demanding, but understanding these limits is a prerequisite for correct error handling.

9. Error Handling Patterns Compared

The different approaches to error handling in Bash differ considerably in robustness, readability, and maintenance effort. When writing robust Bash scripts, choosing the right pattern for each context is what makes the difference.

Context Unsafe Pattern Robust Pattern Why
Variable expansion local x=$(cmd) local x; x="$(cmd)" local always returns 0 (SC2155)
Required environment variable if [ -z "$VAR" ]; then exit 1; fi ${VAR:?error message} Shorter, clear message, set -u compatible
Cleanup logic rm -f $tmp; exit 0 trap 'rm -f "$tmp"' EXIT Runs on error and signal too
Pipe errors cmd1 | cmd2; echo $? set -o pipefail; cmd1 | cmd2 cmd1 failure not masked by cmd2
Logging the failure location echo "Error" >&2; exit 1 trap 'on_error $LINENO' ERR Automatic stack trace with line number

The patterns in the table complement each other, they are not an either-or choice. A fully robust script combines all five: set -euo pipefail as the base, :? for required parameters, local x; x="$(cmd)" for variable declarations, an ERR trap for locating failures, and an EXIT trap for cleanup. Anyone who learns to write robust Bash scripts internalizes these patterns as the default, not the exception.

Mironsoft

Shell automation, DevOps tooling, and deployment infrastructure

Bash scripts that react instantly when something fails?

We analyze existing shell scripts, identify silent failure points, and implement complete error handling with set -euo pipefail, ERR traps, LINENO logging, and documented exit codes.

Failure Analysis

Identify and prioritize silent failures in existing scripts

Trap Framework

Build ERR and EXIT traps with stack traces and monitoring integration

Exit Code Documentation

Define and integrate custom exit codes for every failure state

10. Summary

Writing robust Bash scripts starts with understanding that Bash ships with error-tolerant defaults out of the box: helpful for an interactive shell, dangerous in automated scripts. set -euo pipefail fundamentally changes these defaults: -e aborts on a non-zero exit code, -u treats unset variables as errors, -o pipefail propagates errors through pipes. The ERR trap combined with LINENO and FUNCNAME delivers complete stack traces for every failure. The EXIT trap cleans up resources for every termination scenario. Documented exit codes make scripts integrable into monitoring systems.

Knowing the limits matters just as much as knowing the tools: set -e does not apply in conditional contexts, not after || and &&, and not with local x=$(cmd). Ignoring these pitfalls leads to scripts that feel more robust than they actually are. Combining the patterns described here with ShellCheck as a static analyzer in the CI pipeline is the complete approach to writing robust Bash scripts that actually hold up in production.

Robust Bash Scripts: The Essentials at a Glance

set -euo pipefail

The foundation: -e aborts on error, -u turns unset variables into an error, -o pipefail propagates errors through pipes. Mandatory in every production script.

ERR Trap + LINENO

trap 'on_error $LINENO' ERR delivers stack traces with line number and function chain for every failure, indispensable for debugging in production.

EXIT Trap

trap cleanup EXIT runs on normal completion, errors, and signals. Reliably clean up lock files, temp files, and connections without duplicating logic in every exit path.

Exit Code Conventions

Document custom exit codes (2-125) for failure states. Monitoring systems can identify the failure type instantly, without parsing logs.

11. FAQ: Writing Robust Bash Scripts

1What exactly does set -euo pipefail do?
-e aborts on non-zero, -u turns unset variables into an error, -o pipefail propagates errors through pipes. Together they prevent silent failure in the three most common failure scenarios.
2Why doesn't set -e trigger inside if conditions?
In conditional contexts non-zero isn't an error, it's a conditional result. POSIX semantics. The same applies after || and &&. Guard explicitly with || { exit 1; }.
3What is the ERR trap?
Runs before the set -e abort. Ideal for error logging with LINENO and FUNCNAME. $? still holds the failed command's exit code, save it immediately.
4Why is local x=$(cmd) dangerous?
local always returns 0. The failure of cmd gets swallowed, set -e never triggers. Correct: local x; x="$(cmd)", two separate lines (ShellCheck SC2155).
5Getting the exit code in the EXIT trap?
$? in the EXIT trap holds the script's final exit code. That lets you distinguish between success (0) and different failure types and react accordingly.
6Which exit codes are reserved for custom codes?
2-125 for application-specific errors. 0 = success, 1 = general, 126 = not executable, 127 = not found, 128+N = terminated by signal N.
7Are flags inherited by subshells?
$()-subshells inherit the flags. ( )-blocks inherit them too but can override them. Library files that get executed directly must set the flags themselves.
8What is PIPESTATUS?
Array with exit codes of every pipeline command. Overwritten by the next command, save it immediately. Useful for precise error diagnosis even without pipefail.
9Coordinating multiple trap definitions?
A second trap overwrites the first. Best practice: one central cleanup() function, registered once with trap cleanup EXIT. Use trap -p EXIT to inspect the current trap.
10How do I test error handling systematically?
false inserted and checked whether it aborts. false | true tests pipefail. ShellCheck and BATS tests are the systematic approach for full test coverage.