Bash Scripting: A Deep Dive into Exit Codes and Status
Understand Bash exit codes, inspect $? safely, set statuses with exit, and build reliable control flow.
Bash Scripting: A Deep Dive into Exit Codes and Status
Bash exit codes are how commands tell your script what happened. 0 means success, and a non-zero status means the command failed or produced a result your script needs to handle.
This guide shows you how to read $?, set statuses with exit, and use exit codes to build safer control flow in Bash automation.
Understanding Exit Codes
Every command, function, or script executed in Bash returns an exit code upon completion. This is an integer value that signals the outcome of the execution. By convention:
0(Zero): Indicates success. The command completed without any errors.Non-zero(Any other integer): Indicates failure or an error. Different non-zero values can sometimes signify specific types of errors.
This simple 0 vs. non-zero convention is fundamental to how Bash operates and how you can build conditional logic into your scripts.
Retrieving the Last Exit Code: $?
Bash provides a special parameter, $?, which holds the exit code of the most recently executed foreground command. You can check its value immediately after any command to determine its outcome.
# Example 1: Successful command
ls /tmp
echo "Exit code for 'ls /tmp': $?"
# Example 2: Failed command (non-existent directory)
ls /nonexistent_directory
echo "Exit code for 'ls /nonexistent_directory': $?"
# Example 3: Grep finding a match (success)
grep "root" /etc/passwd
echo "Exit code for 'grep root /etc/passwd': $?"
# Example 4: Grep not finding a match (failure, but expected)
grep "nonexistent_user" /etc/passwd
echo "Exit code for 'grep nonexistent_user /etc/passwd': $?"
Output (may vary slightly depending on your system and /etc/passwd content):
ls /tmp
# ... (list of files in /tmp)
Exit code for 'ls /tmp': 0
ls /nonexistent_directory
ls: cannot access '/nonexistent_directory': No such file or directory
Exit code for 'ls /nonexistent_directory': 2
grep "root" /etc/passwd
root:x:0:0:root:/root:/bin/bash
Exit code for 'grep root /etc/passwd': 0
grep "nonexistent_user" /etc/passwd
Exit code for 'grep nonexistent_user /etc/passwd': 1
Notice that grep returns 0 for a match and 1 for no match. Both are valid outcomes in grep's context, but for conditional logic, 0 signifies the successful finding of the pattern.
Setting Exit Codes Explicitly with exit
When writing your own scripts or functions, you can explicitly set their exit code using the exit command followed by an integer value. This is crucial for communicating the script's outcome to calling processes, parent scripts, or CI/CD pipelines.
#!/bin/bash
# script_success.sh
echo "This script will exit with success (0)"
exit 0
#!/bin/bash
# script_failure.sh
echo "This script will exit with failure (1)"
exit 1
# Test the scripts
./script_success.sh
echo "Status of script_success.sh: $?"
./script_failure.sh
echo "Status of script_failure.sh: $?"
Output:
This script will exit with success (0)
Status of script_success.sh: 0
This script will exit with failure (1)
Status of script_failure.sh: 1
Tip: If
exitis called without an argument, the script's exit status will be the exit status of the last executed command beforeexitwas called.
Leveraging Exit Codes for Control Flow
Exit codes are the backbone of conditional execution in Bash, enabling you to create dynamic and responsive scripts.
Conditional Statements (if/else)
The if statement in Bash evaluates the exit code of a command. If the command exits with 0 (success), the if block is executed. Otherwise, the else block (if present) is executed.
#!/bin/bash
FILE="/path/to/my/important_file.txt"
if [ -f "$FILE" ]; then # The test command `[` exits 0 if file exists
echo "File '$FILE' exists. Proceeding with processing..."
# Add file processing logic here
# Example: cat "$FILE"
exit 0
else
echo "Error: File '$FILE' does not exist."
echo "Aborting script."
exit 1
fi
Logical Operators (&&, ||)
Bash provides powerful short-circuiting logical operators that depend on exit codes:
command1 && command2:command2is executed only ifcommand1exits with0(success).command1 || command2:command2is executed only ifcommand1exits with anon-zerovalue (failure).
These are extremely useful for sequential commands and fallback mechanisms.
#!/bin/bash
LOG_DIR="/var/log/my_app"
# Create directory only if it doesn't exist
mkdir -p "$LOG_DIR" && echo "Log directory '$LOG_DIR' ensured."
# Try to start a service, if it fails, try a fallback command
systemctl start my_service || { echo "Failed to start my_service. Attempting fallback..."; ./start_fallback.sh; }
# A command that must succeed for the script to continue
copy_data_to_backup_location && echo "Data backup successful." || { echo "Data backup failed!"; exit 1; }
echo "Script completed successfully."
exit 0
set -e: Exit on Error
The set -e option is a powerful tool for making your scripts more robust. When set -e is active, Bash will immediately exit the script if any command exits with a non-zero status. This prevents silent failures and cascading errors.
#!/bin/bash
set -e # Exit immediately if a command exits with a non-zero status
echo "Starting script..."
# This command will succeed
ls /tmp
echo "First command succeeded."
# This command will fail, and because of 'set -e', the script will exit here
ls /nonexistent_path
echo "This line will never be reached if the previous command failed."
exit 0 # This line will only be reached if all preceding commands succeeded
Output (if /nonexistent_path does not exist):
Starting script...
# ... (output of ls /tmp)
First command succeeded.
ls: cannot access '/nonexistent_path': No such file or directory
The script terminates after the failed ls command, and the "This line will never be reached" message is not printed.
Warning:
set -ehas exceptions, and some commands legitimately return non-zero for expected outcomes. For example,grepreturns1when it finds no match. Prefer an explicitif grep -q "pattern" file; then ... fiwhen you care about the result.
Common Exit Code Scenarios and Best Practices
While 0 for success and non-zero for failure is the general rule, some non-zero codes have common meanings, especially for system commands and built-ins:
0: Success.1: General error, catchall for miscellaneous issues.2: Misuse of shell builtins or incorrect command arguments.126: Command invoked cannot execute (e.g., permissions issue, not an executable).127: Command not found (e.g., typo in command name, not inPATH).128 + N: The command was terminated by signalN. For example,130(128 + 2) means the command was terminated bySIGINT(Ctrl+C).
When creating your own scripts, stick to 0 for success. For failures, 1 is a safe default for a general error. If your script handles multiple distinct error conditions, you can use higher non-zero values (e.g., 10, 20, 30) to differentiate them, but document these custom codes clearly.
Best Practices for Robust Scripting:
- Always Check Critical Commands: Don't assume success. Use
ifstatements or&&to verify critical steps. - Provide Informative Error Messages: When a script fails, print clear messages to
stderrexplaining what went wrong and how to potentially fix it. Use>&2to redirect output to standard error.my_command || { echo "Error: my_command failed. Check logs." >&2; exit 1; } - Clean Up on Failure: Use
trapto ensure temporary files or resources are cleaned up even if the script exits prematurely.cleanup() { echo "Cleaning up temporary files..." rm -f /tmp/my_temp_file_$$ } trap cleanup EXIT - Validate Inputs: Check script arguments or environment variables early and exit with an informative error if they are invalid.
- Log Exit Status: For complex automation, log the exit status of key operations for auditing and debugging purposes.
Real-World Example: A Robust Backup Script Snippet
Here's how you might combine these concepts in a practical scenario:
#!/bin/bash
set -e # Exit immediately if a command exits with a non-zero status
BACKUP_SOURCE="/data/app/config"
BACKUP_DEST="/mnt/backup/configs"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
LOG_FILE="/var/log/backup_config_${TIMESTAMP}.log"
# --- Functions ---
log_message() {
echo "$(date +%Y-%m-%d_%H:%M:%S) - $1" | tee -a "$LOG_FILE"
}
cleanup() {
log_message "Cleanup initiated."
if [ -n "${TEMP_DIR:-}" ] && [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
log_message "Removed temporary directory: $TEMP_DIR"
fi
}
# --- Trap for exit and signals ---
trap 'cleanup' EXIT
trap 'log_message "Script interrupted (SIGINT). Exiting."; exit 130' INT
trap 'log_message "Script terminated (SIGTERM). Exiting."; exit 143' TERM
# --- Main Script Logic ---
log_message "Starting configuration backup."
# 1. Check if source directory exists
if [ ! -d "$BACKUP_SOURCE" ]; then
log_message "Error: Backup source '$BACKUP_SOURCE' does not exist." >&2
exit 2 # Custom error code for invalid source
fi
# 2. Ensure backup destination exists
mkdir -p "$BACKUP_DEST" || {
log_message "Error: Failed to create/ensure backup destination '$BACKUP_DEST'." >&2
exit 3 # Custom error code for destination issue
}
# 3. Create a temporary directory for compression
TEMP_DIR=$(mktemp -d)
log_message "Created temporary directory: $TEMP_DIR"
# 4. Copy data to temporary directory
cp -r "$BACKUP_SOURCE" "$TEMP_DIR/" || {
log_message "Error: Failed to copy data from '$BACKUP_SOURCE' to '$TEMP_DIR'." >&2
exit 4 # Custom error code for copy failure
}
log_message "Data copied to temporary location."
# 5. Compress the data
ARCHIVE_NAME="config_backup_${TIMESTAMP}.tar.gz"
tar -czf "$TEMP_DIR/$ARCHIVE_NAME" -C "$TEMP_DIR" "$(basename "$BACKUP_SOURCE")" || {
log_message "Error: Failed to compress data." >&2
exit 5 # Custom error code for compression failure
}
log_message "Data compressed into $ARCHIVE_NAME."
# 6. Move the archive to the final destination
mv "$TEMP_DIR/$ARCHIVE_NAME" "$BACKUP_DEST/" || {
log_message "Error: Failed to move archive to '$BACKUP_DEST'." >&2
exit 6 # Custom error code for move failure
}
log_message "Archive moved to '$BACKUP_DEST/$ARCHIVE_NAME'."
log_message "Backup completed successfully!"
exit 0
Takeaway
Treat exit codes as part of your script's interface. Check critical commands, return clear non-zero statuses on failure, and document any custom codes that another script or CI job might need to interpret.