Bash & the POSIX Shell Module 9 – Robust scripts: errors, traps and debugging
Error-handling patterns for scripts
Send errors to stderr with die and warn, test status with if, || and the negation operator, fail early, and choose exit codes a caller can read.
What you will learn
- Send error messages to stderr while keeping stdout clean for data and pipes
- Test a command's status with if, || and ! instead of saving $? for later
- Validate inputs and required commands before any side effect runs
- Choose exit codes a caller can act on, and summarise batches at the end
Before you start
What you’ll learn · Before you start
What you will learn
- Send error messages to stderr while keeping stdout clean for data and pipes
- Test a command's status with if, || and ! instead of saving $? for later
- Validate inputs and required commands before any side effect runs
- Choose exit codes a caller can act on, and summarise batches at the end
Before you start
On this page
A nightly job ran export.sh | gzip > daily.csv.gz and reported success for a week, yet each morning’s archive was
43 bytes long: export.sh had printed error: database locked on its standard output, where the data goes, and the
pipeline’s status was the 0 of gzip. Nothing in the run said that anything had failed. A few error-handling
patterns put the message on standard error and give the caller an exit status it can act on.
You will write errors to stderr while keeping stdout for data, branch on a command’s status with if, || and !,
validate inputs and required commands before any side effect, choose exit codes a caller can act on, and keep
batches going so the end of the run can list every failure.
Errors go to stderr; data stays on stdout
A shell program reads from standard input, writes data to standard output and writes diagnostics (errors, warnings,
progress) to standard error. The split is not a convention of taste — it is the one that lets cmd | other-cmd
work. The data stays on stdout, which other-cmd reads; the diagnostics still reach the terminal through stderr.
POSIX defines standard error as the stream “for diagnostic output” (its stdin page), and the Google Shell Style
Guide asks that every error message go there.
The one-line form is printf '…' >&2:
#!/bin/bash
# The one-line form for writing an error to standard error.
# Read-only snippet: run it from a bigger script.
printf 'error: no such directory: %s\n' "$dir" >&2
exit 3 >&2 sends the output to file descriptor 2, which is standard error unless the caller redirects it.
Redirection: stdin, stdout and stderr explains redirection in full;
here the one thing to carry is >&2 for every error print.
Printing an error on stdout, so a pipe swallows it
Wrong: echo "error: cannot read $file" followed by exit 1 in a script that is piped into another command.
The next command reads the error as data (or ignores it) and the terminal shows nothing. Right: printf 'error: cannot read %s\n' "$file" >&2, and the error line reaches the terminal even when stdout is captured.
Checkpoint
Check: Errors to stderr; data to stdout
Small helpers for consistent errors: die, warn and a tool check
Two one-line helpers make every error look the same, and need_cmd fails early when a required outside tool is
missing. errs.sh is the whole library:
from session import run, write
# A small library of error helpers. die prints to stderr and exits with a status, warn prints to stderr and returns,
# need_cmd checks for an outside command before any side effect runs. The script that uses them handles the three
# error paths: usage error (exit 2), missing input (exit 3), and record failure (exit 4), with messages a caller can
# read and act on.
write("errs.sh", """# Error helpers for the lesson's small library. source this file.
die() { printf 'error: %s\\n' "$*" >&2; exit "${rc:-1}"; }
warn() { printf 'warning: %s\\n' "$*" >&2; }
need_cmd() {
for c in "$@"; do
if ! command -v -- "$c" >/dev/null 2>&1; then
printf 'error: required command not found: %s\\n' "$c" >&2
exit 1
fi
done
}
""")
write("import.sh", """#!/bin/bash
set -euo pipefail
# shellcheck source=errs.sh
source ./errs.sh
usage() {
printf 'usage: import.sh <records-dir>\\n' >&2
rc=2 die "wrong arguments"
}
[ $# -eq 1 ] || usage
dir=$1
[ -d "$dir" ] || { rc=3 die "no such directory: $dir"; }
need_cmd awk sort
failed=0
# Each file holds one record per line in the form "id,amount". A record whose amount is not above 0 counts as failed.
while IFS= read -r -d '' f; do
while IFS=, read -r id amount; do
if ! awk -v a="$amount" 'BEGIN { exit !(a > 0) }'; then
warn "bad amount in $f: id=$id amount=$amount"
failed=$((failed + 1))
continue
fi
printf 'imported %s %s from %s\\n' "$id" "$amount" "$(basename -- "$f")"
done < "$f"
done < <(find "$dir" -type f -name '*.csv' -print0 | sort -z)
if (( failed > 0 )); then
rc=4 die "$failed record(s) failed; see warnings above"
fi
echo "every record imported"
""")
write("make_records.sh", """mkdir -p records
printf 'A,10\\nB,-5\\n' > records/jan.csv
printf 'C,20\\n' > records/feb.csv
""")
run("""
chmod +x import.sh make_records.sh
# usage error (no argument): exit 2 with a usage message to stderr
!bash import.sh
# missing input (bad path): exit 3
!bash import.sh ./no-such-dir
# normal run with a bad amount in one record: exit 4 with a count
bash make_records.sh
!bash import.sh ./records
# recover by writing the record cleanly and run again: exit 0
printf 'A,10\\nB,5\\n' > records/jan.csv
bash import.sh ./records
""") Output
$ chmod +x import.sh make_records.sh # usage error (no argument): exit 2 with a usage message to stderr $ bash import.sh usage: import.sh <records-dir> error: wrong arguments [exit status 2] # missing input (bad path): exit 3 $ bash import.sh ./no-such-dir error: no such directory: ./no-such-dir [exit status 3] # normal run with a bad amount in one record: exit 4 with a count $ bash make_records.sh $ bash import.sh ./records imported C 20 from feb.csv imported A 10 from jan.csv warning: bad amount in ./records/jan.csv: id=B amount=-5 error: 1 record(s) failed; see warnings above [exit status 4] # recover by writing the record cleanly and run again: exit 0 $ printf 'A,10\nB,5\n' > records/jan.csv $ bash import.sh ./records imported C 20 from feb.csv imported A 10 from jan.csv imported B 5 from jan.csv every record imported
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 die_warn_session.py
die writes error: … to stderr and exits with the status in $rc (default 1), so the caller writes rc=2 die "wrong arguments" on a usage error and rc=3 die "no such directory" on a missing input. warn writes
warning: … to stderr and returns without exiting, so the caller can count the warnings and decide later. The
session’s run showed four separate paths: the usage error with exit 2, the missing input with exit 3, the run
with a bad record ending at exit 4 with the counts, and the clean run after the record was fixed.
need_cmd is the pattern for every outside tool the script relies on: command -v -- "$c" >/dev/null 2>&1 returns 0
when the shell can run the name c, whether it is a builtin, a function or a program on PATH. Checking the tools
before the first side effect is the fail-early rule, which the next section unfolds.
Key idea
A one-line die and a one-line warn are enough for every script in this module: consistent prefixes, errors on
stderr, and an exit code the caller can act on.
Test a command’s status directly instead of saving it
A command’s exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” is a decision, so read it where the decision is made: as the
condition of if (or while or until), on the left of || or &&, or after !. Those are also the places
where set -e looks away, so a failure read there is handled instead of ending the script. Testing $? a few lines
later is the slip ShellCheck’s SC2181 names, because every command in between sets $? to its own status.
from session import run, write
# Four patterns for reading an exit status directly, and the slip ShellCheck's SC2181 names: saving $? and testing it
# several lines later, when another command has already overwritten it.
write("status.sh", """#!/bin/bash
set -euo pipefail
# 1) Branch with "if cmd": the status is the condition; no "$? -eq 0" needed.
if grep -q error today.log; then
echo "today has errors"
else
echo "today is clean"
fi
# 2) Short-circuit with ||: handle the failure in-line and keep errexit on.
cd ./workspace || { echo "cannot enter workspace" >&2; exit 3; }
echo "in workspace: $PWD"
cd - >/dev/null
# 3) Negated condition with !: run a branch when the command fails.
if ! command -v nosuch_tool >/dev/null 2>&1; then
echo "nosuch_tool is not installed; falling back to the shell builtin"
fi
# 4) Keep the number when it matters. Under set -e a plain "grep ...; rc=$?" would end the script when grep
# returns 1, so the status is saved on the right of ||, where errexit looks away.
rc=0
grep -q panic today.log || rc=$?
case $rc in
0) echo "today has panic lines" ;;
1) echo "today has no panic lines (grep returned 1)" ;;
*) echo "grep itself failed with status $rc" >&2 ;;
esac
""")
write("sc2181.sh", """#!/bin/bash
# The SC2181 anti-pattern: save $? and test it several lines later. By then another command has reset $?.
# today.log holds "warn:" (lowercase). grep -q WARN returns 1 (no match), so the real answer is "no WARN".
grep -q WARN today.log
echo "about to test the status"
touch marker
if [ $? -ne 0 ]; then
echo "no WARN found"
else
echo "WARN found"
fi
""")
write("today.log", "fine\nwarn: cache expired\nerror: upstream 503\nfine\n")
run("""
chmod +x status.sh sc2181.sh
mkdir -p workspace
cat today.log
bash status.sh
echo "---the SC2181 anti-pattern:---"
bash sc2181.sh
!shellcheck sc2181.sh
""") Output
$ chmod +x status.sh sc2181.sh
$ mkdir -p workspace
$ cat today.log
fine
warn: cache expired
error: upstream 503
fine
$ bash status.sh
today has errors
in workspace: ~/demo/workspace
nosuch_tool is not installed; falling back to the shell builtin
today has no panic lines (grep returned 1)
$ echo "---the SC2181 anti-pattern:---"
---the SC2181 anti-pattern:---
$ bash sc2181.sh
about to test the status
WARN found
$ shellcheck sc2181.sh
In sc2181.sh line 7:
if [ $? -ne 0 ]; then
^-- SC2181 (style): Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?.
For more information:
https://www.shellcheck.net/wiki/SC2181 -- Check exit code directly with e.g...
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 status_session.py
The four patterns the session shows are the whole kit:
if cmd; then …; else …; fi— the condition is the command. No[ $? -eq 0 ]; it would read the same two values ascmditself.cmd || { handler; exit 3; }— a short-circuit that handles the failure on the spot, withset -estill on. ShellCheck SC2164 reports a barecd "$dir"and asks for the|| exitform, which keeps acdthat failed from running the next line in the wrong folder.if ! cmd; then …; fi— the negation.!inverts the status and lets a branch run on the failure.rc=0; cmd || rc=$?; case $rc in …— for the case where the number itself matters. Underset -e, a plaincmd; rc=$?never reaches the second command whencmdfails; on the right of||the status is saved instead, and the session’sgrep -q panicended withtoday has no panic lines (grep returned 1).
The anti-pattern in sc2181.sh was a short run with grep -q WARN today.log (no match, $? was 1), then an
echo and a touch ($? reset to 0), then if [ $? -ne 0 ]; then echo "no WARN"; else echo "WARN found"; fi. The
test read 0 -ne 0, which is false, so the script printed “WARN found” even though the log did not contain WARN.
ShellCheck 0.11.0 reports this as SC2181.
Checkpoint
Check: Test status with if, || and !, not $? saved
Fail early: validate inputs before any side effect
A failure that stops a script after it has already written half the output is harder to clean up than a failure
before any output. The order of the first lines of import.sh is the whole pattern — check the arguments, the
input, and the required commands, then start work:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Usage error, exit 2. Called with no argument, import.sh fails its first check. usage printed "usage: import.sh <records-dir>" and rc=2 die printed "error: wrong arguments", both on stderr, and the session showed status 2.
- Missing input, exit 3. With one argument that names no folder, the second check fails: die printed "error: no such directory: ./no-such-dir" and the script ended with status 3, a code of its own for a wrong input.
- Missing tool, exit 1. The third check runs need_cmd awk sort before any record is read. Had either tool been missing, it would have named the command on stderr and exited 1. The session had both, so it went on.
- Bad records, exit 4. The loop read every record, printed the good ones on stdout and sent one warning to stderr for B, whose amount -5 is not above 0. At the end rc=4 die reported "1 record(s) failed" and the status was 4.
- Clean run, exit 0. After B was fixed, the same call printed three imported lines on stdout, nothing on stderr, and "every record imported", with status 0. Every way out is on the right, and only the clean run reaches the bottom.
Three checks come before the first record is read. When the argument count is wrong, usage prints the one-line
usage message and dies with 2. When the directory does not exist, die prints the path and dies with 3. When a
required command is missing, need_cmd prints its name and exits with 1. In each case no record is processed and
stdout stays empty, so the caller’s pipe reads nothing instead of half a result.
The habits the pattern names are small:
[ $# -eq 1 ] || usage— the argument count is the first thing. Bash’s own builtins return 2 for incorrect usage (the Bash manual, Exit Status), andimport.shfollows them withrc=2 die.[ -d "$dir" ] || die "no such directory: $dir"— the input is the second.-dchecks for a directory;-efor any kind of file;-rfor a readable file.need_cmd awk sort— every outside tool is checked once, at the top, so a run that misses a tool fails before any record is written.
Fail-early also applies inside the loop: when a record is bad, the loop collects it as a failure instead of writing a half-record. The next section covers that half of the pattern.
Checkpoint
Check: Validate inputs and tools before any side effect
Choose exit codes on purpose, and summarise batches
Exit codes are the one thing a caller reads without any output. 0 means success and every other value means some
kind of failure, which each program defines for itself: POSIX grep exits 0 when a line matched, 1 when none did
and more than 1 on an error, and Bash’s builtins return 2 for incorrect usage. A script should choose its codes on a
scale like that and document them, so a caller can branch on the number:
| Code | Meaning | When to use |
|---|---|---|
| 0 | Success | Everything worked |
| 1 | General failure | An unexpected error, no better code fits |
| 2 | Usage error | Wrong arguments, missing option, bad format |
| 3+ | Specific failure | One per class the caller may want to retry or ignore |
| 126 | Could not execute | The shell found the file but could not run it |
| 127 | Command not found | The shell could not find a command on PATH |
| 128 + N | Killed by signal N | 130 Ctrl-C, 141 SIGPIPE, 143 TERM (earlier lessons) |
Statuses above 125 already mean something to a calling shell (the Bash manual: “the shell may use values above 125
specially”): 126 and 127 for a command that could not run, 128 + N for one killed by signal N. Keep a script’s own
codes at 125 or below. And an exit code is a reason, not a count: the GNU Coding Standards warn that a status is 8
bits, so a count of 256 errors would arrive as 0 and read as success. import.sh picked 2, 3 and 4: a usage error (rc=2 die), a
missing input (rc=3 die) and record failures (rc=4 die). A later wrapper could then retry on 4 (data problem)
without retrying on 2 (bug in the call).
For a batch, keeping going is almost always better than stopping at the first failure. Record the id, the file and
the reason in an array; at the end, print a summary and die with the right code:
#!/bin/bash
# A batch pattern: collect failures as the loop runs, then summarise and exit non-zero.
# Read-only snippet; die, warn and process are expected to come from the script's own helpers.
failed=()
for record in "${records[@]}"; do
if ! process "$record"; then
failed+=("$record")
warn "failed: $record"
fi
done
if (( ${#failed[@]} > 0 )); then
printf 'summary: %d/%d records failed\n' "${#failed[@]}" "${#records[@]}" >&2
printf ' %s\n' "${failed[@]}" >&2
rc=4 die "see the summary above"
fi The caller sees exit 4 and the summary on stderr; the stdout stream carries the records that did work. A rerun of the batch only needs the ids in the summary, not every record.
Checkpoint
Check: Exit codes on purpose, and batch summaries
Practise: writing the right error path
The questions start from the two sessions and change one thing: where an error is written, a status read too late, a check that comes after the work, a code a caller must act on. Decide the outcome before you check:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why do errors go to stderr instead of stdout, and how is >&2 read?
A program’s standard output is the data that the next command of a pipe, or a file, receives; standard error is a
separate stream that stays on the terminal unless the caller redirects it. Writing errors to stderr keeps the
pipeline working: a user who runs script | jq sees the data reach jq and the error reach the screen, instead of
the error turning into unparseable input for jq. >&2 means “send this command’s output to file descriptor 2”,
which is standard error (1 is standard output and 0 standard input; POSIX numbers them this way). So
printf 'error: %s\n' "$msg" >&2 writes the formatted line to stderr, and a caller can keep the two streams apart
with 2>errors.log.
Two questions follow: an import that reported success with records missing, and a build step that worked in the wrong folder although every status was 0. Note what you would look at first in each before opening it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A nightly import reads records.csv with while IFS=, read -r id amount && [ -n "$id" ]; do import_one; done. It ran green for weeks. The night the export held a blank line after its third record, the job imported 3 records, skipped the 180 below it, and still exited 0. Explain why, and make such a shortfall impossible to miss.
-
Mid to senior
A deploy job runs cd build && make install from its checkout. On one CI runner it installed another project's files: the job log showed an unexpected absolute path just before make ran, every status was 0, and ShellCheck reports nothing. cd did not fail. What happened, and how do you make the script immune?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why do error messages go to standard error?
Standard output is the data the next command or file receives. With errors on standard error, script | jq still gives jq clean data while the error stays on the screen.
-
What do the die and warn helpers of a script do?
die writes error: and the message to standard error and exits with the status in rc, 1 by default, so rc=2 die "wrong arguments" marks a usage error. warn writes warning: and the message to standard error and returns without exiting.
-
Where should a script read a command's status?
Where the decision is made: if cmd, cmd || ..., ! cmd, or rc=0; cmd || rc=$? when the number matters. Testing $? after another command has run is ShellCheck's SC2181 slip.
-
Why does cmd; rc=$? never record a failure in a set -e script?
The failing cmd stops the script before rc=$? runs. Write rc=0; cmd || rc=$? so the failure is handled and its status kept.
-
What should a script check before its first side effect?
The argument count, the input and every command it needs, so a broken call stops before it leaves any partial state behind.
-
Which exit codes should a script choose for its own failures?
2 for a usage error, as Bash's builtins use it, and 3 and up for failures of its own, kept at 125 or below, since callers read 126, 127 and 128 plus N as could not run or killed by a signal.
-
How should a script handle failures in a batch of items?
Keep going, collect the failed items in an array, print a summary at the end and then exit non-zero, so the caller can retry only the failures.
Key takeaways
- Errors go to stderr (
>&2) so stdout stays a clean data stream for pipes and redirects; a one-linedieand a one-linewarnare enough for every script in this module. - Read a command’s status where the decision is made:
if cmd,cmd || …,! cmd, andrc=0; cmd || rc=$?when the number matters. Testing$?after another command has run is ShellCheck’s SC2181 slip; the status is gone by then. - Fail early: check the argument count, the input and every required command before the first side effect, so a broken call leaves no partial state.
- Choose exit codes a caller can act on: 2 for a usage error, as Bash’s builtins do, and 3 and up for failures of your own, kept at 125 or below, because a calling shell reads 126, 127 and 128 + N as “could not run” and “killed by a signal”.
- For a batch, keep going, collect the failures in an array, summarise them at the end, then
diewith the right code: the caller can retry only the failures without re-running the whole set.
References
- Bash Reference Manual: Exit Status (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.8 Exit Status and Errors) (The Open Group and IEEE)
- POSIX.1-2024: stdin, stdout, stderr (standard I/O streams) (The Open Group and IEEE)
- POSIX.1-2024: grep (exit status) (The Open Group and IEEE)
- GNU Coding Standards: Writing Robust Programs (Free Software Foundation)
- ShellCheck wiki: SC2164 (use cd ... || exit in case cd fails) (ShellCheck)
- ShellCheck wiki: SC2181 (check status directly, not with $?) (ShellCheck)
- Google Shell Style Guide (Google)
Report a problem with this lesson
Kept only in this browser. Your Learn progress