Exit Codes and Error Handling
Commands like return, exit 1, and systemctl is-active --quiet have already shown up several times in earlier articles, all resting on one common mechanism: the exit code. This article opens that mechanism up fully, raising a Bash script from "seems to work" to "stops correctly and reports it when an error occurs" — a distinction that matters most for the production scripts written in the next module.
What You Will Learn
By the end of this article you will be able to:
- explain what an exit code is and read it via
$?; - stop a script with its own error code using
exit; - condition the next action on the previous command's result with
&&and||; - explain what each of
set -e,set -u,set -o pipefaildoes and when it can be risky; - run cleanup with
trapwhen a script exits or receives a signal.
What an Exit Code Is
On Linux, every command that runs — whether it succeeds or fails — returns an exit code: an integer from 0 to 255. By strict convention:
- 0 — success;
- 1–255 — failure, with the specific number sometimes indicating the type of error (varies command to command).
The most recently executed command's exit code is stored in the special $? variable:
$? refers only to the immediately preceding command — if any other command (even echo) runs in between, $? switches to that command's code instead. This is why the result needs to be checked right away or stored in a separate variable:
tar -czf /backup/data.tar.gz /data
status=$?
if [ "$status" -ne 0 ]; then
echo "Backup failed (code: $status)"
fi
Stopping a Script with Its Own Exit Code: exit
The exit command inside a script stops it immediately, making the given number the script's final exit code:
#!/bin/bash
if [ -z "$1" ]; then
echo "Error: an argument is required. Usage: $0 <file>" >&2
exit 1
fi
echo "File: $1"
If exit is omitted, the script ends with the last command's exit code — this is often unclear and can prevent the caller (for example, cron or another script) from correctly detecting success or failure. This is why writing an explicit exit at every important exit point is recommended: exit 0 for success, other numbers for various failure conditions.
Note
>&2 redirects the message to standard error (stderr) instead of standard output (stdout). This is an important habit for separating error messages from successful output: later, when the script's output is piped to another command or written to a file, error messages don't get mixed in with the actual result.
Matching Exit Codes to Convention
There's no single strict standard, but there's a widely followed convention:
| Code | Meaning |
|---|---|
0 |
success |
1 |
general error |
2 |
incorrect usage (e.g. a missing argument) |
126 |
the file was found but couldn't be executed (e.g. no permission) |
127 |
command not found |
128+N |
terminated by signal N (e.g. 130 = 128 + 2, i.e. Ctrl+C / SIGINT) |
In a script with several possible error types, assigning each one a distinct code helps the caller (or a monitoring system) know exactly what went wrong. This approach is applied directly in the Healthcheck Script project, since monitoring systems often interpret different exit codes differently (for example, in Nagios-style checks, 0 = OK, 1 = WARNING, 2 = CRITICAL).
&& and ||: Conditioning the Next Action on the Exit Code
&& — runs the next command only if the previous one succeeded (exit code 0):
|| — runs the next command only if the previous one failed (any exit code other than 0):
Combined, they express the "if success, X, otherwise Y" pattern:
Warning
This three-part pattern (A && B || C) needs care: if B also fails (for example, echo itself very rarely fails, but in theory it could), C also runs — leading to a confusing result. For complex logic, a plain if/else is clearer and safer.
set -e, set -u, set -o pipefail: Stricter Error Handling
By default, a Bash script keeps going even after an error — for example, if cd fails, the next line's rm -rf * still runs in the current (wrong) directory. Three settings help prevent this:
If any command ends with a non-zero exit code, the script stops immediately (unless the command is used as an if/while condition, or inside &&/||).
If an undeclared (or unset) variable is used, the script stops with an error — immediately exposing a misspelled variable name ($FIEL instead of $FILE).
If any command in a pipeline (cmd1 | cmd2) fails, the entire pipeline is considered to have failed. By default, Bash only looks at the last command's exit code in a pipeline — which means, for example, in cat missing_file.txt | grep pattern, even if cat fails, if grep returns "not found" as its own failure, the whole pipeline can falsely appear successful.
Using all three together is common practice, often written on one line at the start of a script:
#!/bin/bash
set -euo pipefail
SOURCE_DIR="/data"
BACKUP_DIR="/backup"
tar -czf "${BACKUP_DIR}/data.tar.gz" "$SOURCE_DIR"
echo "Backup completed successfully"
If SOURCE_DIR or BACKUP_DIR doesn't exist, or tar fails for any reason, the script stops immediately, without printing the false "Backup completed successfully" message.
Danger
set -e doesn't cover every case and can sometimes stop a script at an unexpected point — for example, if a test failing inside if failing_command; then is an expected situation, set -e generally handles this correctly, but in some more complex cases inside a function (for example, inside command substitution) it may not behave as expected. This is why set -euo pipefail isn't a "magic fix" — it raises the volume on errors, but an explicit check after an important action (like deleting a file) is still recommended. Always test a production script in a lab environment, deliberately triggering error conditions, before deploying it.
trap: Cleaning Up on Exit or Signal
A script might create a temporary file or set a lock while running. If the script stops unexpectedly (an error, or the user pressing Ctrl+C), that temporary state can be left behind. trap lets you automatically run a given command/function on a specific signal or when the script exits:
#!/bin/bash
set -euo pipefail
LOCK_FILE="/tmp/backup.lock"
cleanup() {
rm -f "$LOCK_FILE"
echo "Cleanup complete"
}
trap cleanup EXIT
touch "$LOCK_FILE"
echo "Working..."
sleep 5
echo "Done"
EXIT is a special pseudo-signal that fires no matter how the script ends (successfully, with an error, or via exit). This is the most reliable place to clean up temporary files or release a lock, since it runs even if the script stops at an unexpected line because of set -e.
It can also be used with signal names — for example, printing a special message when the user presses Ctrl+C (SIGINT):
Practical Scenario: a Safe Backup Script — Detecting Failure Ahead of Time
Task: check upfront that the source directory exists, only archive it after that, and report an error with a clear exit code.
#!/bin/bash
set -euo pipefail
SOURCE_DIR="${1:-}"
BACKUP_DIR="/backup"
if [ -z "$SOURCE_DIR" ]; then
echo "Error: no source directory given. Usage: $0 <directory>" >&2
exit 2
fi
if [ ! -d "$SOURCE_DIR" ]; then
echo "Error: directory '$SOURCE_DIR' not found" >&2
exit 1
fi
mkdir -p "$BACKUP_DIR"
archive_name="${BACKUP_DIR}/$(basename "$SOURCE_DIR")-$(date +%Y%m%d).tar.gz"
if tar -czf "$archive_name" "$SOURCE_DIR"; then
echo "Backup successful: $archive_name"
exit 0
else
echo "Error: archiving failed" >&2
exit 1
fi
${1:-} is a parameter expansion syntax: if $1 isn't given, it returns an empty string instead of an error (this is needed so that, with set -u enabled, a missing $1 doesn't cause an error). Every possible failure case (a missing argument, a missing directory, tar failing) is checked separately and exits cleanly with a matching code — this makes it possible, when the script is run via cron or called from another script, to reliably detect success or failure.
Common Mistakes
Checking $? Too Late
Here $? has already switched to echo's exit code (almost always 0), not tar's result. Read $? immediately after the command, or store the result in a variable.
Trusting set -e and Not Checking After an Important Action Anyway
set -e covers many cases, but a command used inside a function, or as part of an &&/|| chain, might sometimes not stop the script as expected. Always leave an explicit if/exit-code check after irreversible actions like deleting a file or writing to a database.
Writing Error Messages to stdout
This message goes into the same stream as ordinary output. If the script's output is passed to another program (for example, a monitoring system), success and error messages don't get separated. Correct: echo "Error: ..." >&2.
Placing trap at the End of the Script, Somewhere "Never Reached"
Set trap as soon as possible after the resource that needs cleanup (a temporary file, a lock) is created. If trap is written at the end of the script, and an error stops the script partway through, trap itself never runs.
Exercises
- Run
safe_backup.shwithoutset -euo pipefail, deliberately calling it with a nonexistent directory; then compare withset -euo pipefailenabled — explain the difference in the result. - Using
trap cleanup EXIT, write a small script that reliably deletes a temporary file; test it both with a normal exit and withkill -TERM, confirming cleanup works in both cases. - Using the exit code table, write a script that returns two different error types (a missing argument and a missing file) with distinct codes, confirming each with
echo "$?". - Compare the
$?value offalse | truewithset -o pipefailenabled and disabled.
Summary
An exit code is the universal language expressing success or failure for any command or script. $?, exit, and &&/|| let you read and condition on that state; set -euo pipefail makes Bash's default "keep going" behavior sensitive to errors; and trap guarantees cleanup no matter how a script ends. With these tools, it's now possible to move to the module's practical part — the Backup Script, Healthcheck Script, and Log Cleanup Script projects, which combine every earlier topic (variables, conditionals, loops, functions, exit codes) into one working production script.