Bash Exit Codes: Return Values and Error Handling

Every command you run in bash returns an exit code—a number between 0 and 255 that indicates whether the command succeeded or failed. This simple mechanism is the foundation of error handling in...

Key Insights

  • Exit codes are the primary mechanism for error handling in bash—every command returns 0 for success and 1-255 for various failure conditions, accessible via $?
  • Use set -e and set -o pipefail to fail fast in scripts, but understand their limitations with conditionals, pipelines, and subshells
  • Proper exit code handling requires explicit checks, cleanup traps, and awareness of how pipelines, functions, and background processes affect return values

Introduction to Exit Codes

Every command you run in bash returns an exit code—a number between 0 and 255 that indicates whether the command succeeded or failed. This simple mechanism is the foundation of error handling in shell scripts. By convention, an exit code of 0 means success, while any non-zero value indicates failure.

The special variable $? holds the exit code of the most recently executed command. Understanding how to check and respond to this value separates fragile scripts from robust ones.

#!/bin/bash

# Successful command
ls /tmp
echo "Exit code: $?"  # Outputs: 0

# Failed command
ls /nonexistent_directory
echo "Exit code: $?"  # Outputs: 2 (or 1, depending on ls implementation)

# Command that succeeds
grep "pattern" /etc/hosts
echo "Exit code: $?"  # 0 if found, 1 if not found, 2 if file error

The exit code disappears the moment you run another command, so capture it immediately if you need it:

some_command
exit_code=$?
echo "Logging the result..."
if [ $exit_code -ne 0 ]; then
    echo "Command failed with code $exit_code"
fi

Standard Exit Codes and Conventions

While you can use any value from 1-255 for failures, certain codes have established meanings:

  • 0: Success
  • 1: General errors (catchall for miscellaneous failures)
  • 2: Misuse of shell command (missing keyword, permission problem, etc.)
  • 126: Command cannot execute (permission problem or not an executable)
  • 127: Command not found
  • 128: Invalid exit argument (exit takes only integer args in range 0-255)
  • 128+n: Fatal error signal “n” (e.g., 130 = terminated by Ctrl+C, which is signal 2)
  • 130: Script terminated by Control-C (SIGINT)
  • 137: Process killed with SIGKILL (128+9)
  • 255: Exit status out of range
#!/bin/bash

# Demonstrating standard exit codes

# Exit code 127 - command not found
nonexistent_command 2>/dev/null
echo "Command not found: $?"  # 127

# Exit code 126 - cannot execute
touch /tmp/not_executable
chmod -x /tmp/not_executable
/tmp/not_executable 2>/dev/null
echo "Cannot execute: $?"  # 126

# Exit code 2 - misuse
ls --invalid-option 2>/dev/null
echo "Invalid option: $?"  # 2

# Exit code 1 - general error
grep "nonexistent" /etc/hosts > /dev/null
echo "Pattern not found: $?"  # 1

For your custom scripts, stick to exit codes 1-125 to avoid conflicts with reserved codes. Use different codes to distinguish between error types:

#!/bin/bash
# exit-code-conventions.sh

readonly E_SUCCESS=0
readonly E_GENERAL=1
readonly E_MISSING_FILE=2
readonly E_INVALID_ARG=3
readonly E_NETWORK=4

