Skip to content
DevOps devops shell 5 min read

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:

CodeMeaning
0Success — everything worked
1General error (the catch-all failure)
2Misuse of a shell builtin (e.g. wrong arguments)
126Command found but not executable (permission problem)
127Command not found (typo or not installed)
130Terminated by Ctrl+C (signal SIGINT)
137Killed (signal SIGKILL, often the out-of-memory killer)

Exit codes wrap around at 256. If your script does exit 256 you actually return 0, and exit -1 becomes 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:

FlagLong formWhat it does
-eset -o errexitExit immediately if any command fails (returns non-zero)
-uset -o nounsetTreat use of an unset variable as an error and exit
-o pipefailA 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 pipefail directly under the shebang.
  • Use exit 0 for success and a distinct non-zero code for each kind of failure so callers can react.
  • Send error messages to stderr with >&2 and keep normal output on stdout.
  • Always add a trap ... EXIT when you create temp files, locks, or background jobs.
  • For commands you expect might fail, handle them explicitly with || true or an if check rather than letting set -e kill 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.
Last updated June 15, 2026
Was this helpful?