Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 21 · From Script to Reusable Tool

Exit Codes as a Contract

Chapter 15.6 taught the convention: 0 worked, 1 failed, 2 you called it wrongly. Every script since has followed it. This chapter makes it an interface rather than a habit — because the exit status is the only part of a tool's output a machine can branch on, which gives it the same obligations as the name of a flag.

Advanced18 min readLinuxPart IBashExit StatusInterfaceScripting

Part I · Module 21 · Chapter 21.4 · Exit Codes as a Contract

A human reading your tool's output can tell a licence failure from a compile error from a test failure. An automated caller cannot read the output at all. It gets one integer.

1. The Status Is an Interface

Which numbers are yours, and what yours promise

Why the exit status is an interface. Some numbers are not yours to use: the shell already assigns meanings to the ones above a hundred and twenty five. Below that, each number you return is a promise a caller can branch on, which means it has to be documented and must not change — the same obligation as the name of a flag.Some numbers are notyoursthe shell already assignsmeaningsA missing or unrunnablecommandand anything killed by asignalSo stay well below thatrangeand never return a countThe rest are yours todefineone meaning per numberEach one is a promisea caller writes a branchagainst itSo document it and keepitchanging one breaks callerssilently12
Some values are not yours to assign: the shell already uses the ones above a hundred and twenty five for a command it could not run and for anything killed by a signal. Below that, each number you return is a promise a caller writes a branch against — which means it has to be documented and must not change, exactly like the name of a flag.

Here is what a caller does with it, and it is the whole argument:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
./check-env.sh
case $? in
  0) run_regression ;;
  2) note "fix your invocation"; exit 2 ;;
  3) note "licence server unreachable — retrying in 10m"; sleep 600; retry ;;
  4) note "tools not installed on this host — moving the job"; requeue ;;
  *) note "unexpected status"; exit 1 ;;
esac

Four different reactions to four different numbers. The caller cannot grep your output for "licence" — your wording will change, your output may be on stderr, and in a scheduler it may not be captured at all. The number is the API.

2. The Numbers That Are Not Yours

Measured, each produced deliberately:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ touch ne.sh && chmod 644 ne.sh && ./ne.sh
bash: ./ne.sh: Permission denied
$ echo $?
126

$ bash -c 'nosuchcommand'
bash: nosuchcommand: command not found
$ echo $?
127

$ bash -c 'kill -TERM $$'
$ echo $?
143
ValueMeansSet by
0successyou
1–125yours to defineyou
126found but not executablethe shell
127command not foundthe shell
128+Nkilled by signal Nthe shell
130interrupted (128+2)the shell
143terminated (128+15)the shell
255often "out of range"see below

So 1 to 125 is your range, and using anything above it means a caller cannot distinguish your meaning from the shell's. A script that returns 127 for "config not found" is indistinguishable from a script that does not exist — which is precisely the case a caller most needs to tell apart.

Two more measurements worth having:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'exit 256'; echo $?
0
$ bash -c 'exit -1'; echo $?
255

exit 256 is 0. The status is one byte, so 256 wraps to zero and a failure is reported as success. Chapter 15.6 measured it for scripts and Chapter 18.3 for functions; it is the reason for the rule that follows.

3. Choosing Your Numbers

Three or four meanings is almost always enough, and the first three are conventional:

StatusMeaning
0it worked
1the work failed — tests failed, the build broke
2you called it wrongly — bad arguments, bad configuration
3+your own distinctions

0, 1 and 2 are worth treating as fixed, because they are what every tool in this course has used and what a reader will assume. The interesting decisions are above 2, and the test for each one is: would a caller do something different?

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
exit: 0  every test passed
      1  at least one test failed
      2  bad invocation or configuration
      3  no licence available — a retry may succeed
      4  the environment is not set up — a retry will not help

That table earns its keep because 3 and 4 lead to opposite actions: retry later versus move the job to a different host. A caller that cannot tell them apart either retries forever or gives up immediately, and both are wrong half the time.

By contrast, splitting 1 into "a test failed" and "two tests failed" earns nothing — no caller branches on it, and the count is already in the output.

4. Document It Where the Caller Looks

Three places, and the cost of all three is a dozen lines:

In the help text, because that is what a human reads:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Exit status:
  0  every test passed
  1  at least one test failed
  2  bad invocation or configuration
  3  no licence available (a retry may succeed)
  4  the environment is not set up (a retry will not help)

In the header comment, so head is documentation:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# run-suite — run a regression suite
#
# usage: run-suite [-v] [-s SEEDS] <testlist>
#
# exit: 0 all passed · 1 a test failed · 2 bad invocation
#       3 no licence (retryable) · 4 environment not set up

And as named constants in the script itself, which is the part people skip:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
readonly EX_OK=0
readonly EX_FAIL=1
readonly EX_USAGE=2
readonly EX_NOLICENCE=3
readonly EX_NOENV=4
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
die_usage() { note "$*"; usage; exit "$EX_USAGE"; }
have vcs || { note "vcs is not on PATH"; exit "$EX_NOENV"; }

