Skip to content

09 · Exit Codes & Basic Error Handling

🎥 Video walkthrough

Every command returns an exit code

ls /tmp
echo "$?"        # 0 — success

ls /nonexistent
echo "$?"        # 2 (or another non-zero code) — failure

By convention, 0 means success and any non-zero value (1–255) means some kind of failure — the specific non-zero number is often command-specific.

Using exit codes in conditionals

if grep -q "error" app.log; then
    echo "found errors"
else
    echo "no errors found"
fi

-q makes grep "quiet" — no output, it just sets its exit code, which is exactly what an if needs.

mkdir -p /tmp/build && cd /tmp/build && echo "ready to build"
# each step only runs if the previous one succeeded

cp important.txt backup/ || echo "backup copy failed!" >&2
# only runs the echo if cp failed

exit — setting your own script's exit code

#!/usr/bin/env bash

if [[ ! -f "config.yml" ]]; then
    echo "Error: config.yml not found" >&2
    exit 1
fi

echo "config found, continuing..."
exit 0

A script's own exit code (from exit N, or implicitly the last command run) is what the caller of your script sees in $? — important once your scripts are called from other scripts, cron, or CI.

set -e — stop on the first error

#!/usr/bin/env bash
set -e     # exit immediately if any command exits non-zero

mkdir -p /tmp/build
cd /tmp/build
cp /nonexistent/file .    # this fails...
echo "this line never runs"   # ...so set -e stops the script here

Without set -e, a script keeps running after a failed command, silently building on top of a broken state — usually not what you want in anything beyond a quick interactive check.

set -u — catch unset variables

#!/usr/bin/env bash
set -u    # error out if you reference a variable that was never set

echo "$UNDEFINED_VAR"    # bash: UNDEFINED_VAR: unbound variable — script exits

set -u catches typos in variable names early instead of silently substituting an empty string, which is what happens by default.

set -euo pipefail — the standard "strict mode" trio

#!/usr/bin/env bash
set -euo pipefail
# -e: stop on any error
# -u: error on unset variables
# -o pipefail: a pipeline fails if ANY command in it fails, not just the last

grep "error" nonexistent_file.txt | wc -l
# without pipefail, this would still "succeed" (wc always exits 0)
# with pipefail, the failed grep makes the whole pipeline fail

This trio is covered in more depth in Level 2's "Scripting Best Practices," but it's worth adopting from your very first real script.

trap — running cleanup code on exit

#!/usr/bin/env bash

cleanup() {
    echo "cleaning up temp files..."
    rm -f /tmp/myscript.tmp
}

trap cleanup EXIT     # run cleanup no matter HOW the script ends

echo "working..." > /tmp/myscript.tmp
echo "doing work"
exit 0                 # cleanup still runs, because of the trap

trap registers a function or command to run when a given signal (or the special pseudo-signal EXIT) occurs — this guarantees temp files get cleaned up even if the script exits early or hits an error. (Level 3's "Signal Handling" module covers SIGINT/SIGTERM traps for interrupting long-running scripts gracefully.)

A simple, defensive error-handling pattern

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

die() {
    echo "Error: $1" >&2
    exit "${2:-1}"
}

[[ -f "input.csv" ]] || die "input.csv not found"

line_count=$(wc -l < input.csv)
(( line_count > 0 )) || die "input.csv is empty"

echo "processing $line_count lines..."

A small die helper like this keeps error paths short and readable, and sends the message to stderr where it belongs (not stdout, which might be piped into something else).

How It Actually Works

Every process, on exit, reports a single 8-bit integer (0–255) back to its parent through the _exit(2)/exit(2) syscall's status argument. The parent shell retrieves it with wait()/waitpid() and bash stores it in the special variable $?. Because it's only 8 bits, exit codes wrap modulo 256 — a script that does exit 256 actually reports 0, and exit -1 reports 255; this is a hardware/kernel limit inherited from the Unix process model, not a bash quirk.

set -e (errexit) changes bash's own control flow: after each simple command completes, bash checks its exit status and, if nonzero, immediately terminates the shell — but only for commands where bash considers the failure "unhandled." Commands that are already part of an if/while condition, the left side of &&/||, or preceded by ! are exempt, because their exit status is being deliberately tested rather than ignored. This is also why cmd1 | cmd2 under plain set -e doesn't stop the script if cmd1 fails but cmd2 succeeds — the pipeline's reported status is cmd2's exit code unless set -o pipefail is also enabled, which changes bash's internal pipeline-status calculation to return the last nonzero status among all stages (or 0 if all succeeded).

trap ... EXIT registers a handler in bash's internal signal/event table that fires when the shell's own exit sequence begins, regardless of whether it got there via a normal fall-through, an explicit exit, or set -e aborting — because all three paths funnel through the same internal "shell is exiting now" hook before the process finally calls _exit(2).

Cheat sheet

Construct Purpose
$? exit code of the last command
exit N end the script with exit code N
set -e stop the script on the first failing command
set -u error on unset variable references
set -o pipefail a pipeline fails if any stage fails
trap cmd EXIT run cmd when the script exits, always
cmd1 \|\| cmd2 run cmd2 only if cmd1 fails

🔀 See this in another language

Exercise

Write safe_copy.sh with set -euo pipefail at the top that takes a source and destination path as $1/$2, prints a usage message and exits 1 if either is missing, uses a die helper to fail clearly if the source file doesn't exist, and uses trap to print "done" on exit regardless of success or failure.