Bash Exit Codes: What $? Means and How to Use It

March 17, 2026

Bash Exit Codes: What $? Means and How to Use It

March 17, 2026

Bash exit codes are how processes communicate success or failure to the shell. Every command you run exits with a number — 0 means success, anything else means something went wrong. Understanding $?, the built-in exit code reference, and the error-handling flags like set -e and set -o pipefail is what separates scripts that fail silently from scripts that fail loudly and helpfully.

0 Success 1 General error 2 Shell misuse / bad syntax 126 Permission denied 127 Command not found 130 Ctrl+C (SIGINT)
The most important bash exit codes and their meanings

1. What $? Is and How to Use It

bash
#!/bin/bash

# $? holds the exit code of the LAST command
ls /etc/hosts
echo "$?"    # 0 (file exists, command succeeded)

ls /nonexistent
echo "$?"    # 2 (ls failed — file not found)

# Check immediately — $? changes after EVERY command
grep "root" /etc/passwd
status=$?    # save $? before it gets overwritten
echo "grep exited with: $status"

# Typical pattern: check and act
if ! cp source.txt dest.txt; then
    echo "Copy failed with exit code $?" >&2
    exit 1
fi

2. Complete Exit Code Reference

  • 0 — Success. Command completed without errors.
  • 1 — General error. Many commands use this as a catch-all failure code.
  • 2 — Misuse of shell built-ins (e.g., bad arguments to cd, syntax errors in scripts).
  • 126 — Command found but not executable (permission denied or not a program).
  • 127 — Command not found. Usually a typo or missing PATH entry.
  • 128 — Invalid exit argument (used in scripts that call exit 256 — wraps to 0, so avoid).
  • 128+n — Fatal error signal n. Exit 137 = killed by signal 9 (SIGKILL). Exit 143 = signal 15 (SIGTERM).
  • 130 — Script terminated by Ctrl+C (128 + SIGINT signal 2).
  • 255 — Exit status out of range (if you pass a value > 255 to exit).

3. exit — Setting Your Own Exit Codes

#!/bin/bash

# exit 0: success
# exit 1: general error (most scripts use 1 for all failures)
# exit 2+: meaningful codes for callers to distinguish errors

INPUT="$1"

[[ -z "$INPUT" ]] && { echo "Usage: $0 " >&2; exit 1; }
[[ -f "$INPUT" ]] || { echo "File not found: $INPUT" >&2; exit 2; }
[[ -r "$INPUT" ]] || { echo "Permission denied: $INPUT" >&2; exit 3; }

# Process the file...
echo "Processing $INPUT"
exit 0   # explicit success (optional — script exits 0 if last command succeeded)</code></pre>
</div>

4. set -e, set -u, set -o pipefail

The "strict mode" trio for production scripts:

bash
#!/bin/bash
set -euo pipefail
# -e: exit immediately if any command fails (non-zero exit)
# -u: treat unset variables as errors
# -o pipefail: pipeline fails if ANY command in it fails (not just the last)

# Without pipefail, this hides the grep failure:
# grep "ERROR" /nonexistent/file | wc -l   → prints 0 and exits 0 (wrong!)
# With pipefail, the script correctly exits non-zero

# -e gotcha: use || true to allow expected failures
rm -f optional-file.txt || true   # won't abort script if file doesn't exist
grep "pattern" file.txt || true   # grep exit 1 if no match — OK here

# -u gotcha: check variable is set before using it
filename="${1:-}"  # use default empty string instead of failing
[[ -n "$filename" ]] || { echo "Filename required" >&2; exit 1; }

5. trap for Cleanup on Exit

#!/bin/bash
set -euo pipefail

TMPDIR=$(mktemp -d)

# cleanup() runs no matter how the script exits (success, error, or Ctrl+C)
cleanup() {
    echo "Cleaning up temporary files..." >&2
    rm -rf "$TMPDIR"
}
trap cleanup EXIT   # EXIT fires on any exit

# SIGINT (Ctrl+C) and SIGTERM handlers
trap 'echo "Interrupted" >&2; exit 130' INT
trap 'echo "Terminated" >&2; exit 143' TERM

# Script body — TMPDIR will be cleaned up regardless
echo "Working in $TMPDIR"
cp /etc/hosts "$TMPDIR/hosts.bak"
# ... do work ...
echo "Done"

6. Complete Error Handling Pattern

#!/bin/bash
set -euo pipefail

# Centralized error handler
error_exit() {
    echo "[ERROR] Line ${2:-unknown}: ${1}" >&2
    exit "${3:-1}"
}
trap 'error_exit "Unexpected failure" $LINENO' ERR

# Cleanup handler
TMPFILE=""
cleanup() { [[ -n "$TMPFILE" ]] && rm -f "$TMPFILE"; }
trap cleanup EXIT

# Input validation
[[ $# -lt 1 ]] && error_exit "Usage: $0 " $LINENO 1
INPUT="$1"
[[ -f "$INPUT" ]] || error_exit "File not found: $INPUT" $LINENO 2

TMPFILE=$(mktemp)
process_data() {
    cp "$INPUT" "$TMPFILE"
    # ... processing ...
    echo "Processed: $TMPFILE"
}

process_data
echo "Script completed successfully"
exit 0</code></pre>
</div>

Exit codes work hand-in-hand with bash functions where functions signal success or failure via return codes. For checking file conditions that might cause non-zero exits, see the bash check if file exists guide.

Summary

$? holds the last exit code. 0 is success, 1 is general error, 127 is command not found. Always use set -euo pipefail in production scripts to catch failures early. Use trap cleanup EXIT to ensure cleanup runs regardless of how the script exits. Define meaningful exit codes (2, 3, etc.) so callers can distinguish failure types.