Understanding Exit Codes as an API Between Scripts
AI generated
Bash · Exit Codes · POSIX · Error Handling · Shell API
Understanding Exit Codes as an API Between Scripts
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.

13 min read Exit Codes · POSIX · $? · set -e · pipefail · PIPESTATUS Bash 4.x · 5.x · POSIX Shell · Linux

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.

11. FAQ: Understanding Exit Codes as an API Between Scripts

1What does exit code 127 mean?
Command not found. Causes: wrong PATH, missing binary, typo in the command name.
2Why does a pipeline return exit 0 despite a failure?
Without pipefail the pipeline exit code is always the one from the last command. set -o pipefail fixes this.
3How to use PIPESTATUS?
Save it immediately after the pipe: statuses=("${PIPESTATUS[@]}"). Contains the exit code of each pipe step. Gets overwritten by the next command.
4set -e does not trigger inside if conditions?
In conditional contexts, non zero is not an error but the condition result. POSIX compliant behavior. Guard explicitly with || { exit 1; }.
5How to save $? correctly?
command; rc=$?, saved immediately after the command, before any further command overwrites $?. Idiomatic: test directly with if ! command; then.
6Exit code of background processes?
Save pid=$!, then wait $pid; rc=$?. wait blocks until the process ends and sets $? to its exit code.
7What does exit code 130 mean?
Termination by SIGINT (Ctrl+C): 128 + 2 = 130. Set exit 130 explicitly in trap INT handlers for correct signal propagation.
8How to define your own exit codes as an API?
readonly EXIT_CONFIG_MISSING=3 as a constant. Move it into exit-codes.sh, include via source. Range 3 to 125 is free for your own codes.
9exit vs. return in Bash?
exit terminates the entire script. return only ends the current function. Both accept 0 to 255. Outside a function, return behaves like exit.
10Testing exit codes in CI pipelines?
BATS (Bash Automated Testing System): run ./script.sh; assert_equal $status 0. Run as a regular test step in CI.