Every command that runs — and every script — leaves behind a number saying how it ended: 0 for success, anything else for failure. Exit codes are the shell's entire error-handling protocol, and learning to read and write them is what turns a script from a list of steps into a reliable component.

Reading them #

$? holds the exit status of the last command:

Reading success and failure
$ grep -q aospix /etc/passwd
$ echo $?
1
$ true && echo $?
0

Zero means success — not true, not yes: the command did its job. Non-zero means it did not, and the specific number often names the reason: 1 generic failure, 2 misuse, 126 not executable, 127 command not found, 130 Ctrl+C. Programs you call define their own meanings beyond that; the man page's EXIT STATUS section tells you.

Writing them #

A script's exit code is the exit code of its last command — which is why "it printed an error but reported success" happens:

Failing loudly, on purpose
if [[ ! -f "$config" ]]; then
  echo "config not found: $config" >&2
  exit 1
fi

Two details: error messages go to stderr (>&2) so they do not pollute piped output, and the code travels without you — exit 1 or just letting the failing command be last.

Chaining on results #

&& and || are exit codes wearing shorthand:

Success chains, failure fallbacks
mkdir -p out && cp app.conf out/
curl -sf url || echo "check failed" >&2
cmd && echo ok || echo failed

The a && b || c idiom has one trap — if b fails, c runs too. For real branching, an if is clearer.

Functions and set -e #

Functions return codes the same way — return 1 or the last command's status — which makes them testable units:

ConstructResult
fn && nextnext runs only if fn succeeded
if fn; thenbranches on fn's code
! fnInverts the code
set -eScript exits on any unhandled failure

The parsing side of arguments is getopts; dispatch by value is case. The scripting series continues.