Linux Basic — Exit Codes

4 minute read

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

Updated: