11.1. Exit Codes#

11.1.1. Common Pitfalls#

1. Forgetting that $? captures the last command

# Bad: $? now contains result of echo, not ls
ls /nonexistent
echo $?  # Wrong! This is exit code of echo (0), not ls

# Good: Save immediately
ls /nonexistent
code=$?
echo $code  # Correct!

2. Not checking exit codes in critical sections

# Bad: Continues even if mkdir fails
mkdir /mnt/backup
cd /mnt/backup
rm -rf *  # DISASTER if cd failed!

# Good: Check and exit
mkdir /mnt/backup || exit 1
cd /mnt/backup || exit 1
rm -rf *  # Safe now

3. Losing pipeline errors

# Bad: Only checks wc
cat huge_file | process | wc -l
# If process crashed, you won't notice

# Good: Use pipefail
set -o pipefail
cat huge_file | process | wc -l  # Fails if any command fails

4. Ignoring PIPESTATUS[0]

# Mistake: PIPESTATUS[0] is empty after accessing it
cmd1 | cmd2
echo ${PIPESTATUS[0]}  # Works
echo ${PIPESTATUS[0]}  # Now it's empty (array reset)!

# Save it first
pipeline_codes=${PIPESTATUS[@]}

11.1.2. Real-World Example: Robust Backup Function#

#!/bin/bash

# Production-grade backup with error handling
backup_with_verification() {
  local source=$1
  local dest=$2
  local log_file="/tmp/backup.log"
  
  # Validate inputs
  [[ -z "$source" ]] && { echo "Source required" >&2; return 2; }
  [[ -z "$dest" ]] && { echo "Dest required" >&2; return 2; }
  [[ ! -d "$source" ]] && { echo "Source not found" >&2; return 1; }
  
  # Create backup
  echo "[$(date)] Starting backup..." >> "$log_file"
  tar -czf "$dest/backup.tar.gz" "$source" 2>>"$log_file" || {
    echo "Backup failed" >&2
    return 1
  }
  
  # Verify backup integrity
  tar -tzf "$dest/backup.tar.gz" > /dev/null 2>&1 || {
    echo "Backup verification failed" >&2
    rm -f "$dest/backup.tar.gz"
    return 1
  }
  
  echo "[$(date)] Backup successful" >> "$log_file"
  return 0
}

# Usage with error handling
if backup_with_verification "/home/user" "/backups"; then
  echo "Backup completed successfully"
else
  exit_code=$?
  echo "Backup failed with code $exit_code"
  exit $exit_code
fi

11.1.3. Error Handling Patterns#

11.1.3.1. Pattern 1: Early Return#

safe_operation() {
  local file=$1
  
  # Check preconditions immediately
  [[ -f "$file" ]] || return 2
  [[ -r "$file" ]] || return 13  # Permission denied
  
  # Process file
  process "$file" || return 1
  
  return 0
}

11.1.3.2. Pattern 2: Error Accumulation#

# Track if any step failed
backup_data() {
  local errors=0
  
  backup_home || ((errors++))
  backup_config || ((errors++))
  backup_database || ((errors++))
  
  if [[ $errors -gt 0 ]]; then
    echo "Failed: $errors backup(s)" >&2
    return 1
  fi
  return 0
}

11.1.3.3. Pattern 3: Try-Catch Simulation#

try() {
  [[ $- = *e* ]]; local saved_e=$?
  set +e
}

catch() {
  export exception_code=$?
  (( saved_e )) && set +e
  return $exception_code
}

# Usage
try && {
  risky_command1
  risky_command2
} catch || {
  echo "Error occurred with code $exception_code"
}

11.1.4. Handling Errors in Pipelines#

By default, only the last command’s exit code is checked:

# Dangerous: gunzip fails, but script continues
gunzip backup.tar.gz | tar -x | verify_archive
echo $?  # Only checks verify_archive, not gunzip!

# Fix: Use pipefail
set -o pipefail
gunzip backup.tar.gz | tar -x | verify_archive
echo $?  # Now returns error from any command in pipeline

# Or check individual stages
gunzip backup.tar.gz || { echo "Gunzip failed"; exit 1; }
tar -xf backup.tar || { echo "Tar failed"; exit 1; }
verify_archive || { echo "Verify failed"; exit 1; }

11.1.4.1. PIPESTATUS Array#

# Capture all exit codes from pipeline
cmd1 | cmd2 | cmd3
echo ${PIPESTATUS[@]}  # Prints array of all exit codes

# Example
ls /nonexistent 2>/dev/null | grep pattern | wc -l
echo ${PIPESTATUS[@]}  # Might print: 2 1 0
# 2 = ls failed
# 1 = grep found nothing
# 0 = wc succeeded

11.1.5. Propagating Errors from Functions#

By default, only the last command’s exit code is returned:

# Bad: This always returns 0!
check_file() {
  [[ -f /nonexistent ]] 
  echo "Checking file"
  # Function returns exit code of echo (0), not the test!
}

check_file
echo $?  # Prints 0 (wrong!)

# Good: Explicitly return the error code
check_file() {
  [[ -f /nonexistent ]] || return 1
  echo "File exists"
  return 0
}

check_file
echo $?  # Prints 1 (correct!)

# Better: Let error propagate naturally
check_file() {
  local file=$1
  [[ -f "$file" ]] || { echo "File not found: $file" >&2; return 1; }
  echo "Processing $file"
}

11.1.6. Understanding Exit Codes#

Every command returns an exit code (status code) indicating success or failure:

  • 0: Command succeeded

  • 1-255: Command failed (reason varies by program)

  • 128+N: Signal termination (128 + signal number)

11.1.6.1. Checking Exit Codes#

# Immediate check with $?
ls /nonexistent
echo $?  # Prints 2 (file not found error)

# In conditional
if ls /nonexistent; then
  echo "Success"
else
  echo "Failed with code $?"
fi

# Direct comparison
grep pattern /nonexistent 2>/dev/null
if [[ $? -eq 0 ]]; then
  echo "Found"
elif [[ $? -eq 1 ]]; then
  echo "Not found"
elif [[ $? -eq 2 ]]; then
  echo "Grep error"
fi

11.1.6.2. Common Exit Codes#

Code

Meaning

0

Success

1

General error

2

Misuse of command

127

Command not found

128

Invalid signal

130

Terminated by Ctrl+C (SIGINT)