exit "$EX_NOENV" says what it means at the call site, and readonly means a later edit cannot quietly reassign it — Chapter 18.5's local -r at script level. The constants also give you one place to grep when you want to know every exit path: grep -n 'exit "\$EX_' run-suite.

5. Changing a Code Breaks Callers Silently

This is why it is a contract rather than a convention. A caller written against your table:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
./check-env.sh
case $? in
  0) run_regression ;;
  3) sleep 600; retry ;;
  4) requeue ;;
  *) note "unexpected status $?"; exit 1 ;;
esac

Now you decide 3 and 4 were the wrong way round and swap them. Nothing errors. The caller's case still matches both numbers — it just does the wrong thing with each, retrying forever on a host where the tools are not installed and requeueing when a ten-minute wait would have worked.

And if you renumber — 3 becomes 5 — the caller falls through to *), logs "unexpected status", and exits 1. Which at least says something, but says it about the wrong layer.

So treat the numbers like flag names:

  • Add freely. A new code is a new meaning; a caller's *) arm catches it until they care.
  • Never reuse. If 3 stops meaning "no licence", retire 3 and use 5. A retired number is cheaper than a silently wrong branch.
  • Never renumber. There is no deprecation warning for an integer.
  • And give callers a catch-all to land in, by documenting that unlisted non-zero values mean "failed, reason not categorised".

6. The Status Only Arrives If the Caller Lets It

One interaction worth stating, because it undoes everything above. Chapter 20.2 measured that set -e is suspended at a tested call site — and the corollary is that your carefully chosen status is only visible if the caller captures it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat caller.sh
set -e
./check-env.sh          # exits 4
echo "NOT REACHED"

$ bash caller.sh; echo "caller rc=$?"
caller rc=4

Good — a bare call propagates it. But:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat caller2.sh
set -e
if ./check-env.sh; then echo "env ok"; else echo "env not ok"; fi
echo "and we continue"

$ bash caller2.sh; echo "caller rc=$?"
env not ok
and we continue
caller rc=0

The if consumed the status and flattened four distinct meanings into one boolean. That is the caller's choice and it is often the right one — but it means a tool with a five-value contract should say, in its help text, that case $? is the way to use it, because if tool; then throws the contract away and nothing warns you.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Exit status (use `case $?` to distinguish; `if` collapses these to pass/fail):
  0  every test passed
  ...

7. A Complete Pair

A tool with a real contract, and a caller that uses it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# check-env — verify this host can run a regression
#
# usage: check-env [-v]
#
# exit: 0 ready
#       2 bad invocation
#       3 no licence available      (retryable — try another host or wait)
#       4 tools not installed       (not retryable on this host)
#       5 scratch space exhausted   (retryable after cleanup)
#
# Use `case $?` to distinguish these; `if check-env; then` collapses
# them to pass/fail and discards the reason.

set -o errexit
set -o nounset
set -o pipefail

readonly EX_OK=0
readonly EX_USAGE=2
readonly EX_NOLICENCE=3
readonly EX_NOTOOLS=4
readonly EX_NOSPACE=5

note()  { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
usage() { printf 'usage: %s [-v]\n' "${0##*/}" >&2; }
have()  { command -v "$1" > /dev/null 2>&1; }

verbose=0
while getopts ':v' opt; do
  case $opt in
    v)  verbose=1 ;;
    \?) note "unknown option: -$OPTARG"; usage; exit "$EX_USAGE" ;;
  esac
done
shift $(( OPTIND - 1 ))
[ $# -eq 0 ] || { usage; exit "$EX_USAGE"; }

say() { [ "$verbose" -eq 0 ] || note "$*"; }

# ---- the checks, each mapped to one documented status -------------------

say 'checking for the tools'
if ! have make; then
  note 'make is not on PATH'
  exit "$EX_NOTOOLS"
fi

say 'checking scratch space'
# stand-in: a sentinel file means "full"
if [ -f ./SCRATCH_FULL ]; then
  note 'scratch space is exhausted'
  exit "$EX_NOSPACE"
fi

say 'checking for a licence'
# stand-in: a sentinel file means "no licence"
if [ -f ./NO_LICENCE ]; then
  note 'no licence available'
  exit "$EX_NOLICENCE"
fi

say 'all checks passed'
exit "$EX_OK"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# driver — use check-env's contract
set -o nounset

note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }

./check-env
case $? in
  0) note 'ready — starting the regression'; printf 'STARTED\n' ;;
  2) note 'driver called check-env wrongly — this is a bug in driver'; exit 2 ;;
  3) note 'no licence; would sleep 600 and retry'; printf 'RETRY\n' ;;
  4) note 'tools missing; would requeue on another host'; printf 'REQUEUE\n' ;;
  5) note 'scratch full; would clean up and retry'; printf 'CLEANUP\n' ;;
  *) note "unexpected status from check-env"; exit 1 ;;
esac
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./driver
check-env: all checks passed
driver: ready — starting the regression
STARTED

$ touch NO_LICENCE && ./driver
check-env: no licence available
driver: no licence; would sleep 600 and retry
RETRY

