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 -eandset -o pipefailto 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.