Linux Basic — Exit Codes
Exit Codes
Every command Bash runs returns an integer exit code between 0 and 255. 0 means success; anything else means a failure of some kind. Reading and returning these codes correctly is the foundation of all error handling.
Reading $?
$? is the exit code of the most recent foreground command:
ls /tmp >/dev/null
echo "$?"
# 0 ← success
ls /nope 2>/dev/null
echo "$?"
# 2 ← ls's "missing path" code (1 on macOS, 2 on Linux)
$? is single-shot reset by every command — including echo. Capture it immediately:
some_cmd
rc=$?
echo "got code $rc" # now $? is the echo's exit code, not some_cmd's
Convention — What the Numbers Mean
There’s no enforced standard, but these are the common conventions:
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | generic failure |
| 2 | misuse / bad arguments (also Bash’s “syntax error” code) |
| 64-78 | sysexits.h semantic codes (USAGE, DATAERR, NOINPUT, …) |
| 126 | command found but not executable (permission denied) |
| 127 | command not found |
| 128 | invalid exit argument |
| 128+N | killed by signal N (e.g. 130 = SIGINT, 137 = SIGKILL, 143 = SIGTERM) |
| 255 | “out of range” — also returned by some tools for serious errors |
Examples you’ll see in real life:
nonexistent-cmd
echo $?
# 127
chmod -x /tmp/myscript; /tmp/myscript
echo $?
# 126
sleep 60 &
pid=$!
kill -INT $pid
wait $pid; echo $?
# 130 (128 + 2 = SIGINT)
Stick to 0 for success, 1 for “ordinary failure” in your own scripts. Use 2 for misuse (bad CLI args). Use higher numbers (3+) for distinct error categories when callers will branch on them.
exit N and return N
exit N ends the whole script with code N. return N ends a function with code N. Both default to $? (the previous command’s code) when N is omitted:
fail_if_missing() {
[[ -e "$1" ]] || return 1 # explicit
return 0
}
if ! fail_if_missing /etc/myconfig; then
exit 1
fi
# A common idiom: exit with the previous command's code
some_cmd || exit
The bare exit (no arg) means “exit with whatever $? is now” — sometimes useful, sometimes a foot-gun.
Capturing Output AND Exit Code
output=$(some_cmd 2>&1)
rc=$?
if (( rc != 0 )); then
echo "FAIL ($rc): $output" >&2
exit "$rc"
fi
The order is critical. Don’t do anything between output=$(…) and rc=$? — even an echo will overwrite $?.
The compact form:
if output=$(some_cmd 2>&1); then
echo "OK: $output"
else
rc=$?
echo "FAIL ($rc): $output" >&2
fi
if and while test the exit code of the controlling command directly, so you don’t need to read $? — but if you want to report the code, save it on the first line of the else.
PIPESTATUS — Codes From a Pipeline
$? in a pipeline reflects only the last command’s exit code. The full list is in PIPESTATUS:
false | true | echo done
echo "PIPESTATUS = ${PIPESTATUS[*]}"
# done
# PIPESTATUS = 1 0 0
Without set -o pipefail, a pipeline succeeds if its last command succeeds — even if curl failed in the middle. pipefail (next page) flips that. PIPESTATUS is how you inspect individual stages.
curl -sS https://example.com | jq .data
if (( ${PIPESTATUS[0]} != 0 )); then
echo "curl failed" >&2
fi
PIPESTATUS is set per-pipeline. Read it immediately, like $?.
|| and && for Quick Branching
# Run cleanup only on success
backup && rm -rf /tmp/staging
# Print warning on failure
backup || echo "backup failed" >&2
# Idiomatic "exit if missing"
command -v jq >/dev/null || { echo "jq required" >&2; exit 1; }
&& runs the next command only if the previous succeeded. || runs it only on failure. Group multiple commands with { … ; }.
Negation — !
if ! command -v jq >/dev/null; then
echo "jq missing" >&2
exit 1
fi
! flips the exit code. It also disables set -e for that command — useful when you want to test something that might fail without aborting.
set -e
! grep -q ERROR file.log # OK — doesn't abort even if grep returns 1
grep -q ERROR file.log # aborts the script if no match (under set -e)
true and false
Two built-ins that do nothing but return 0 or 1. Handy as defaults:
debug=${DEBUG:-false}
$debug && echo "verbose mode" # only echoes if DEBUG=true was set
# Always-succeed in a chain to swallow an error
risky_thing || true
Exit Codes Versus Output
A command’s output (stdout/stderr) and its exit code are independent. Some tools print nothing on failure; some print an error and return 0. Always check the exit code; don’t grep the output:
# ❌ Fragile
out=$(some_cmd)
if [[ "$out" == *error* ]]; then …
# ✅ Robust
if ! out=$(some_cmd 2>&1); then …
A Real Example — Idempotent Setup Step
ensure_dir() {
local d=$1
if [[ -d "$d" ]]; then
return 0
fi
if mkdir -p -- "$d"; then
echo "Created $d"
return 0
fi
echo "FAIL: could not create $d" >&2
return 1
}
ensure_dir /var/lib/myapp || exit $?
return 0/1 from the function, then the caller decides whether to exit. Cleanly composable.
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
cmd; rc=$? with anything between |
capture $? on the very next line |
$? reset by every command |
if [[ $? -eq 0 ]] after a command |
if cmd; then … |
needless intermediate $? |
Treating pipeline $? as “did the curl work?” |
check PIPESTATUS or use pipefail |
only last command’s code is in $? |
exit (no arg) at the end of error handler |
exit 1 or specific code |
bare exit uses current $? (could be 0!) |
return 0 after a failure to “hide” it |
propagate the real code with return $? |
callers can’t react |
Next → set -e and set -u