$ rm NO_LICENCE && touch SCRATCH_FULL && ./driver
check-env: scratch space is exhausted
driver: scratch full; would clean up and retry
CLEANUP

$ rm SCRATCH_FULL
$ PATH=/usr/bin:/bin ./driver            # make is still present here
check-env: all checks passed
driver: ready — starting the regression
STARTED

$ ./check-env extra-arg
usage: check-env [-v]
$ echo $?
2

$ ./check-env -z
check-env: unknown option: -z
usage: check-env [-v]
$ echo $?
2

And the measured consequence of a caller using if instead:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat if-driver
#!/usr/bin/env bash
if ./check-env; then echo "ready"; else echo "not ready"; fi

$ touch NO_LICENCE && ./if-driver
check-env: no licence available
not ready
$ rm NO_LICENCE && touch SCRATCH_FULL && ./if-driver
check-env: scratch space is exhausted
not ready

Two completely different situations, one word of output. The reason is still on stderr for a human, and the caller has thrown away the only part a machine could act on. Which is why the header comment says so explicitly.

Five decisions:

Five statuses, and each one leads the caller somewhere different — start, fix the driver, wait, requeue, clean up. That is the test from §3, applied; there is no status here that two callers would treat identically.

3 and 5 are retryable, 4 is not, and the help text says which. That distinction is the entire value of having more than one failure code, and it is the kind of thing only the tool knows.

Named constants, readonly. exit "$EX_NOLICENCE" at the call site, and grep -n 'exit "\$EX_' check-env lists every exit path in the file.

2 means "the caller has a bug", which is why driver exits 2 itself on receiving 2 rather than retrying — a usage error from a script calling another script is not a transient condition.

The *) arm exists so that adding a status 6 later does not break driver; it logs and fails rather than falling through silently. §5's rule about giving callers somewhere to land.

Practice

  1. Produce 126, 127 and 143 deliberately, one command each. Then produce 130 and say what sent it.
  2. Run exit 256 and exit 300 in a subshell and print the status. Explain both using the one-byte rule.
  3. Write a script that ends exit "$failed" where failed is 256. Confirm it reports success, then fix it with the bare-test ending.
  4. Write a five-row exit table for a tool of your own. For each row, name the different thing a caller would do — and delete any row where you cannot.
  5. Add readonly EX_* constants to a script with three exit paths. Then run grep -n 'exit "\$EX_' and say what that gives you that grep exit did not.
  6. Write driver with a case $? against five statuses, then rewrite it with if tool; then. Run both against two different failure causes and compare the output.
  7. Swap the meanings of two statuses in a tool without changing the numbers. Run the original caller and describe what it now does wrong.
  8. Renumber a status from 3 to 5. Run the original caller and note which arm it lands in.
  9. Find sysexits.h on your machine — it may not be in /usr/include; try xcrun --show-sdk-path or man 3 sysexits. Read EX_USAGE. Then measure what grep, diff and git actually return for a usage error. What does that tell you about adopting it?
  10. Add a status 6 to check-env without touching driver. Confirm driver still behaves sanely, and name the arm that saved you.

Check Your Understanding

  1. Why is the exit status the only part of a tool's output an automated caller can use?
  2. Which values are not yours, and what does the shell use each for?
  3. What is the highest status you should return, and why?
  4. What happens to exit 256, and what rule follows from it?
  5. What is the test for whether a new status earns its place?
  6. Why must you never reuse or renumber a status?
  7. A caller writes if tool; then. What does that do to a five-value contract?

A simulator is the extreme case of this contract being ignored: Chapter 27.1 measures three stages all returning zero on a design with no design-under-test in it.

For the sharpest violation of this contract, Chapter 29.2 measures a compile that reported 256 errors, produced no executable, and exited 0 — because the status was the error count truncated to one byte.

8. Remember

  • A regression is where this contract earns its keep: Chapter 32.7 measures four values divided by who acts, and the one-character mistakes that destroy them — exit 256 reading as 0, and a pipe to tee reading as success.
  • The exit status is the only part of your output a machine can branch on, which makes it an interface.
  • 1 to 125 are yours. 126 is not executable, 127 is not found, 128+N is signal N — 130 interrupt, 143 terminate.
  • exit 256 is 0. Never return a count; put it in the output and let the status carry the category.
  • 0, 1 and 2 are conventional: worked, the work failed, you called it wrongly.
  • A new status earns its place only if a caller would do something different. Otherwise it is the same status.
  • Retryable versus not is the distinction worth encoding — only the tool knows which its failures are.
  • Document the table in three places: the help text, the header comment, and readonly EX_* constants.
  • grep -n 'exit "\$EX_' then lists every exit path in the file.
  • Add freely, never reuse, never renumber. There is no deprecation warning for an integer.
  • Give callers a catch-all to land in, and document that unlisted non-zero means "failed, uncategorised".
  • if tool; then collapses the whole contract to pass/fail. Say in the help text that case $? is the way to use it.

Chapter 21.5 takes the two properties that decide whether a colleague is willing to run your tool at all: that running it twice is safe, and that it can tell them what it would do before it does it.

Where this fits

Part of the Linux & Bash curriculum.