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:
$ 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:
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:
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:
| Construct | Result |
|---|---|
fn && next | next runs only if fn succeeded |
if fn; then | branches on fn's code |
! fn | Inverts the code |
set -e | Script exits on any unhandled failure |
The parsing side of arguments is getopts; dispatch by value is case. The scripting series continues.
