Shell by Example: Exit Codes POSIX + Bash

Every command returns an exit code (0-255). By convention, 0 means success and non-zero indicates an error. The special variable $? holds the last command’s exit code.

Successful command returns 0:

Edit
#!/bin/sh
true
echo "Exit code of 'true': $?"
Output:
Exit code of 'true': 0

Failed command returns non-zero:

Edit
#!/bin/sh
false
echo "Exit code of 'false': $?"
Output:
Exit code of 'false': 1

Common exit codes by convention:

  • 0 - Success
  • 1 - General errors
  • 2 - Misuse of shell command
  • 126 - Command not executable
  • 127 - Command not found
  • 128+N - Fatal error signal N

Check exit code with if statement:

Edit
#!/bin/sh
if grep -q "root" /etc/passwd; then
    echo "Found root user (grep returned 0)"
else
    echo "No root user found"
fi
Output:
Found root user (grep returned 0)

Using $? explicitly:

Edit
#!/bin/sh
ls /nonexistent 2>/dev/null
status=$?
if [ "$status" -ne 0 ]; then
    echo "ls failed with exit code: $status"
fi
Output:
ls failed with exit code: 2

The exit command sets the script’s exit code:

Edit
#!/bin/sh
run_check() {
    if [ -f "/etc/passwd" ]; then
        return 0 # Success
    else
        return 1 # Failure
    fi
}

run_check
echo "run_check returned: $?"
Output:
run_check returned: 0

Return codes in functions use return, not exit. exit would terminate the entire script.

Edit
#!/bin/sh
check_file() {
    [ -f "$1" ] # Return code is the test's result
}

check_file "/etc/passwd"
echo "Check /etc/passwd: $?"

check_file "/nonexistent"
echo "Check /nonexistent: $?"
Output:
Check /etc/passwd: 0
Check /nonexistent: 1

Chain commands based on exit codes:

Edit
#!/bin/sh
echo "Using && (run if previous succeeded):"
true && echo "  This runs because true succeeded"
false && echo "  This won't run"

echo "Using || (run if previous failed):"
false || echo "  This runs because false failed"
true || echo "  This won't run"
Output:
Using && (run if previous succeeded):
  This runs because true succeeded
Using || (run if previous failed):
  This runs because false failed

Combine && and || for simple conditionals:

Edit
#!/bin/sh
test -d "/tmp" && echo "/tmp exists" || echo "/tmp missing"
Output:
/tmp exists

Exit codes with pipes - $? gives last command’s code:

Edit
#!/bin/sh
echo "hello" | grep -q "world"
echo "Pipe exit code: $?"
Output:
Pipe exit code: 1

Bash

Bash provides a PIPESTATUS array for all pipe exit codes:

Edit
#!/bin/bash
cat /nonexistent 2>/dev/null | grep "pattern" | head -n 1
echo "First command: ${PIPESTATUS[0]}"
echo "Second command: ${PIPESTATUS[1]}"
echo "Third command: ${PIPESTATUS[2]}"
Output:
First command: 1
Second command: 
Third command: 

Use set -e to exit on first error:

Edit
#!/bin/sh
echo "Without set -e:"
(
    false
    echo "  This still runs"
)

echo "With set -e:"
(
    set -e
    true
    echo "  This runs"
    # false would cause exit here
)
Output:
Without set -e:
  This still runs
With set -e:
  This runs

Bash

Use set -o pipefail with set -e for pipe errors.

This makes the script exit if any command in a pipeline fails, not just the last one.

Edit
#!/bin/bash
set -eo pipefail

Custom exit codes for different error types:

Edit
#!/bin/sh
ERR_INVALID_ARG=1

validate_arg() {
    case "$1" in
        start | stop) return 0 ;;
        *) return "$ERR_INVALID_ARG" ;;
    esac
}

validate_arg "invalid"
code=$?
case $code in
    0) echo "Valid argument" ;;
    "$ERR_INVALID_ARG") echo "Invalid argument (code $code)" ;;
esac
Output:
Invalid argument (code 1)

Best practice: always check critical commands:

Edit
#!/bin/sh
if ! command -v ls >/dev/null 2>&1; then
    echo "ls command not found" >&2
    exit 127
fi

echo "Script completed successfully"
Output:
Script completed successfully

« Here Documents | Traps »