| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10coreutils 9.5-8.el10_2curl 8.12.1-2.el10nmap-ncat 7.92-5.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; examples use files under /tmp |
| Scope | Bash error handling in scripts: direct command tests, $?, expected non-zero tool statuses, set -e / set -euo pipefail basics, trap ERR and trap EXIT, bounded retries, batch partial failure, and pipeline pipefail. Does not cover every set -e exception or full signal-trap reference. |
In this article we will build Bash scripts that treat a non-zero exit status as information, not an automatic disaster. You will test commands directly in if, separate expected non-zero codes from real errors, use set -e and trap ERR where they help, and retry transient failures with a cap.
Production scripts need more than a one-line set -euo pipefail preamble. Strict mode and traps change shell behavior, but your script still decides whether a failure is expected, retryable, fatal, or ignorable. The patterns below keep stdout clean for data, put human messages on stderr, and use trap EXIT to centralize cleanup for normal exits and handled failure paths.
How Bash Reports Errors
Every command returns an exit status from 0 to 255. Bash treats 0 as success and any other value as failure in conditionals and with && / ||. That does not mean every non-zero status is a bug.
Your script needs to decide what each failure means:
- Expected —
grep -qwith no match, ordiff -qwhen files differ - Retryable — a TCP check before a service finishes starting
- Fatal — a missing config file before any work runs
- Ignorable — an optional cache warm-up that may fail on first boot
The Bash exit codes guide covers how $?, exit, and pipeline status work at the shell level. This article focuses on what you do with those statuses in real scripts.
When you only need pass or fail, put the command directly in if or while instead of running it and then reading $?:
if grep -q '^root:' /etc/passwd; then echo "root entry found"; fiSample output:
root entry found! reverses the result of a command or condition. For example, you can test whether a file does not exist:
if ! [[ -e /tmp/nonexistent_file_xyz ]]; then
echo "file absent (expected)"
fiSample output:
file absent (expected)Check Command Failures Correctly
$? holds the status of the most recently executed command. Another command overwrites it immediately, so copy the value into a variable when you must inspect it later.
Run diff -q on two different files and read the status on the next line:
diff -q /etc/hosts /etc/passwd >/dev/null
echo "diff status=$?"Sample output:
diff status=1When you need both stdout and status, run the command first, then save $? before anything else touches it:
output=$(grep '^127' /etc/hosts 2>/dev/null)
status=$?
echo "status=$status lines=$(printf '%s\n' "$output" | wc -l)"Sample output:
status=0 lines=2With set -e enabled, a failed command substitution in a standalone assignment may terminate the script before you can save $?. When failure is expected and must be inspected, put the assignment in an if condition instead — commands tested by if are exempt from errexit.
Download a page and branch on success or capture the failure status in the else arm:
if body=$(curl -fsS --max-time 3 https://example.com 2>/dev/null); then
echo "bytes=${#body}"
else
curl_status=$?
echo "curl failed status=$curl_status" >&2
fiSample output on the lab host:
bytes=558Use && and || for short fatal-or-fallback paths. Reserve cmd || die "message" for cases where failure truly should stop the script — not for expected non-zero tool codes.
Functions return an exit status the same way commands do. Make return values predictable so callers can test them in if:
validate_port() {
local port="$1"
[[ "$port" =~ ^[0-9]+$ ]] && (( 10#$port >= 1 && 10#$port <= 65535 ))
}Call the function from an if test without reading $? first:
if validate_port 8080; then echo "port 8080 ok"; else echo "port rejected"; fiSample output:
port 8080 okThe Bash if else and Bash function guides cover more conditional and return-value patterns.
Handle Expected Non-Zero Exit Codes
Non-zero does not automatically mean your script broke. Several common tools use specific exit codes for ordinary outcomes. Handle the documented codes; treat anything else as a real error.
GNU grep normally uses 0 for a match, 1 for no selected lines, and 2 for an error. When exact error detection matters, run grep without -q and send matches to /dev/null:
grep '^root:' /etc/passwd >/dev/null
grep_status=$?
case "$grep_status" in
0) echo "pattern found" ;;
1) echo "no match (expected in some checks)" ;;
2) echo "grep failed" >&2; exit 2 ;;
*) echo "unexpected grep status $grep_status" >&2; exit "$grep_status" ;;
esacSample output:
pattern foundA missing file returns 2, not 1:
grep 'pattern' /nonexistent_file >/dev/null 2>/dev/null
echo "missing file status=$?"Sample output:
missing file status=2When exact error detection matters, be careful with grep -q: GNU grep may return 0 once it finds a match even if it encounters an error elsewhere in the file. Treating every non-zero grep status as "no match" still hides I/O and permission errors — that distinction is the point of explicit handling.
GNU diff uses 0 for identical files, 1 for differences, and 2 for trouble:
diff -q /etc/hosts /etc/passwd >/dev/null
diff_status=$?
case "$diff_status" in
0) echo "files are the same" ;;
1) echo "files differ (expected in drift checks)" ;;
2) echo "diff could not run" >&2; exit 2 ;;
*) echo "unexpected diff status $diff_status" >&2; exit "$diff_status" ;;
esacSample output:
files differ (expected in drift checks)flock -n returns 1 when the lock is already held (unless you change it with --conflict-exit-code). That is an expected contention outcome, not a shell crash — useful for single-instance scripts.
set -e and set -euo pipefail
set -e (errexit) tells Bash to exit when many commands return non-zero. set -u (nounset) errors on unset variables. set -o pipefail makes a pipeline fail when any stage fails. Together they are often called strict mode.
set -e does not mean "exit whenever anything returns non-zero." Bash skips errexit in several contexts documented in the manual — commands used as if, while, or until tests; most && / || lists; negated commands; and more.
With set -e, a bare failing command aborts the script before the next line runs:
bash -c 'set -e; false; echo never'; echo "wrapper exit=$?"Sample output:
wrapper exit=1echo never never ran because false triggered errexit inside the subshell.
The same false inside an if test does not trigger errexit:
bash -c 'set -e; if false; then echo yes; else echo "if false did not abort"; fi'Sample output:
if false did not abortThat gap is why blind set -e is not a complete error-handling plan. Expected grep and diff codes, retry loops, and batch collectors still need explicit branches. Compound commands, subshells, and pipelines interact with errexit in ways that surprise writers who enable the option once and never read the manual.
Use set -uo pipefail when you want unset-variable checks and pipeline failure detection without enabling errexit. Add -e only when its fail-fast behavior fits the script, then handle expected failures explicitly with if, ||, retries, and batch summaries where the default is wrong. See Bash strict mode for errexit exceptions, inherit_errexit, and a production preamble.
Report and Diagnose Errors
Reserve stdout for data the next command or caller might parse. Human diagnostics belong on stderr so pipelines and $(...) captures stay clean. See Bash redirection for how file descriptors 1 and 2 behave in longer scripts.
Short helpers keep messages consistent:
warn() { printf 'WARN: %s\n' "$*" >&2; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }warn continues; die terminates the script. To demonstrate die without killing your interactive shell, run it in a subshell — parentheses are only for the demo; in a real script you normally want termination:
( die "missing required config" )
echo "exit=$?"Sample output:
ERROR: missing required config
exit=1trap ERR runs a handler when a command fails. It is not Bash's version of try/catch — there are no exceptions, and the handler does not unwind the stack.
cat <<'SCRIPT' > /tmp/trap_err_demo.sh
#!/usr/bin/env bash
set -E -e
on_error() {
local status=$?
local line=${BASH_LINENO[0]}
printf 'ERROR: line %d: %s (status=%d)\n' \
"$line" "$BASH_COMMAND" "$status" >&2
}
trap on_error ERR
false
echo "never runs"
SCRIPTRun the script and read the diagnostic line on stderr:
bash /tmp/trap_err_demo.sh 2>&1; echo "script_exit=$?"Sample output:
ERROR: line 10: false (status=1)
script_exit=1set -E enables errtrace so the ERR trap can inherit into functions, command substitutions, and subshells — behavior that does not happen by default. The ERR trap follows many of the same exceptions as errexit; it will not fire in every context where you might expect "catch" semantics. Use it for logging and last-chance diagnostics, not as a substitute for checking exit statuses where the outcome matters.
Validate Before Doing Work
Fail fast when required inputs are missing or invalid. Checking config before network calls or file batches avoids confusing partial work.
Only source configuration files you trust, because source executes their contents as shell code — it is not mere KEY=value parsing.
cat <<'SCRIPT' > /tmp/validate_config.sh
#!/usr/bin/env bash
set -uo pipefail
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
CONFIG="${1:?config path required}"
[[ -r "$CONFIG" ]] || die "config not readable: $CONFIG"
# shellcheck source=/dev/null
source "$CONFIG"
[[ -n "${API_URL:-}" ]] || die "API_URL is empty"
[[ "${RETRIES:-0}" =~ ^[0-9]+$ ]] && (( RETRIES > 0 )) || die "RETRIES must be a positive integer"
echo "config ok: API_URL=$API_URL RETRIES=$RETRIES"
SCRIPTTest with a valid file:
printf 'API_URL=http://127.0.0.1:18080/health\nRETRIES=3\n' > /tmp/bash-error-lab.conf
bash /tmp/validate_config.sh /tmp/bash-error-lab.confSample output:
config ok: API_URL=http://127.0.0.1:18080/health RETRIES=3An empty API_URL should stop before any HTTP work:
printf 'API_URL=\nRETRIES=0\n' > /tmp/bash-error-bad.confRun the validator against that bad file:
bash /tmp/validate_config.sh /tmp/bash-error-bad.conf; echo "exit=$?"Sample output:
ERROR: API_URL is empty
exit=1Retry Temporary Failures
Retry only operations that are safe to repeat and likely to succeed after a short wait. Do not blindly retry commands that create side effects on every run.
This helper captures the failed command's status, skips sleeping after the final attempt, and returns that status when all tries fail. The command runs inside an if test so a non-zero result does not trigger errexit when the caller has set -e:
cat <<'SCRIPT' > /tmp/retry_lib.sh
retry() {
local max="${1:-3}" delay="${2:-1}" attempt=1 last_status=1
shift 2
while (( attempt <= max )); do
if "$@"; then
return 0
else
last_status=$?
fi
if (( attempt == max )); then
break
fi
printf 'attempt %d/%d failed (status=%d), sleeping %ds\n' \
"$attempt" "$max" "$last_status" "$delay" >&2
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
return "$last_status"
}
SCRIPTThe port-check demo sources that library and calls retry:
cat <<'SCRIPT' > /tmp/retry_check.sh
#!/usr/bin/env bash
source /tmp/retry_lib.sh
check_port() {
timeout 1 bash -c "echo >/dev/tcp/$1/$2" 2>/dev/null
}
retry 4 1 check_port 127.0.0.1 19999
SCRIPTStart a listener on port 19999 after two seconds so the first attempts fail (nc comes from nmap-ncat on RHEL):
( sleep 2; nc -l 127.0.0.1 19999 -w 1 </dev/null >/dev/null 2>&1 ) &Run the retry script:
bash /tmp/retry_check.sh 2>&1; echo "final exit=$?"Sample output:
attempt 1/4 failed (status=1), sleeping 1s
attempt 2/4 failed (status=1), sleeping 2s
final exit=0When every attempt fails, the function returns the last command's status instead of always returning 1. Source the same library and call it:
cat <<'SCRIPT' > /tmp/retry_42_demo.sh
#!/usr/bin/env bash
source /tmp/retry_lib.sh
always_42() { return 42; }
retry 3 1 always_42
echo "status=$?"
SCRIPTRun the demo:
bash /tmp/retry_42_demo.sh 2>&1Inside retry, the if "$@" test prevents errexit from aborting the loop early. If the caller uses set -e, a final non-zero return from retry still triggers errexit unless you handle it (retry ... || return 1, or test the result in if).
Sample output:
attempt 1/3 failed (status=42), sleeping 1s
attempt 2/3 failed (status=42), sleeping 2s
status=42Continue After Individual Failures
For maintenance scripts, process every item even when some fail. Collect failures, print a summary on stderr, and exit non-zero if anything failed.
Create three sample files — two valid, one bad:
rm -rf /tmp/bash-batch-lab && mkdir /tmp/bash-batch-lab
printf 'ok\n' > /tmp/bash-batch-lab/good1.txt
printf 'ok\n' > /tmp/bash-batch-lab/good2.txt
printf 'bad\n' > /tmp/bash-batch-lab/bad.txtThe batch processor logs warnings, continues past bad files, and exits non-zero at the end:
cat <<'SCRIPT' > /tmp/process_batch.sh
#!/usr/bin/env bash
failures=()
for f in /tmp/bash-batch-lab/*.txt; do
if [[ $(<"$f") != "ok" ]]; then
failures+=("$(basename "$f")")
printf 'WARN: %s failed validation\n' "$(basename "$f")" >&2
continue
fi
echo "processed $(basename "$f")"
done
if ((${#failures[@]} > 0)); then
printf 'FAILED: %s\n' "${failures[*]}" >&2
exit 1
fi
SCRIPTRun the batch and check the overall exit status:
bash /tmp/process_batch.sh; echo "batch_exit=$?"Sample output:
WARN: bad.txt failed validation
processed good1.txt
processed good2.txt
FAILED: bad.txt
batch_exit=1Two files succeeded, one failed, and the script returned 1 so a CI job or wrapper can react.
Handle Pipeline Failures
Pipelines need explicit policy because Bash normally reports only the last command's status. With pipefail, the pipeline's exit status is the status of the rightmost command that exits non-zero, or zero if every stage succeeds.
Without pipefail, a failing first stage can hide behind a succeeding last stage:
set +o pipefail
false | true
echo "default pipeline status=$? PIPESTATUS=${PIPESTATUS[*]}"Sample output:
default pipeline status=0 PIPESTATUS=1 0With pipefail enabled:
set -o pipefail
false | true
echo "pipefail pipeline status=$? PIPESTATUS=${PIPESTATUS[*]}"Sample output:
pipefail pipeline status=1 PIPESTATUS=1 0Read ${PIPESTATUS[*]} when you need each stage's status separately. The Bash pipes guide covers subshell behavior and longer chains.
Always Clean Up Temporary Resources
Duplicating cleanup after every error path is brittle. Register an EXIT trap once to centralize teardown for normal shell exits and handled failure paths.
cat <<'SCRIPT' > /tmp/trap_demo.sh
#!/usr/bin/env bash
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; printf 'cleaned %s\n' "$tmpdir" >&2; }
trap cleanup EXIT
echo "work file" > "$tmpdir/work.txt"
cat "$tmpdir/work.txt"
false
SCRIPTRun the script and confirm cleanup ran even though the last command failed:
bash /tmp/trap_demo.sh 2>&1; echo "script_exit=$?"Sample output:
work file
cleaned /tmp/tmp.XXXXXXXXXX
script_exit=1An EXIT trap is not guaranteed cleanup under every termination condition. SIGKILL cannot be trapped, and some fatal signals skip your handler. Use EXIT for temp directories and restoring settings on the paths your script controls; pair it with signal traps when users may interrupt long jobs.
Common Bash Error-Handling Mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
Check $? after every command |
Noise; easy to read stale status | Test commands directly in if / while |
| Treat all non-zero as fatal | Breaks expected grep, diff, locks |
Branch on documented status codes |
Map every grep non-zero to "no match" |
Hides status 2 I/O errors |
Handle 0, 1, and >1 separately |
| Retry without a max attempt count | Hangs forever on permanent failures | Bounded loop with backoff cap |
| Retry non-idempotent writes | Duplicates data or charges twice | Retry reads and probes only |
| Log errors to stdout | Breaks pipelines and $(...) capture |
printf ... >&2 or warn / die |
Copy-paste cleanup on each exit 1 |
Misses branches; leaks temp files | trap cleanup EXIT |
Treat trap ERR like try/catch |
No stack unwind; many errexit exceptions | Log with ERR; check statuses that matter |
Summary
Bash error handling starts with a simple rule: non-zero is a status, and your script decides what it means. Test commands directly in conditionals when you only need pass or fail, capture $? immediately when you also need output, and handle tool-specific codes such as grep 0/1/>1 and diff 0/1/2 instead of treating every non-zero value as a crash.
set -e and set -euo pipefail add fail-fast defaults, but errexit has documented exceptions and does not replace explicit branches for expected failures, retries, or batch summaries. Pair stderr diagnostics and small warn / die helpers with trap ERR for logging — not as a substitute for checking exit statuses — and trap EXIT to centralize cleanup on paths your script controls.
Retry transient checks with a bounded loop that preserves the final failure status. Batch jobs should process every item, collect failures, and return a non-zero exit when anything failed. Enable pipefail when pipeline stages must not fail silently, and read PIPESTATUS when you need per-stage detail.
References
- Bash manual — Exit Status
- Bash manual — Bourne Shell Builtins (
trap) - Bash manual — Pipelines
- GNU grep manual — Exit Status
- GNU diffutils manual — Exit status
- Linux manual —
flock(1)
Frequently Asked Questions
Is set -e error handling?
set -e tells Bash to exit when many commands return non-zero, but it skips errexit in if/while tests, most &&/|| chains, and other contexts documented in the Bash manual. That is a fail-fast default, not a full plan. Combine it with explicit branches for expected tool codes, retries, and batch collectors.
Should I check $? after every command?
No. When you only need pass or fail, put the command in if, while, or && / ||. Read $? when you must store the numeric status after capturing output or when the command runs inside a construct that hides failure. Copy $? into a variable immediately because the next command overwrites it.
How do I retry a command?
Wrap the command in a while loop with a max attempt counter and increasing sleep between tries. Capture the command's exit status, skip sleeping after the final failed attempt, and return that status when all tries fail. Retry only idempotent or read-only checks such as HTTP health probes or TCP port tests.
How do I continue after one item fails but still return failure?
Loop over all items, use continue on per-item failure, append failed names to an array, and call exit 1 after the loop if the array is non-empty. Print the failure list on stderr so operators see a summary without parsing stdout.
How do I keep errors out of stdout?
Send human messages and warnings to stderr with >&2, printf '...' >&2, or helpers such as warn and die. Keep stdout for data another command or $(...) will consume. Mixing logs into stdout breaks pipelines and command substitution.
Does Bash have try/catch?
No. Bash has no native exception mechanism like Python or Java. Use exit statuses with if, &&, ||, and functions; use trap ERR for diagnostics on failures; use trap EXIT for cleanup. Those patterns overlap with strict mode but do not provide stack unwinding or typed exceptions.

