Linux & Bash · Part I · Module 15 · Writing and Running Bash Scripts
What a Script Returns: exit and the Caller's Contract
A script's output goes to a log that nobody reads. Its exit status goes to whatever ran it, and is acted on immediately. That makes one small integer the script's real interface — and a script that does not choose it deliberately will report success for a run that failed.
Intermediate17 min readLinuxPart IFundamentalsScriptingExit Status
Part I · Module 15 · Chapter 15.6 · What a Script Returns
Chapter 15.3 said a script returns exactly one thing. This is that thing.
1. The Only Thing That Comes Back
$ ./run_sim.sh alu
PASS alu (seed 0)
$ echo $?
0Chapter 11.1 established what an exit status is. What this chapter adds is that for a script, it is not a detail — it is the interface.
Everything else a script produces is advisory. The output goes to a terminal nobody is watching or a log nobody opens. The status goes to whatever ran the script, and is acted on without a human in the loop:
- A
&&chain stops or continues (Chapter 11.3). - A continuous-integration step is marked green or red.
- A scheduler retries, alerts, or does nothing.
- A parent script decides whether to carry on.
2. The Default Is Whatever Happened Last
$ cat a.sh
#!/bin/bash
false
$ ./a.sh; echo $?
1A script that runs off the end returns its last command's status. Here that happens to be right.
Now add one harmless line:
$ cat b.sh
#!/bin/bash
false
echo "done"
$ ./b.sh; echo $?
0Zero. The false failed, and then echo succeeded, and echo's status is what the script returned.
3. Saying It Explicitly
exit 0 # success
exit 1 # failure
exit 2 # bad usageexit N ends the script immediately with status N.
$ cat c.sh
#!/bin/bash
false
exit
$ ./c.sh; echo $?
1exit with no number returns the last command's status — the same as running off the end, but explicit. It is useful at the end of a script whose final command is the answer:
grep -q 'TEST PASSED' sim.log
exit # the grep's status is the script'sOr equivalently, and more clearly:
grep -q 'TEST PASSED' sim.log...as the last line, with nothing after it.
4. Choosing the Numbers
# exit: 0 the test passed
# 1 the test failed
# 2 bad usage
# 3 the simulator is not on PATHFour conventions, from Chapter 11.1 and worth restating where scripts are the subject:
0 means complete success and nothing else. "Succeeded with warnings" is a different code. The thing reading your status is a machine, and it looks at one number.
1 is the general failure. When a script has only two outcomes, use 0 and 1 and stop.
2 upward are yours to define, and should be documented in the header (Chapter 15.5). Distinguishing "bad usage" from "the work failed" is the most valuable split, because they call for completely different responses.
Stay below 126. Above that you collide with the shell's reserved meanings — 126 not executable, 127 not found, 128+N killed by a signal.
5. exit Inside a Function
$ cat e.sh
#!/bin/bash
f() { exit 3; }
f
echo "NOT REACHED"
$ ./e.sh; echo $?
3exit in a function ends the whole script, not the function. The echo never ran.
$ cat f.sh
#!/bin/bash
f() { return 3; }
f
echo "reached, f returned $?"
$ ./f.sh; echo $?
0return ends the function and leaves its status in $?. The script carried on and ended with echo's status.
| In a function | Ends | Status goes to |
|---|---|---|
exit N | The script | The caller of the script |
return N | The function | $? in the script |
A helper that reports a result should return. A helper that detects a fatal condition may exit — which is why a die() function ends with exit and a check() function ends with return.
die() { echo "error: $*" >&2; exit "${2:-1}"; }
passed() { grep -q 'TEST PASSED' "$1"; } # returns grep's status(Chapter 18.3 covers return against exit; this is the part that belongs with exit status.)
And in a subshell
$ cat g.sh
#!/bin/bash
( exit 5 )
echo "after subshell, status was $?"
exit 0
$ ./g.sh; echo $?
after subshell, status was 5
0exit in a subshell ends the subshell, exactly as Chapter 11.4 showed. The status is available in $? and the script continues.
That is the reason a guard that must stop the script needs braces:
cd "$d" || { echo "cannot enter $d" >&2; exit 1; } # correct
cd "$d" || ( echo "cannot enter $d" >&2; exit 1 ) # prints and carries on6. set -e and the Status
$ cat j.sh
#!/bin/bash
set -e
bash -c "exit 42"
echo "NOT REACHED"
$ ./j.sh; echo $?
42With set -e, a failing command ends the script and the script returns that command's status. Not 1 — the actual status, propagated.
That is usually what you want: a script wrapping a tool passes the tool's own exit code through, so the caller sees the real reason.
It is also why set -e and a documented set of codes interact. If your script defines 3 as "simulator not found" and some unrelated tool happens to exit 3, set -e propagates that 3 and the caller misreads it. Where the codes matter, catch failures explicitly rather than relying on set -e:
compile_design || exit 4 # our code for a compile failure, not vcs's7. Cleanup That Runs Whatever Happens
$ cat k.sh
#!/bin/bash
trap 'echo " cleanup ran"' EXIT
exit 7
$ ./k.sh; echo $?
cleanup ran
7trap … EXIT runs its command on every exit path — a normal end, an exit, or set -e stopping the script. It is how a script removes its temporary files without repeating the cleanup at every exit point.
tmpdir=$(mktemp -d) || exit 1
trap 'rm -rf "$tmpdir"' EXIT
# ... work, exit anywhere, the directory is removed ...8. Writing the Contract Down
A script's status is only useful if the caller knows what the numbers mean.
#!/usr/bin/env bash
#
# run_sim.sh — compile and run one UVM test.
#
# exit: 0 the test passed
# 1 the test failed
# 2 bad usage, or the run directory could not be made
# 3 the simulator is not on PATH
# 4 the compile failedAnd then the caller can act on them:
./run_sim.sh alu
case $? in
0) echo "pass" ;;
1) echo "fail — investigate the log" ;;
2) echo "I called it wrongly" >&2 ;;
3) echo "environment not set up" >&2 ;;
4) echo "build problem, not a test problem" >&2 ;;
esacThose five cases call for five different responses, and the distinction only exists because the script bothered to make it. A script returning only 0 and 1 forces the caller to read the log — which, as §1 said, is the thing nobody does.
9. Putting It Together
#!/usr/bin/env bash
#
# run_regression.sh — run every test in a list.
#
# exit: 0 every test passed
# 1 at least one test failed
# 2 bad usage or unreadable test list
# 3 the simulator is not on PATH
set -uo pipefail
testlist="${1:?usage: $0 <testlist>}"
[ -r "$testlist" ] || { echo "cannot read $testlist" >&2; exit 2; }
command -v vcs >/dev/null 2>&1 || { echo "vcs not on PATH" >&2; exit 3; }
workdir=$(mktemp -d) || exit 2
cleanup() { local st=$?; rm -rf "$workdir"; exit "$st"; }
trap cleanup EXIT
pass=0
fail=0
while IFS=, read -r name seed || [ -n "$name" ]; do
[ -n "$name" ] || continue
if ./run_sim.sh "$name" "$seed" > "$workdir/$name.log" 2>&1; then
pass=$(( pass + 1 ))
else
fail=$(( fail + 1 ))
echo "FAILED: $name (seed $seed)" >&2
fi
done < "$testlist"
echo "passed: $pass failed: $fail"
# The status says WHETHER; the output above said HOW MANY.
[ "$fail" -eq 0 ]Six decisions about status, every one of them from this module:
2and3for the two preconditions, documented in the header.mktemp -d || exit 2— a failure to prepare is a usage-class problem, not a test failure.- A
cleanuptrap that preserves the status, withlocal st=$?first. if ./run_sim.sh …rather thanset -e, so a failing test is counted rather than ending the run.- The final bare test gives 0 or 1 with no
exitneeded. - The count is in the output, never in the status — because 256 failures would be reported as success.
Practice
mkdir -p /tmp/ex && cd /tmp/ex1 — Watch a trailing echo destroy the answer.
printf '#!/bin/bash\nfalse\n' > a.sh; chmod +x a.sh
printf '#!/bin/bash\nfalse\necho "done"\n' > b.sh; chmod +x b.sh
./a.sh; echo " without echo: $?"
./b.sh >/dev/null; echo " with echo: $?"2 — Compare bare exit with exit 0.
printf '#!/bin/bash\nfalse\nexit\n' > c.sh; chmod +x c.sh; ./c.sh; echo " bare exit: $?"
printf '#!/bin/bash\nfalse\nexit 0\n' > d.sh; chmod +x d.sh; ./d.sh; echo " exit 0: $?"3 — Use the bare-test idiom.
cat > t.sh <<'EOF'
#!/bin/bash
fail="${1:-0}"
echo "failures: $fail"
[ "$fail" -eq 0 ]
EOF
chmod +x t.sh
./t.sh 0; echo " -> $?"
./t.sh 3; echo " -> $?"4 — Never exit a count.
printf '#!/bin/bash\nexit 300\n' > w.sh; chmod +x w.sh; ./w.sh; echo " exit 300 -> $?"
printf '#!/bin/bash\nexit 256\n' > x.sh; chmod +x x.sh; ./x.sh; echo " exit 256 -> $?"5 — See exit and return differ in a function.
printf '#!/bin/bash\nf() { exit 3; }\nf\necho "NOT REACHED"\n' > e.sh; chmod +x e.sh
./e.sh; echo " exit in a function: $?"
printf '#!/bin/bash\nf() { return 3; }\nf\necho " f returned $?"\n' > g.sh; chmod +x g.sh
./g.sh; echo " return in a function: $?"6 — Confirm a subshell exit does not end the script.
printf '#!/bin/bash\n( exit 5 )\necho " subshell gave $?"\nexit 0\n' > s.sh; chmod +x s.sh
./s.sh; echo " script: $?"7 — Watch set -e propagate the real status.
printf '#!/bin/bash\nset -e\nbash -c "exit 42"\necho "NOT REACHED"\n' > p.sh; chmod +x p.sh
./p.sh; echo " -> $?"8 — Add a cleanup trap, then make it preserve the status.
printf '#!/bin/bash\ntrap '"'"'echo " cleanup"'"'"' EXIT\nexit 7\n' > k.sh; chmod +x k.sh
./k.sh; echo " -> $?"
printf '#!/bin/bash\ntrap '"'"'exit 9'"'"' EXIT\nexit 7\n' > l.sh; chmod +x l.sh
./l.sh; echo " trap overrode -> $?"
cat > m.sh <<'EOF'
#!/bin/bash
cleanup() { local st=$?; echo " cleanup, preserving $st"; exit "$st"; }
trap cleanup EXIT
exit 7
EOF
chmod +x m.sh; ./m.sh; echo " -> $?"9 — Write a contract and act on it.
cat > contract.sh <<'EOF'
#!/bin/bash
# exit: 0 ok · 1 failed · 2 bad usage
[ "$#" -eq 1 ] || { echo "usage: $0 <ok|fail>" >&2; exit 2; }
[ "$1" = "ok" ]
EOF
chmod +x contract.sh
for a in ok fail; do ./contract.sh "$a"; echo " $a -> $?"; done
./contract.sh; echo " (none) -> $?"10 — Clean up. cd /tmp && rm -rf /tmp/ex
Check Your Understanding
1. Why is the exit status a script's real interface rather than its output?
2. A script ends with echo "finished". What does it return, and why is that a problem?
3. What does a bare exit return?
4. exit $failure_count with 256 failures reports success. Why, and what should the script do instead?
5. What is the difference between exit 3 and return 3 inside a function?
6. An EXIT trap changed your script's exit status. How do you preserve it?
Chapter 30.2 measures the parallel version of this: three jobs exiting 0, 3 and 7, and a bare wait reporting 0.
10. Remember
- The exit status is a script's real interface. It is read by a machine; the output usually is not read at all.
- A script that runs off the end returns its last command's status — and a trailing
echoalways succeeds. - That is the most common way a script lies about itself, and it makes CI steps permanently green.
exit Nis explicit; bareexitreturns the last status.- A bare test as the final line —
[ "$fail" -eq 0 ]— gives 0 or 1 with noexitat all. - 0 is complete success, 1 is general failure, 2 upward are yours — and document them in the header. Chapter 21.4 turns that header comment into a contract a caller can rely on.
- Stay below 126, which is where the shell's reserved meanings begin.
- Never exit a count. It wraps at 256 and reports success. Status says whether; output says how many.
exitin a function ends the script;returnends the function.exitin a subshell ends the subshell, which is why a guard that must stop the script needs braces.- With
set -e, the script returns the failing command's status — which can collide with your own documented codes. trap … EXITruns on every exit path and is how cleanup happens once.- A trap can change the status. Capture
$?on the handler's first line and re-exit with it. - Separating "it failed" from "you called it wrongly" is the most valuable distinction a script can make.
That completes Module 15. A script now has a name, an interpreter, a known environment, an argument interface, a readable structure and a contract — which is everything except the ability to make a decision.
Module 16 is that: if, test, and what Bash is actually testing, and the comparison operators that let a script choose what to do next instead of running the same commands every time. It opens by cashing in this module's work — because if does not evaluate a condition, it runs a command and reads exactly the number this chapter has been about.
Where this fits
Part of the Linux & Bash curriculum.
