An exit status is a small value that a shell uses to decide what runs next. Zero means success for shell control flow; a nonzero status takes the failure branch. That does not give every number a universal application meaning. A search with no matches, invalid input, and an unavailable server may deserve different handling even though all can produce nonzero results. Read the command’s contract before deciding what to retry or ignore.

Keep the status attached to its command

Bash exposes the most recent foreground command’s status through $?. Capture it before logging, testing another condition, or running cleanup. Those operations have statuses of their own and can replace the evidence you wanted. A function normally returns the status of its last executed command, so a successful final log message can accidentally turn an earlier failure into a successful function result.

Use if command; then ... else ... fi when the command’s success determines the next action. In the else branch, save $? immediately if the exact failure code matters. An explicit return or exit can then propagate that saved result after diagnostics. Avoid using if ! command and expecting $? inside the success branch to contain the original failure: the negation has already turned it into success.

Bash uses status 127 when command lookup fails and 126 when it finds a command that cannot be executed. It represents fatal signal termination as 128 plus the signal number. These conventions help interpret shell reports, but an application can explicitly exit with the same number. A numerical status alone is therefore not proof of which internal event happened. Keep stderr and the identity of the failed operation alongside it.

When it breaks

A pipeline defaults to its last stage’s status. An upstream generator can fail while a downstream reader successfully consumes an empty or partial stream. With pipefail enabled, Bash selects the rightmost nonzero stage status, or zero if every stage succeeds. PIPESTATUS exposes each stage separately, but subsequent commands replace that array too. Capture the complete observation before running another command.

Errexit, enabled by set -e, has exceptions determined by syntax and calling context. Failures used as if conditions, certain commands in AND or OR lists, and other documented contexts do not automatically exit. A function invoked as a condition can continue after a failed command in its body. An ERR trap follows similar exclusions and is not a report of every possible failure. Neither mechanism proves a script is safe.

Adding || true broadly discards useful distinctions. It may be appropriate when every failure from that exact operation is acceptable, but it can hide an unexpected permission error or an unavailable executable. Handle documented expected results specifically, and propagate unexpected ones. Likewise, first && second || recover runs recover when second fails too; it is not equivalent to an if statement that only tests first.

Background launch has another boundary: successfully starting a job does not mean the job completed successfully. Save its identifier from $! and inspect wait’s result for that job. If a script starts several jobs, retain each identifier and wait for each required result. Otherwise a final successful foreground command can mask work that failed after being launched.

Check it quickly

The following complete Bash experiment uses intentional failures and checks their exact propagation. It demonstrates immediate capture, negation, a function called in a conditional context, and a background job that fails after launch. It does not contact external services or require any files.

bash --noprofile --norc <<'BASH'
set -e
fail_seven() { return 7; }
if fail_seven; then
  exit 1
else
  saved=$?
fi
printf 'saved status %s\n' "$saved"
[[ $saved -eq 7 ]]
[[ $? -eq 0 ]]
if ! fail_seven; then
  negated=$?
fi
[[ $negated -eq 0 ]]
continued=no
conditional_function() {
  false
  continued=yes
}
if conditional_function; then
  [[ $continued == yes ]]
else
  exit 1
fi
(exit 6) &
job_pid=$!
if wait "$job_pid"; then
  exit 1
else
  job_status=$?
fi
[[ $job_status -eq 6 ]]
printf 'PASS: saved failure, negation, conditional context, wait\n'
BASH

Expect a saved status of seven and the final PASS message. The negated status is zero, and the conditional function reaches its assignment despite errexit being set. Wait must report the child’s six. These assertions expose control-flow boundaries; they do not assign meanings to an arbitrary tool’s codes. When adopting the pattern, test both expected negative results and unexpected operational failures, and verify that the wrapper exits with the result its caller needs.

Sources