Code of the Day
IntermediateScripting patterns

Exit Codes

Use $?, set -e/u/pipefail, trap ERR and EXIT to write robust scripts that fail loudly and clean up after themselves.

BashIntermediate10 min read
Recommended first
By the end of this lesson you will be able to:
  • Read and act on exit codes with $?
  • Enable strict mode with set -euo pipefail
  • Register cleanup logic with trap EXIT
  • Catch unexpected errors with trap ERR
  • Explain why pipefail matters for pipeline reliability

A command that fails silently is worse than a command that fails loudly. The default Bash behaviour is to keep running even when a command exits with an error — which means a script can reach the end having done half its job and destroyed something along the way. Exit codes and signal traps are the mechanism Bash provides to turn silent failures into explicit errors.

Exit codes: $?

Every command exits with a numeric code. 0 means success; any non-zero value means failure. The variable $? holds the of the most recently executed command:

ls /etc/hosts
echo "Exit code: $?"    # 0 — file exists

ls /nonexistent
echo "Exit code: $?"    # 2 (or similar) — file not found

Conditional logic is built on exit codes — if, &&, and || all operate on them:

if grep -q "ERROR" app.log; then
  echo "Errors found"
fi

mkdir -p /tmp/work && echo "Directory ready"
cp config.json /tmp/work || { echo "Copy failed" >&2; exit 1; }

set -e: exit on error

set -e (or set -o errexit) causes the script to exit immediately when any command returns a non-zero exit code:

#!/usr/bin/env bash
set -e

cd /nonexistent   # script exits here with an error
echo "This never runs"

Without set -e, the script would continue past the failed cd, potentially running subsequent commands in the wrong directory. This is a common source of subtle bugs in deployment and backup scripts.

set -e has edge cases. It does not trigger inside if conditions, while/until conditions, or commands that are negated with !. It also does not catch failures in the middle of a pipeline — that requires set -o pipefail (below). Know these limits and test your error paths.

set -u: fail on unset variables

set -u (or set -o nounset) causes the script to exit when you expand an unset variable:

#!/usr/bin/env bash
set -u

echo "User is: $UNDEFINED_VAR"   # exits: UNDEFINED_VAR: unbound variable

This catches typos in variable names immediately, rather than letting them silently expand to empty strings and cause confusing downstream failures.

Use a default value when a variable is legitimately optional:

output_dir=${OUTPUT_DIR:-/tmp/output}

set -o pipefail

By default, a pipeline's exit code is the exit code of the last command in the pipeline. This means earlier failures are silently swallowed:

# Without pipefail, this exits 0 because sort succeeds:
cat /nonexistent | sort
echo "Exit: $?"   # 0 — the failure is invisible

set -o pipefail changes this: the pipeline exits with the code of the rightmost failing command:

set -o pipefail
cat /nonexistent | sort
echo "Exit: $?"   # 1 (or 2) — cat's failure is surfaced

In practice, use all three together at the top of every production script:

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

trap EXIT: guaranteed cleanup

trap registers a command (or function) to run when the script exits — whether normally, on error, or when interrupted. Use it to clean up temporary files:

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

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

# Work in tmpdir — cleanup runs no matter how the script ends
cp config.json "$tmpdir/"
process_files "$tmpdir"

Without trap EXIT, a script killed by Ctrl-C or an error would leave the temp directory behind.

trap ERR: log unexpected failures

trap ERR fires when any command fails (subject to the same conditions as set -e). Use it to log context before the script exits:

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

on_error() {
  echo "[ERROR] Script failed at line $LINENO" >&2
}
trap on_error ERR

# ... rest of script

$LINENO expands to the current line number, making it easy to locate the failure.

Combine trap ERR and trap EXIT. ERR fires first (logs the error), then EXIT fires (cleans up). Multiple traps can be stacked on the same signal by building up the trap command, or by calling a function from the trap that handles multiple concerns.

Check your understanding

  1. 1.
    Without set -o pipefail, what is the exit code of: cat /missing_file | sort
  2. 2.
    You want a temp directory to be deleted whenever your script exits, including on Ctrl-C. Which trap signal should you use?
  3. 3.
    With set -u, the expansion ${VAR:-default} causes the script to exit if VAR is unset.

Do it yourself

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

tmpdir=$(mktemp -d)
trap 'echo "Cleaning up $tmpdir"; rm -rf "$tmpdir"' EXIT

echo "Working in $tmpdir"
echo "hello" > "$tmpdir/test.txt"
cat "$tmpdir/test.txt"

# Uncomment to see exit-on-error in action:
# cat /nonexistent

Save this as test-strict.sh, make it executable (chmod +x test-strict.sh), and run it. Then uncomment the failing line and run again to see the error exit.

Where to go next

You can now write scripts that fail loudly and clean up reliably. The Scripting patterns lab is next — apply everything from conditionals through exit codes in hands-on quiz challenges, then move on to Text processing to learn grep, sed, and awk.

Finished reading? Mark it complete to track your progress.

On this page