if [ $# -eq 0 ]; then
    echo "Error: No arguments provided" >&2
    exit $E_INVALID_ARG
fi

if [ ! -f "$1" ]; then
    echo "Error: File not found: $1" >&2
    exit $E_MISSING_FILE
fi

exit $E_SUCCESS

Setting Exit Codes in Scripts

Your script’s exit code is determined by the last command executed unless you explicitly use the exit command. This implicit behavior can cause unexpected results:

#!/bin/bash
# implicit-exit.sh

echo "Starting process..."
mkdir /tmp/mydir  # Succeeds
ls /nonexistent   # Fails with exit code 2

# Script exits with code 2 (from ls), even though mkdir succeeded

Always exit explicitly in production scripts:

#!/bin/bash

if ! mkdir /tmp/mydir 2>/dev/null; then
    echo "Failed to create directory" >&2
    exit 1
fi

echo "Directory created successfully"
exit 0

Functions return exit codes using return instead of exit. Using exit inside a function terminates the entire script:

#!/bin/bash

validate_file() {
    local file=$1
    
    if [ ! -f "$file" ]; then
        echo "File not found: $file" >&2
        return 1
    fi
    
    if [ ! -r "$file" ]; then
        echo "File not readable: $file" >&2
        return 2
    fi
    
    return 0
}

# Using the function
if validate_file "/etc/passwd"; then
    echo "File is valid"
else
    echo "Validation failed with code: $?"
    exit 1
fi

Use trap to ensure proper exit codes even when errors occur:

#!/bin/bash

cleanup() {
    local exit_code=$?
    echo "Cleaning up..."
    rm -f /tmp/tempfile
    exit $exit_code
}

trap cleanup EXIT

# Your script logic here
touch /tmp/tempfile
some_command_that_might_fail

Testing and Handling Exit Codes

The most explicit way to handle exit codes is with conditional statements:

#!/bin/bash

if grep -q "pattern" /etc/hosts; then
    echo "Pattern found"
else
    exit_code=$?
    echo "Pattern not found (exit code: $exit_code)" >&2
    exit 1
fi

Use && and || for concise conditional execution:

#!/bin/bash

# && chains commands - next runs only if previous succeeded
mkdir /tmp/mydir && cd /tmp/mydir && touch file.txt

# || provides fallback - next runs only if previous failed
grep "pattern" file.txt || echo "Pattern not found"

# Combining both
command1 && command2 || command3
# Note: This doesn't mean "if-then-else"! 
# command3 runs if either command1 OR command2 fails

The set -e option makes your script exit immediately when any command fails:

#!/bin/bash
set -e  # Exit on any error

mkdir /tmp/mydir
cd /tmp/mydir
touch file.txt
# If any command fails, script exits immediately

However, set -e has limitations. It doesn’t affect commands in conditionals, and it won’t catch failures in pipelines unless you also use set -o pipefail:

#!/bin/bash
set -e
set -o pipefail  # Fail if ANY command in a pipeline fails

# Without pipefail, only the exit code of 'tail' matters
# With pipefail, failure in grep causes the entire pipeline to fail
grep "pattern" /var/log/syslog | tail -n 10 | process_data

A robust script template combines multiple approaches:

#!/bin/bash
set -euo pipefail  # Exit on error, undefined vars, pipe failures

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/var/log/myscript.log"

log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

error_exit() {
    log "ERROR: $1"
    exit "${2:-1}"
}

# Usage
mkdir /tmp/mydir || error_exit "Failed to create directory" 2
log "Directory created successfully"

Advanced Patterns and Best Practices

Create reusable error handling functions for consistent behavior:

#!/bin/bash

check_command() {
    if ! command -v "$1" &> /dev/null; then
        echo "Error: Required command '$1' not found" >&2
        exit 127
    fi
}

require_file() {
    if [ ! -f "$1" ]; then
        echo "Error: Required file not found: $1" >&2
        exit 2
    fi
}

# Validate prerequisites
check_command jq
check_command curl
require_file config.json

Use trap for cleanup operations that must run regardless of how the script exits:

#!/bin/bash
set -e

TEMP_DIR=""

cleanup() {
    local exit_code=$?
    if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then
        rm -rf "$TEMP_DIR"
    fi
    
    if [ $exit_code -ne 0 ]; then
        echo "Script failed with exit code: $exit_code" >&2
    fi
    
    exit $exit_code
}

trap cleanup EXIT INT TERM

# Create temp directory
TEMP_DIR=$(mktemp -d)
echo "Working in: $TEMP_DIR"

# Do work...
# Cleanup happens automatically on exit

For complex scripts, implement a standardized error reporting system:

#!/bin/bash

declare -A ERROR_CODES=(
    [SUCCESS]=0
    [GENERAL]=1
    [CONFIG_ERROR]=10
    [NETWORK_ERROR]=11
    [DATABASE_ERROR]=12
)

report_error() {
    local error_type=$1
    local message=$2
    local exit_code=${ERROR_CODES[$error_type]:-1}
    
    echo "[$error_type] $message" >&2
    logger -t "$(basename "$0")" -p user.error "$error_type: $message"
    
    return $exit_code
}

# Usage
if ! curl -s https://api.example.com/health > /dev/null; then
    report_error "NETWORK_ERROR" "API health check failed"
    exit $?
fi

Common Pitfalls and Debugging

Pipeline exit codes can be tricky. By default, a pipeline returns the exit code of the last command only:

#!/bin/bash

# This succeeds (exit 0) even though grep fails!
grep "nonexistent" /etc/hosts | wc -l
echo "Exit code: $?"  # 0, because wc succeeded

# Use PIPESTATUS to check each command
grep "nonexistent" /etc/hosts | wc -l
echo "grep exit code: ${PIPESTATUS[0]}"  # 1
echo "wc exit code: ${PIPESTATUS[1]}"    # 0

Always use set -o pipefail or check PIPESTATUS explicitly:

#!/bin/bash

# Method 1: pipefail
set -o pipefail
grep "pattern" file.txt | sort | uniq || {
    echo "Pipeline failed" >&2
    exit 1
}

# Method 2: PIPESTATUS
grep "pattern" file.txt | sort | uniq
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
    echo "grep failed" >&2
    exit 1
fi

Subshells and command substitution create new processes with their own exit codes:

#!/bin/bash
set -e

# This doesn't trigger 'set -e' because it's in a subshell
result=$(false; echo "still running")
echo "Result: $result"  # Prints "still running"

# Check the exit code explicitly
if ! result=$(command_that_might_fail); then
    echo "Command failed" >&2
    exit 1
fi

Background processes don’t affect the script’s exit code unless you explicitly wait for them:

#!/bin/bash

# Start background job
long_running_command &
job_pid=$!

# Do other work...

# Wait for background job and capture its exit code
if ! wait $job_pid; then
    echo "Background job failed" >&2
    exit 1
fi

Exit codes are the backbone of reliable bash scripts. Master them, and you’ll write scripts that fail gracefully, report errors clearly, and integrate seamlessly with other tools in the Unix ecosystem.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.