Exit Codes & Error Handling
Every command you run on Linux finishes with a number called an exit code (also called an exit status or return code). It tells you whether the command succeeded or failed. By default a shell script ignores these numbers and just keeps running, which means a broken script can quietly do damage before you notice. This page shows you how to read exit codes, return your own, and turn a fragile script into one that stops the moment something goes wrong and cleans up after itself.
What an exit code is
When a program ends, it hands the shell a single integer between 0 and 255. The rule is simple: 0 means success, any non-zero number means failure. This convention is universal across Ubuntu and almost every command-line tool.
A few values have agreed-upon meanings:
| Code | Meaning |
|---|---|
0 | Success — everything worked |
1 | General error (the catch-all failure) |
2 | Misuse of a shell builtin (e.g. wrong arguments) |
126 | Command found but not executable (permission problem) |
127 | Command not found (typo or not installed) |
130 | Terminated by Ctrl+C (signal SIGINT) |
137 | Killed (signal SIGKILL, often the out-of-memory killer) |
Exit codes wrap around at 256. If your script does
exit 256you actually return 0, andexit -1becomes 255. Always stay in the 0–255 range, and reserve codes above 125 since the shell uses those for signals.
Reading the exit code with $?
The special variable $? holds the exit code of the last command that finished. You can check it right after running anything.
ls /etc/nginx
echo "ls exit code: $?"
ls /does/not/exist
echo "ls exit code: $?"
Output:
nginx.conf sites-available sites-enabled
ls exit code: 0
ls: cannot access '/does/not/exist': No such file or directory
ls exit code: 2
When to use this: read $? immediately after the command you care about. The value is overwritten by the very next command, so save it to a variable if you need it later:
systemctl is-active nginx
status=$?
if [ "$status" -ne 0 ]; then
echo "nginx is not running (code $status)"
fi
Returning your own exit code with exit N
Inside your own scripts, use exit N to tell whoever called the script what happened. This matters because other tools — cron, systemd, CI pipelines — decide what to do next based on your exit code.
#!/usr/bin/env bash
# backup.sh — fail loudly if the source folder is missing
SRC="/var/www/myapp"
if [ ! -d "$SRC" ]; then
echo "Error: $SRC does not exist" >&2
exit 1
fi
tar -czf /backups/myapp.tar.gz "$SRC"
echo "Backup complete"
exit 0
Note >&2 sends the error message to stderr (the standard error stream) instead of stdout (standard output). This keeps error text separate from normal output so logs and pipelines stay clean. A bare exit with no number returns the code of the last command run.
Defensive scripting with set -euo pipefail
By default Bash is forgiving in dangerous ways: it keeps going after a failed command, treats undefined variables as empty strings, and hides failures inside pipes. For any serious script, put this line right under the shebang:
#!/usr/bin/env bash
set -euo pipefail
Here is exactly what each flag does:
| Flag | Long form | What it does |
|---|---|---|
-e | set -o errexit | Exit immediately if any command fails (returns non-zero) |
-u | set -o nounset | Treat use of an unset variable as an error and exit |
-o pipefail | — | A pipeline fails if any command in it fails, not just the last one |
Why pipefail matters: without it, curl badurl | tee log.txt reports success because tee succeeded, hiding the failed curl. With pipefail, the whole pipeline reports the failure.
Here is the difference in practice. Without the flags:
#!/usr/bin/env bash
cp /etc/missing.conf /tmp/ # fails, but...
echo "still running!" # this runs anyway
echo "$UNDEFINED_VAR ok" # prints " ok" silently
Output:
cp: cannot stat '/etc/missing.conf': No such file or directory
still running!
ok
With set -euo pipefail the script stops at the first failure and the bug is obvious:
#!/usr/bin/env bash
set -euo pipefail
cp /etc/missing.conf /tmp/
echo "still running!"
Output:
cp: cannot stat '/etc/missing.conf': No such file or directory
When NOT to use set -e: when you genuinely expect a command might fail and want to handle it yourself. In that case, append || true or check $?:
set -euo pipefail
grep "ERROR" /var/log/syslog || true # grep returns 1 when nothing matches; that's fine here
Cleaning up with trap
trap runs a command when your script exits or receives a signal. This is how you guarantee cleanup — removing temp files, releasing locks, stopping a background process — even if the script crashes halfway through.
The most useful pattern catches the EXIT event, which fires no matter how the script ends (success, error, or interrupt):
#!/usr/bin/env bash
set -euo pipefail
WORKDIR=$(mktemp -d) # make a temp directory
trap 'rm -rf "$WORKDIR"' EXIT # always delete it on exit
echo "Working in $WORKDIR"
curl -fsSL https://example.com/data.tar.gz -o "$WORKDIR/data.tar.gz"
tar -xzf "$WORKDIR/data.tar.gz" -C "$WORKDIR"
# even if tar fails here, the trap still removes WORKDIR
You can trap specific signals too. INT is Ctrl+C and TERM is the signal systemctl stop or kill sends:
trap 'echo "Interrupted — shutting down cleanly"; exit 130' INT TERM
When to use traps: any script that creates temporary files, mounts a disk, holds a lock file, or starts a service it must stop later. If your script touches something that should not be left behind, add a trap.
A complete, robust template
#!/usr/bin/env bash
set -euo pipefail
# Cleanup runs on any exit
cleanup() {
rm -f "$LOCKFILE"
}
LOCKFILE="/tmp/deploy.lock"
trap cleanup EXIT
if [ -e "$LOCKFILE" ]; then
echo "Another deploy is running. Aborting." >&2
exit 1
fi
touch "$LOCKFILE"
echo "Deploying..."
sudo systemctl restart myapp
echo "Done."
exit 0
Best Practices
- Start every non-trivial script with
set -euo pipefaildirectly under the shebang. - Use
exit 0for success and a distinct non-zero code for each kind of failure so callers can react. - Send error messages to stderr with
>&2and keep normal output on stdout. - Always add a
trap ... EXITwhen you create temp files, locks, or background jobs. - For commands you expect might fail, handle them explicitly with
|| trueor anifcheck rather than lettingset -ekill the script. - Avoid exit codes above 125 — those are reserved for signals and will confuse callers.
- Test failure paths, not just the happy path, so your error handling actually fires.