Signal9Signal9 — home
online
all lessons

Exit Status & Chaining: How Commands Report Success

Beginner25 min5 steps
What you'll learn
  • Explain that every command returns a numeric exit status: 0 means success, non-zero means failure
  • Read the last command's status from $? and recognize the common codes (0, 1, 2, 126, 127, 130, 137)
  • Chain commands deliberately with ;, &&, and || based on success or failure
  • Know that a pipeline's status is its LAST command's, and reach for set -o pipefail when that hides a failure
  1. Every command answers one hidden question: did it work?

    See that each command leaves behind a number saying whether it succeeded.

    When a command finishes, it hands the shell a single number — its exit status (or exit code). The rule is fixed and, at first, backwards-feeling:

    • `0` means success ("zero problems").
    • any non-zero value means failure — and the specific number often says how it failed.

    That one number is the foundation everything else in this lesson is built on. if, &&, ||, and every shell script's error handling are all just reactions to the exit status. You usually don't see it — it's returned quietly — but it's always there.

    This matters the moment you stop typing commands one at a time and start combining them. A script that copies a file and then deletes the original must know whether the copy actually worked before it removes anything — and "did it work?" is precisely the exit status. Ignore it and you get the classic disasters: deleting the source after a copy that silently failed, deploying after a build that errored, cd-ing into a directory that was never created. Reading and reacting to the status is what turns a list of commands into something trustworthy.

  2. Reading the status: $? and what the numbers mean

    Inspect the last command's exit status and interpret the common codes.

    The shell stores the most recent command's exit status in the special variable `$?`. Check it immediately after the command — the next command overwrites it.

    A few codes are worth recognizing on sight:

    • `0` success · `1` generic failure · `2` misuse (bad options/syntax)
    • `126` found but not executable (permission) · `127` command not found
    • `130` killed by Ctrl-C · `137` killed (128 + signal 9, SIGKILL)

    The pattern for signals is 128 + signal number, which is why a killed process shows up as 137 or 143 rather than something tidy.

    ls /etc/hostname; echo "status: $?"
    /etc/hostname
    status: 0
    ls succeeded, so it returned 0, and $? reports it. Note the echo has to come right after — run any other command first and $? would hold THAT command's status instead.
    ls /nope 2>/dev/null; echo "status: $?"
    status: 2
    ls failed (no such directory) and returned a non-zero status — here 2. The failure is real even though we hid the error message with 2>/dev/null; the exit status, not the text, is how a script knows something went wrong.

    Check yourself

    You run a command and then check echo $? and see 127. What does that almost certainly mean?

  3. Chaining: ; then &&, ||

    Compose commands that run always, only-on-success, or only-on-failure.

    Once commands report success or failure, you can wire them together by that result — no if needed:

    • `A ; B` — run A, then B, always (sequence; the results are independent).
    • `A && B` — run B only if A succeeded (A's status was 0). Read it as "and then".
    • `A || B` — run B only if A failed (A's status was non-zero). Read it as "or else".

    These are the everyday idioms: mkdir build && cd build (only enter the dir if it was created), command || echo 'it failed' (react to failure), make && ./run (only run if the build worked).

    mkdir -p /tmp/demo && cd /tmp/demo && pwd
    /tmp/demo
    && is a guard: each step runs only if the previous one succeeded. If mkdir failed (say, a permissions problem), the cd and pwd would never run — you don't end up operating in the wrong directory. Chaining with && is how you make a sequence safe.
    grep -q root /etc/passwd && echo 'has root' || echo 'no root'
    has root
    -q
    quiet — print nothing, just set the exit status (found=0, not found=1)
    grep's exit status IS the answer: 0 if it found a match, 1 if not. So 'grep -q PATTERN file && echo yes || echo no' is a complete found/not-found test with no if-statement. -q ('quiet') suppresses grep's own output so only your echo prints.
  4. Pipelines report only their last command

    Predict a pipeline's exit status and fix the case where a failure hides.

    A pipeline A | B | C has the exit status of its last command (`C`) — by default the shell ignores whether A or B failed. That's usually fine, but it can hide a real problem: in curl … | grep x, if curl fails but grep still exits cleanly (finding nothing, status 1) or succeeds, you can miss that the download broke.

    Bash's fix is `set -o pipefail`: with it, the pipeline fails if any stage fails, reporting the rightmost non-zero status. In robust scripts you'll often see set -euo pipefail at the top for exactly this reason.

    false | true; echo "without pipefail: $?"
    without pipefail: 0
    The pipeline's status is 'true' (the last stage) = 0, even though 'false' failed upstream. By default that upstream failure is invisible to $?. That's the gotcha pipefail exists to close.
    set -o pipefail; false | true; echo "with pipefail: $?"; set +o pipefail
    with pipefail: 1
    -o pipefail
    make a pipeline fail if ANY stage fails (report the last non-zero status)
    With pipefail on, the pipeline reports the failing stage's status (1 from 'false') instead of masking it. We turn it back off afterward with set +o pipefail so the option doesn't linger in an interactive shell.

    Check yourself

    By default, what determines the exit status of the pipeline a | b | c ?

  5. Now do it — make the status visible

    Run commands that succeed and fail so reading $? and chaining become reflex.

    Three tiny experiments. First, meet the two commands whose whole job is to return a status: true (always 0) and false (always 1). They print nothing — the status is the point.

    true; echo $?; false; echo $?
    Prints 0 then 1. true and false exist purely to succeed or fail on demand — perfect for seeing $? change with nothing else going on. Everything about chaining is built on this success/failure signal.
    cat /etc/hostname && echo '--- read OK ---'; cat /no/such/file || echo '--- read FAILED, handled ---'
    Watch && and || pick their branch by exit status. The first cat succeeds so the && message prints; the second cat fails so the || message prints. You reacted to success and to failure without writing a single if.
    grep -q bash /etc/shells && echo 'bash is a valid login shell' || echo 'bash not listed'
    A real one-liner test: grep's exit status answers the yes/no question and the && / || turn it into a human sentence. This is the shape of countless shell checks — and now you can read exactly why it works: 0 from grep runs the &&, non-zero runs the ||.
Recap
  • Every command returns an exit status: 0 = success, non-zero = failure. $? holds the most recent one (check it right away — the next command overwrites it).
  • Common codes: 1 generic, 2 misuse, 126 not executable, 127 command not found, 130 Ctrl-C, 137 killed (128 + signal).
  • Chain by result: A ; B (always), A && B (B only if A succeeded), A || B (B only if A failed). A && B || C is not a true if/else.
  • A pipeline's status is its last command's; use set -o pipefail so an earlier stage's failure isn't hidden.
Reacting to exit status
You writeIt means
echo $?the exit status of the command that just ran
A && Brun B only if A succeeded (status 0)
A || Brun B only if A failed (status non-zero)
A ; Brun A, then B, regardless of A's result
grep -q x f && … || …branch on whether x is in file f
set -o pipefailmake a pipeline fail if any stage fails