Exit Codes & Error Handling

Every command that finishes hands back a number saying whether it succeeded — zero for success, anything else for failure — and a handful of shell options turn that convention into real error handling.

$? — the last command's exit code

$ terminal
grep "ERROR" access.log
echo "Exit code: $?"
Terminal output
2026-09-08 ERROR disk full
Exit code: 0

0 always means success. Any non-zero value (1 through 255) means some kind of failure — grep specifically returns 1 when it finds no matching lines at all, which is why checking $? after a command is a normal way to branch on whether it worked.

Checking a command's success directly

bash check.sh
#!/bin/bash
if cp source.txt backup.txt; then
    echo "Backup succeeded"
else
    echo "Backup failed"
fi
Terminal output
Backup succeeded

An if can test a command directly, not just a [[ ]] expression — it treats exit code 0 as "true" and anything else as "false."

set -e, set -u, and pipefail

By default, Bash keeps going even after a command fails partway through a script. These options change that:

bash safe-script.sh
#!/bin/bash
set -euo pipefail

echo "Starting..."
cp missing-source.txt destination.txt
echo "This line never runs"
Terminal output
Starting...
cp: cannot stat 'missing-source.txt': No such file or directory
  • set -e — stop the whole script immediately if any command fails.
  • set -u — treat using an undefined variable as an error, instead of silently substituting an empty string.
  • set -o pipefail — make a pipeline's exit code reflect its last failing command, instead of always just the exit code of the final command in the pipe.
What set -e doesn't catch: a failing command inside an if/while condition, or on the left side of &&/||, does not trigger set -e — those contexts are specifically exempted, since the whole point of testing a command's success is to handle failure yourself. It also doesn't catch a failing command inside a pipeline unless pipefail is also set. Treat set -euo pipefail as a strong safety net, not a guarantee that every failure will be caught — explicit checks on commands you really care about are still worth writing.