Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./run_sim.sh alu
PASS  alu (seed 0)
$ echo $?
0

Chapter 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.
Why a script's exit status is its real interface. Whatever ran it — a person, another script, a scheduler — sees one number and often nothing else, because the output has gone to a log nobody reads. A script that ends without choosing a status returns whatever its last command happened to return, which is frequently a success it did not earn.The script chooses itsstatuszero for success, documentednumbers otherwiseThe caller can act onita chain stops, a schedulerretries, a build failsThe contract holdsand the output is aconvenience, not the answerThe script just endswith no deliberate status atallIt returns its lastcommand's statusoften a stray echo, whichalways succeedsA failed run reportssuccessand nothing downstream has anyway to know12
Figure 1 — why a script's exit status is its real interface. Whatever ran it — a person, another script, a scheduler — sees one number, and often nothing else, because the output has gone to a log nobody reads. A script that ends without choosing a status returns whatever its last command happened to return, which is frequently a success it did not earn.

2. The Default Is Whatever Happened Last

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat a.sh
#!/bin/bash
false

$ ./a.sh; echo $?
1

A script that runs off the end returns its last command's status. Here that happens to be right.

Now add one harmless line:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat b.sh
#!/bin/bash
false
echo "done"

$ ./b.sh; echo $?
0

Zero. The false failed, and then echo succeeded, and echo's status is what the script returned.

3. Saying It Explicitly

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
exit 0      # success
exit 1      # failure
exit 2      # bad usage

exit N ends the script immediately with status N.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat c.sh
#!/bin/bash
false
exit

$ ./c.sh; echo $?
1

exit 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
grep -q 'TEST PASSED' sim.log
exit            # the grep's status is the script's

Or equivalently, and more clearly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
grep -q 'TEST PASSED' sim.log

...as the last line, with nothing after it.

4. Choosing the Numbers

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# exit: 0  the test passed
#       1  the test failed
#       2  bad usage
#       3  the simulator is not on PATH

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat e.sh
#!/bin/bash
f() { exit 3; }
f
echo "NOT REACHED"

$ ./e.sh; echo $?
3

exit in a function ends the whole script, not the function. The echo never ran.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat f.sh
#!/bin/bash
f() { return 3; }
f
echo "reached, f returned $?"

$ ./f.sh; echo $?
0

return ends the function and leaves its status in $?. The script carried on and ended with echo's status.

In a functionEndsStatus goes to
exit NThe scriptThe caller of the script
return NThe 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat g.sh
#!/bin/bash
( exit 5 )
echo "after subshell, status was $?"
exit 0

$ ./g.sh; echo $?
after subshell, status was 5
0

exit 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cd "$d" || { echo "cannot enter $d" >&2; exit 1; }   # correct
cd "$d" || ( echo "cannot enter $d" >&2; exit 1 )    # prints and carries on

6. set -e and the Status

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat j.sh
#!/bin/bash
set -e
bash -c "exit 42"
echo "NOT REACHED"

$ ./j.sh; echo $?
42

With 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
compile_design || exit 4        # our code for a compile failure, not vcs's

7. Cleanup That Runs Whatever Happens

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat k.sh
#!/bin/bash
trap 'echo "  cleanup ran"' EXIT
exit 7

$ ./k.sh; echo $?
  cleanup ran
7

trap … 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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 failed

And then the caller can act on them:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
./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 ;;
esac

Those 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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:

  • 2 and 3 for 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 cleanup trap that preserves the status, with local st=$? first.
  • if ./run_sim.sh … rather than set -e, so a failing test is counted rather than ending the run.
  • The final bare test gives 0 or 1 with no exit needed.
  • The count is in the output, never in the status — because 256 failures would be reported as success.

Practice

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
mkdir -p /tmp/ex && cd /tmp/ex

1 — Watch a trailing echo destroy the answer.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 echo always succeeds.
  • That is the most common way a script lies about itself, and it makes CI steps permanently green.
  • exit N is explicit; bare exit returns the last status.
  • A bare test as the final line — [ "$fail" -eq 0 ] — gives 0 or 1 with no exit at 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.
  • exit in a function ends the script; return ends the function.
  • exit in 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 … EXIT runs 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.