0 to 255, POSIX meanings, $?, set -e and pipe exit codes
Exit codes are the only structured interface between Bash scripts. Anyone who understands what 0 to 255 mean, how $? works, when set -e does not trigger and how pipefail rescues pipe exit codes writes scripts that communicate reliably with each other instead of silently overlooking errors.
Table of Contents
- 1. What Exit Codes Really Are
- 2. POSIX Exit Code Meanings: 0 to 255 in Detail
- 3. Evaluating $?: The Right Way and the Wrong Way
- 4. set -e: Behavior, Limits and Pitfalls
- 5. Pipe Exit Codes and pipefail
- 6. PIPESTATUS: Evaluating Every Step in a Pipe
- 7. Defining Your Own Exit Codes as an API
- 8. Exit Codes in Subshells, Functions and Background Processes
- 9. Exit Code Behavior in Direct Comparison
- 10. Summary
- 11. FAQ
1. What Exit Codes Really Are
Exit codes are integer values between 0 and 255 that a process returns to its parent process when it terminates. In Bash, this value is available through the special variable $? after every command. A value of 0 means success, while any other value signals an error or a specific meaning, depending on the program. This simple convention is the fundamental communication interface between processes on Unix systems, and therefore between Bash scripts.
Grasping the concept of exit codes as an API means understanding that when a Bash script calls another script or program, the exit code of the called process is the only standardized return channel. Of course stdout and stderr also exist, but while those are free text and program specific, exit codes are a universal standard. Every POSIX compliant program, every shell builtin, every Bash script communicates through the same mechanism. Anyone who understands and consistently uses this mechanism builds scripts that interact reliably with one another.
In practice you often see Bash scripts that ignore exit codes: a command is called, the result is never checked, and the script simply keeps running. This is equivalent to an API call where the HTTP status response is ignored. The consequences are the same: the script may execute subsequent steps that build on incorrect output or a nonexistent result, without any error ever being signaled.
2. POSIX Exit Code Meanings: 0 to 255 in Detail
POSIX defines a rough standard for the value range of exit codes. 0 means success. 1 is the generic error code, used by most Unix programs for general errors. 2 is often used for incorrect usage or invalid arguments. The range from 3 to 125 is free for program specific exit codes. The range from 126 to 127 is reserved for shell specific error messages: 126 means a program was found but was not executable, and 127 means a program was not found at all. 128 plus N is the pattern for signal induced terminations: a process terminated by signal 9 (SIGKILL) returns 137 (128 + 9). A process terminated by signal 15 (SIGTERM) returns 143 (128 + 15).
Bash itself sets exit codes for specific situations: 130 for interruption by Ctrl+C (SIGINT, signal 2), and 141 for a broken pipe (SIGPIPE, signal 13). Understanding these meanings is crucial for handling signals correctly inside trap handlers. A script that catches SIGINT with trap 'cleanup; exit 130' INT passes on the correct exit code for Ctrl+C and lets the caller distinguish between a normal error and a user initiated abort.
#!/usr/bin/env bash
# exit-code-demo.sh: Demonstrate POSIX exit code conventions
set -euo pipefail
# Define meaningful exit codes as named constants
readonly EXIT_SUCCESS=0
readonly EXIT_GENERAL_ERROR=1
readonly EXIT_USAGE_ERROR=2
readonly EXIT_CONFIG_MISSING=3
readonly EXIT_NETWORK_UNREACHABLE=4
readonly EXIT_PERMISSION_DENIED=5
readonly EXIT_TIMEOUT=6
readonly EXIT_LOCK_HELD=7
usage() {
echo "Usage: $(basename "$0") <command> [args...]" >&2
exit "$EXIT_USAGE_ERROR"
}
validate_config() {
local config_file="$1"
if [[ ! -f "$config_file" ]]; then
echo "[ERROR] Config file not found: $config_file" >&2
exit "$EXIT_CONFIG_MISSING"
fi
if [[ ! -r "$config_file" ]]; then
echo "[ERROR] Config file not readable: $config_file" >&2
exit "$EXIT_PERMISSION_DENIED"
fi
}
# Caller can distinguish between error types
[[ $# -lt 1 ]] && usage
validate_config "${CONFIG_FILE:-/etc/app/config.yml}"
# Check exit code of external command
if ! curl -sf --max-time 10 "https://api.mironsoft.de/health" >/dev/null 2>&1; then
last_exit=$?
if [[ $last_exit -eq 28 ]]; then # curl timeout = 28
echo "[ERROR] API health check timed out" >&2
exit "$EXIT_TIMEOUT"
else
echo "[ERROR] API health check failed (curl: $last_exit)" >&2
exit "$EXIT_NETWORK_UNREACHABLE"
fi
fi
echo "[OK] All checks passed"
exit "$EXIT_SUCCESS"
3. Evaluating $?: The Right Way and the Wrong Way
The special variable $? holds the exit code of the most recently executed command. Its most important property: it is immediately overwritten by the next command that runs. So anyone who wants to evaluate $? after several commands must save it into a regular variable first: exit_code=$?. Saving $? right after a command is the only correct way to make use of a specific command's exit code.
A common mistake: command; if [ $? -ne 0 ] is fragile, because other commands could run between the command and the if check (for example through redirections or subshell calls that overwrite $?). The more robust approach is either testing the command directly in the if condition (if ! command; then) or saving it immediately: command; rc=$?; if [[ $rc -ne 0 ]]. The most direct route is using if command; then, which is idiomatic Bash and leaves $? in the background entirely.
4. set -e: Behavior, Limits and Pitfalls
set -e is one of the most important safety mechanisms when working with exit codes in Bash scripts. It tells Bash to terminate the script immediately if a command returns a non zero exit code. Without set -e, Bash ignores failed commands by default and keeps executing, a behavior that makes sense in interactive shells but hides errors in automation scripts.
The most important limitation: set -e does not trigger inside conditional contexts. If a command is part of an if condition (if failing_command; then), follows || (failing_command || fallback), or comes after && (success && failing_command), the non zero exit code is not treated as an abort condition. This is intentional: in those contexts the exit code is a condition result, not an error. Anyone who still wants to guard these spots must explicitly write || { echo "Error"; exit 1; }.
#!/usr/bin/env bash
# set-e-behavior.sh: Understanding set -e edge cases
set -euo pipefail
# CASE 1: set -e triggers, script exits immediately
# false # Uncomment to see: script stops here with exit code 1
# CASE 2: set -e does NOT trigger in if-context
if false; then
echo "unreachable"
fi
echo "After if: still running (exit code was NOT treated as error)"
# CASE 3: set -e does NOT trigger after ||
false || echo "Fallback executed, false's exit code was consumed by ||"
# CASE 4: set -e does NOT trigger after &&
true && false || echo "Right side of && failed, consumed by ||"
# CASE 5: Correctly saving exit code before set -e can fire
run_and_check() {
local cmd="$1"
local rc=0
# Subshell so set -e doesn't kill parent
( eval "$cmd" ) && rc=$? || rc=$?
if [[ $rc -ne 0 ]]; then
echo "[WARN] Command '$cmd' exited with code $rc"
fi
return "$rc"
}
run_and_check "ls /nonexistent" || true
# CASE 6: Explicit exit code propagation
deploy_step() {
local step_name="$1"
shift
if ! "$@"; then
echo "[FAIL] Step '$step_name' failed with exit code $?" >&2
return 1
fi
echo "[OK] Step '$step_name' succeeded"
}
deploy_step "health-check" curl -sf http://localhost/health
5. Pipe Exit Codes and pipefail
Pipes are one of the most powerful features of Bash, but they have a critical property regarding exit codes: without set -o pipefail, the exit code of a pipeline is always the exit code of the last command in the pipe. An error in an early step of the pipeline is completely hidden as long as the last command succeeds. The classic example: failing_command | grep "pattern" returns exit code 0 whenever grep finds the pattern, or whenever grep had no output from the failed command to process.
With set -o pipefail, a pipeline returns the exit code of the last command with a non zero exit code. If every command in the pipe succeeds, the pipeline's exit code is 0. The interplay of set -e and set -o pipefail ensures that no failed command inside a pipeline stays hidden. The combination set -euo pipefail therefore covers the most common sources of unnoticed errors in Bash scripts.
6. PIPESTATUS: Evaluating Every Step in a Pipe
PIPESTATUS is a Bash array holding the exit codes of every command in the most recent pipeline. After cmd1 | cmd2 | cmd3, ${PIPESTATUS[0]} contains the exit code of cmd1, ${PIPESTATUS[1]} that of cmd2, and ${PIPESTATUS[2]} that of cmd3. This allows granular error diagnosis: which step in a complex pipe chain actually failed? This information is not available from pipefail alone, since it only returns the code of the last failed command.
PIPESTATUS must be saved immediately after the pipe, since it is overwritten by the next command (even a simple echo). The pattern: cmd1 | cmd2; pipe_codes=("${PIPESTATUS[@]}") saves all exit codes of the pipe stages. Each code can then be evaluated individually. This pattern is especially valuable in build pipelines, where you need to know whether a compiler error, a linting error, or an output filter step is what actually failed.
#!/usr/bin/env bash
# pipestatus-demo.sh: Granular pipe exit code analysis
set -uo pipefail
check_pipeline() {
local description="$1"
shift
# Run pipeline and capture PIPESTATUS immediately
eval "$@" || true
local -a statuses=("${PIPESTATUS[@]}")
local all_ok=true
for i in "${!statuses[@]}"; do
if [[ "${statuses[$i]}" -ne 0 ]]; then
echo "[FAIL] Pipeline '$description': step $((i+1)) exited with ${statuses[$i]}" >&2
all_ok=false
fi
done
$all_ok || return 1
echo "[OK] Pipeline '$description' succeeded"
}
# Example: multi-step pipeline with individual exit code tracking
process_logs() {
local logfile="$1"
local pattern="$2"
local output="$3"
grep -E "$pattern" "$logfile" \
| sort -u \
| awk '{print NR": "$0}' \
> "$output"
local -a statuses=("${PIPESTATUS[@]}")
# grep exits 1 if no match (not an error per se)
if [[ "${statuses[0]}" -eq 1 ]]; then
echo "[INFO] No matches found for pattern '$pattern'" >&2
return 0
fi
# sort or awk failing is a real error
for i in 1 2; do
if [[ "${statuses[$i]}" -ne 0 ]]; then
echo "[FAIL] Step $((i+1)) in log processing failed with code ${statuses[$i]}" >&2
return 1
fi
done
echo "[OK] Processed $(wc -l < "$output") matching lines"
}
process_logs "/var/log/syslog" "ERROR|CRITICAL" "/tmp/errors.txt"
7. Defining Your Own Exit Codes as an API
Defining your own exit codes as a documented API is a quality hallmark of professional shell scripts. Instead of blanket using exit code 1 for every kind of error, you reserve specific codes for different failure states: 2 for configuration errors, 3 for network errors, 4 for timeouts, 5 for missing dependencies. Callers, whether other scripts, cron jobs, or CI pipelines, can evaluate the exit code and react accordingly: retry automatically on timeout, raise an alert on configuration errors, or wait before retrying on network errors.
This exit code API should be documented in a header comment of the script and defined as readonly constants. That prevents accidental overwriting and makes the possible return values immediately visible. When several scripts work together, a shared library file such as exit-codes.sh is worth introducing, sourced by every script, so the exit codes stay consistent across an entire project.
8. Exit Codes in Subshells, Functions and Background Processes
Bash functions return the status of their last command as their exit code, or whatever value was explicitly set with return N. Important: return can only return values from 0 to 255 inside functions, no strings, no objects. When a function needs to communicate a more complex status, writing to stdout or setting a global variable is the usual way. The exit code only communicates success (0) or one of several defined error states.
For background processes (command &), the exit code is not immediately available through $?. It is only set after calling wait PID. A common mistake: job &; pid=$!; echo "started"; wait $pid; if [[ $? -ne 0 ]], the echo between & and wait overwrites $?, but this is actually correct here, because $? is only evaluated after wait. When several background processes are started, each PID must be waited on individually with wait and its exit code saved.
9. Exit Code Behavior in Direct Comparison
The behavior of exit codes differs significantly across Bash contexts. This table shows the most important differences, which often lead to misunderstandings.
| Context | Non-Zero Exit Code | set -e Reaction | Recommendation |
|---|---|---|---|
| Normal command | false, ls /nofile |
Script aborts | Desired, default behavior |
| if condition | if false; then |
No abort | Guard explicitly with || exit 1 if needed |
| After || operator | false || echo fallback |
No abort | Use deliberately for controlled fallbacks |
| Pipe without pipefail | false | grep x |
No abort (hidden!) | Always use set -o pipefail |
| Background process | false & |
No abort | wait $! and check the exit code manually |
The table shows why exit codes as an API must be understood consistently: Bash behaves differently in every context. The most important rule: set -euo pipefail covers normal commands and pipes, but conditional contexts and background processes always require explicit handling. Anyone who internalizes this avoids the most common sources of silent failures in Bash scripts.
Mironsoft
Shell automation, error handling and deployment infrastructure
Ready to use exit codes consistently as an API between scripts?
We analyze existing Bash scripts for unsafe exit code handling and implement a documented exit code API with consistent error handling, pipefail and PIPESTATUS evaluation for your deployment stack.
Code review
Analysis of every exit code usage for silent failures and hidden pipe failures
API design
A documented exit code library for consistent error communication between scripts
Pipeline hardening
Retrofitting pipefail, PIPESTATUS and trap ERR so no error ever stays hidden again
10. Summary
Understanding exit codes as an API between scripts means: 0 is success, everything else is a specific state that needs to be communicated. The combination set -euo pipefail ensures that no non zero exit code stays hidden in normal commands and pipes. PIPESTATUS allows granular diagnosis inside pipe chains. Defining your own exit codes as named constants turns scripts into documented APIs.
Knowing the limits of set -e is at least as important as using it: conditional contexts, || operators and background processes are not caught automatically. Anyone who understands these exceptions can decide exactly where explicit error handling is needed, and writes scripts that neither hide errors nor abort overzealously where errors are expected and normal.
Exit codes as an API: the essentials at a glance
Value range
0 means success. 1 is a generic error. 2 is incorrect usage. 126 is not executable. 127 is not found. 128+N is signal N.
set -e limits
Does not trigger in if, while, after || or &&. Background processes require explicit wait plus exit code checking.
PIPESTATUS
Save it immediately after the pipe: statuses=("${PIPESTATUS[@]}"). Reports each step individually, indispensable for diagnosing complex pipes.
Your own exit code API
Define exit codes as readonly constants, move them into exit-codes.sh, document them. Callers can distinguish error types and react accordingly.