Skip to content

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 pipefail does and when it can be risky;
  • run cleanup with trap when 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:

ls /etc/passwd
echo "Exit code: $?"
/etc/passwd
Exit code: 0
ls /etc/nofile
echo "Exit code: $?"
ls: cannot access '/etc/nofile': No such file or directory
Exit code: 2

$? 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):

mkdir -p /backup/2026-07-26 && tar -czf /backup/2026-07-26/data.tar.gz /data

|| — runs the next command only if the previous one failed (any exit code other than 0):

tar -czf /backup/data.tar.gz /data || echo "Backup failed" >&2

Combined, they express the "if success, X, otherwise Y" pattern:

tar -czf /backup/data.tar.gz /data && echo "Backup OK" || echo "Backup failed" >&2

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:

set -e

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 &&/||).

set -u

If an undeclared (or unset) variable is used, the script stops with an error — immediately exposing a misspelled variable name ($FIEL instead of $FILE).

set -o pipefail

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
#!/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"
Working...
Done
Cleanup complete

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):

trap 'echo "Script stopped by the user"; cleanup; exit 130' 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
./safe_backup.sh /data
echo "Script exit code: $?"
Backup successful: /backup/data-20260726.tar.gz
Script exit code: 0

${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

tar -czf backup.tar.gz /data
echo "Started"
if [ $? -ne 0 ]; then

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

echo "Error: file not found"

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

  1. Run safe_backup.sh without set -euo pipefail, deliberately calling it with a nonexistent directory; then compare with set -euo pipefail enabled — explain the difference in the result.
  2. Using trap cleanup EXIT, write a small script that reliably deletes a temporary file; test it both with a normal exit and with kill -TERM, confirming cleanup works in both cases.
  3. 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 "$?".
  4. Compare the $? value of false | true with set -o pipefail enabled 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.

References