Developer Tools · Command-Line Craft
Exit Status Semantics and the Limits of set -e
What the 8-bit exit status encodes, which failures errexit deliberately ignores, and why a pipeline reports only one stage of its work.
Every command in a shell terminates with a status, and the entire automation idiom of the command line is built on that single byte. The status is not an error message, not a category, and not a boolean. It is an integer in the range 0 to 255 whose meaning is fixed by convention for a handful of values and by the program itself for the rest. The gap between the two is where most scripts quietly lose failures.
One byte, transported in a wider integer
The kernel does not store an exit status as a byte. wait and waitpid return a status word that packs the reason for termination together with the value, and the macros WIFEXITED, WEXITSTATUS, WIFSIGNALED, and WTERMSIG unpack it. A process that calls exit(300) has its argument truncated to the low eight bits before it ever reaches the parent, so the parent observes 44. A process killed by a signal never calls exit at all; the kernel records the signal number in the status word, and the shell reconstructs a value of 128 plus that number.
The distinction matters because a shell script cannot tell the two apart from the numeric value alone. A status of 130 means “terminated by SIGINT” in the sense that the shell computed 128 + 2, but a program is free to exit(130) deliberately, and the shell will report the same number. Any wrapper that treats 130 as proof of an interrupt is relying on a convention that the kernel does not enforce.
Three values carry near-universal meaning. Zero is success. 126 means a command was found but could not be executed, which is what path resolution produces for a file without the execute bit or for a directory named as a command. 127 means the command was not found at all. Beyond those, the assignment is per-program: grep returns 1 when no line matched and 2 on an error such as an unreadable file, and diff returns 1 for differing inputs and 2 for trouble. Both are deliberate: they let a caller distinguish “the answer is negative” from “the question could not be asked”.
Why errexit is narrower than it looks
set -e is the usual first attempt at making a script fail fast, and it is weaker than its name suggests. POSIX specifies a list of contexts in which the shell ignores the setting entirely, and the list is not an implementation detail; it is the definition of the option:
- a command in a
whileoruntilcondition, or in anifcondition; - any command of an
&&or||list except the last; - any command in a pipeline except the last;
- a pipeline whose status is being inverted with
!.
Bash and several other implementations extend the list with one more case: the body of a function invoked in any of those positions, for the whole dynamic extent of that invocation. That extension is not in the specification, and a script that relies on it is relying on the shell it runs under.
The function-body case is the one that breaks real scripts. A function written to be called as if check_config; then, containing an internal command that fails, will not abort even though the same command outside the condition would have. The option is a property of the syntactic position, not of the command, and that property propagates down the call stack.
Two further escapes are easy to overlook. A command substitution in an assignment, as in value=$(some_command), does not trigger the exit when the substitution fails; the assignment itself succeeds, and the failure is visible only in $?. And a subshell inherits the option, but the failure of a subshell as a whole is what the parent sees, so an inner failure that is caught or ignored inside the subshell disappears entirely.
There is a version-dependent wrinkle in bash worth knowing: the subshell created for a command substitution does not inherit errexit by default, so a failure inside $( ... ) will not abort that subshell even when the enclosing script has the option set. Bash exposes shopt -s inherit_errexit to change this, and the option is enabled by default when bash runs in POSIX mode, so the same script can behave differently depending on how the shell was started. A script that depends on failure propagation out of a command substitution is depending on an option most readers will not have set.
The practical consequence is that set -e is a safety net for the accidental case, not a control-flow mechanism. Scripts that need to distinguish failures write the test explicitly, with if ! cmd; then ... fi or cmd || exit 1, which states the intent at the point where the decision is made.
A pipeline reports one stage
By default a pipeline’s status is the status of its last command. In producer | filter | sink, a failure in producer is invisible unless sink also fails. This is not an oversight: the status of a pipeline has to be a single integer, and the shell has to choose which stage to report. The choice it makes is the one that usually corresponds to whether the output was delivered.
Two mechanisms recover the rest. pipefail, available in bash, ksh, and zsh but not specified by POSIX, makes the pipeline return the status of the rightmost stage that failed, or zero if all succeeded. PIPESTATUS in bash is an array holding every stage’s status, valid only until the next command runs. A portable script cannot rely on either, so it either avoids multi-stage pipelines in error-critical positions or restructures the work into separate commands with explicit checks.
The interaction with set -e is subtle in the other direction. Because every stage except the last is exempt from errexit, a pipeline with a failing producer and a successful sink exits zero and the script continues. Turning on pipefail changes the pipeline’s status and therefore makes the whole pipeline subject to errexit; it does not make the intermediate stages independently fatal.
Status values that travel through wrappers
A wrapper that runs a command and then reports on it has to decide what to propagate. The convention that costs least is to return the child’s status unchanged, because callers may already branch on specific values. Replacing a child’s status with a generic 1 destroys information the caller had a legitimate use for; replacing it with 0 is worse, since it converts failure into success.
Signal deaths need care in wrappers. When a shell script’s foreground child is killed by a signal, the shell exits with 128 plus the signal number, and a wrapper that wants to preserve the signal semantics should re-raise the signal on itself with the default disposition rather than exiting with the numeric value. Exiting with 130 is not the same as dying from SIGINT: a supervisor watching the process can distinguish the two, and a shell that sees a numeric exit will not treat it as an interrupt.
Reading status without consuming it
$? is overwritten by the next command, including the [ of a test. The idiom of running a command and then testing $? directly is correct only because the test is the first command after the one being examined, and it breaks the moment a diagnostic is inserted between them. Capturing the value immediately is the reliable form:
run_build
status=$?
if [ "$status" -ne 0 ]; then
printf 'build failed with status %d\n' "$status" >&2
exit "$status"
fi
Command substitution adds a second layer: output=$(cmd) sets $? to the status of cmd, but output=$(cmd); echo "$output" then resets it. A script that both wants the output and needs the status must save the status before using the output. In bash, set -o pipefail combined with a command substitution that contains a pipeline reports the pipeline’s failure under the same rules as above, which is one of the few cases where the option’s reach is broader than expected.
What the convention cannot express
A single byte has 256 values, and a program with more than a few failure modes has to collapse them. The usual collapse is to define a small set of documented codes and print detail to standard error, which means the machine-readable channel carries less information than the human-readable one. Tools that need richer reporting either emit structured output on stdout or write a status file; neither is part of the shell interface.
The limit also runs the other way. Because a caller can only see the byte, a program that dies from an unhandled fault reports only the signal number, with no indication of which invariant broke. A supervisor can restart it, but cannot classify the failure beyond “died abnormally”. That asymmetry, rich internal state and one byte out, is the reason log capture and status propagation are separate concerns rather than one mechanism, and it is why signal handling in scripts needs its own conventions on top of the exit status.
References
- POSIX.1-2017, Shell Command Language — set, Special Built-In Utilities — The Open Group
- wait(2) — Linux manual page — Linux man-pages project
- Bash Reference Manual — Exit Status — GNU Project