Linux & Bash · Part I · Module 18 · Functions and Script Decomposition
return vs exit, and a Function's Status
In a thirty-line script these two look like the same thing spelled differently, and a lot of working code uses them interchangeably. The difference appears the moment a function is called from somewhere that wanted to handle the failure — and it appears again, inverted, when the function is called inside command substitution.
Intermediate19 min readLinuxPart IBashFunctionsExit StatusScripting
Part I · Module 18 · Chapter 18.3 · return vs exit, and a Function's Status
Chapter 18.1 established that a function has an exit status. This chapter is about setting it deliberately, and about the other statement that looks like it does the same job.
1. The Difference, in One Script
#!/usr/bin/env bash
# r.sh
with_return() { echo " in with_return"; return 1; echo " NOT REACHED"; }
with_exit() { echo " in with_exit"; exit 7; echo " NOT REACHED"; }
with_return; echo "after with_return: status=$? — script continues"
with_exit; echo "AFTER with_exit — NEVER PRINTED"$ ./r.sh
in with_return
after with_return: status=1 — script continues
in with_exit
$ echo $?
7return ends the function. exit ends the script.
Both stopped their function immediately — neither NOT REACHED printed. The difference is what happened next: after with_return the script carried on and could read the status; after with_exit there was no next line.
Returning a number, against ending the script
2. Why It Matters Only Later
In a short script, every failure is fatal and exit inside a helper is harmless:
check_dir() { [ -d "$1" ] || { echo "no such dir: $1" >&2; exit 2; }; }
check_dir "$1"
# ... the restThat works and is perfectly readable. It also means check_dir can never be used anywhere that wanted to survive a missing directory — and that constraint is invisible until someone tries:
# a year later, someone wants to skip bad directories rather than die
for d in "$@"; do
check_dir "$d" || continue # the || never runs: exit already happened
process "$d"
doneMeasured, with good1 and good2 existing and nope not:
$ ./c1.sh good1 nope good2
processing good1
no such dir: nope
$ echo $?
2good2 was never looked at and SUMMARY reached — the line after the loop — never printed. The || continue is dead code, and nothing about reading those two lines reveals it.
The rule that scales: a function reports, the top level decides.
check_dir() {
if ! [ -d "$1" ]; then
printf '%s: no such dir: %s\n' "${0##*/}" "$1" >&2
return 2
fi
}
for d in "$@"; do
check_dir "$d" || continue # now this works
process "$d"
done$ ./c2.sh good1 nope good2
processing good1
c2.sh: no such dir: nope
processing good2
SUMMARY reached
$ echo $?
0The function is unchanged in what it detects and what it prints. All that changed is who gets to decide, and now both uses are possible.
3. What a Function Returns by Default
With no return at all, a function reports its last command's status:
$ f() { echo hi > /dev/null; false; }; f; echo "status=$?"
status=1
$ f() { false; echo hi > /dev/null; }; f; echo "status=$?"
status=0The second one masks the failure. A trailing echo — a log line, a summary, a progress message — succeeds, and its success becomes the function's answer. This is Chapter 15.6's script-level trap at function scale, and it is more dangerous here because a function is called many times rather than once.
# the failure is invisible to every caller
run_sim() {
vcs -R "$1" > "$2" 2>&1
echo "finished $1" # <- the function now always succeeds
}Three ways out, in increasing explicitness:
run_sim() { vcs -R "$1" > "$2" 2>&1; } # last command IS the answer
run_sim() { # capture, log, return it
local rc
vcs -R "$1" > "$2" 2>&1
rc=$?
echo "finished $1 (status $rc)"
return "$rc"
}
run_sim() { # or just return explicitly
if vcs -R "$1" > "$2" 2>&1; then return 0; fi
return 1
}The middle one is the shape you want when a function both logs and reports. rc=$? is on the line immediately after the command, for Chapter 11.1's reason.
return with no argument returns the last status, which makes the middle form slightly shorter:
$ f() { false; return; }; f; echo "status=$?"
status=1
$ f() { true; return; }; f; echo "status=$?"
status=0Useful, and worth using sparingly — return alone reads as "return nothing" to most people, and return "$rc" says what it means.
4. The Number Has the Same Limits as exit
$ f() { return 255; }; f; echo $?
255
$ f() { return 256; }; f; echo $?
0
$ f() { return -1; }; f; echo $?
255return 256 is 0 — a success report for a failure. Chapter 15.6 measured the identical wrap for exit, and the identical rule applies: the status is one byte, so never return a count:
# BROKEN: 256 errors reports success
count_errors() { local n=$(grep -c UVM_ERROR "$1"); return "$n"; }
# correct: the count is OUTPUT, the status says whether counting worked
count_errors() { grep -c UVM_ERROR "$1"; }$ python3 -c "print('UVM_ERROR : x\n'*256, end='')" > big.log
$ count_errors() { local n=$(grep -c UVM_ERROR "$1"); return "$n"; }
$ count_errors big.log; echo "256 errors -> status $?"
256 errors -> status 0
$ count_errors() { grep -c UVM_ERROR "$1"; }
$ n=$(count_errors big.log); echo "n=$n status=$?"
n=256 status=0The second form is Chapter 18.4's whole subject — and note that it is also shorter, because grep -c already prints the number and already reports whether it could read the file.
The conventional ranges from Chapter 11.1 hold: 0 success, 1 generic failure, 2 usage error, and 3 upward for your own meanings. Stay under 126, which the shell reserves.
5. return Outside a Function
$ bash -c 'return 1'
bash: return: can only `return' from a function or sourced scriptNote the second half of that message, because it is a real feature:
$ printf 'echo " sourced line 1"\nreturn 0\necho " NOT REACHED"\n' > lib.sh
$ bash -c 'source ./lib.sh; echo " back in caller, status=$?"'
sourced line 1
back in caller, status=0return at the top level of a sourced file returns to the sourcing script. That is how a library bails out early — a failed prerequisite check, or the double-source guard — without killing the script that sourced it. Chapter 18.6 uses it for exactly that.
And exit in a sourced file does kill the caller:
$ printf 'echo " lib line 1"\nexit 5\n' > fatal.sh
$ bash -c 'source ./fatal.sh; echo " NEVER PRINTED"'
lib line 1
$ echo $?
5So the return/exit distinction applies to a sourced file as much as to a function, and for the same reason: one hands control back, the other does not.
6. set -e and a Failing Function
Three cases, all measured, because this is where people get surprised:
$ bash -c 'set -e; f() { return 1; }; f; echo "NOT REACHED"'; echo "script status=$?"
script status=1
$ bash -c 'set -e; f() { return 1; }; if f; then :; else echo "in a condition -> survived"; fi'
in a condition -> survived
$ bash -c 'set -e; f() { return 1; }; f || echo "with || -> survived"'
with || -> survivedA bare failing call aborts the script. The same call in a condition or with || does not, because set -e exempts a command whose status is being tested — exactly the exemption Chapter 16.1 §6 described for if.
The practical consequence for writing functions is small but real: a function that returns non-zero as part of its normal contract — is_valid, has_errors, licence_free — must always be called in a tested position, or a set -e script dies on a perfectly ordinary "no":
set -e
has_errors run.log # aborts the script when there are no errors!
if has_errors run.log; then # correctChapter 20.2 covers set -e's exemptions properly. The habit to form now: a predicate function belongs in a condition.
7. The Inversion: exit That Does Not Exit
Here is the measurement that makes die less reliable than it looks.
$ f() { exit 9; }
$ ( f ); echo "after subshell: status=$? — still running"
after subshell: status=9 — still running
$ v=$(f); echo "after \$(f): status=$? — still running"
after $(f): status=9 — still runningNeither one ended the script. exit terminates the process it runs in, and inside ( ) or $( ) that process is a subshell — Chapter 17.7's rule, arriving from a new direction.
Which means this very common shape is broken:
die() { printf '%s: %s\n' "${0##*/}" "$*" >&2; exit 1; }
derive_name() {
[ -n "$1" ] || die "derive_name needs an argument"
printf '%s_tb.sv\n' "${1%.sv}"
}
name=$(derive_name "") # die runs, prints, exits the SUBSHELL
echo "continuing with name=[$name]"$ ./d.sh
d.sh: derive_name needs an argument
continuing with name=[]The error message printed. The script continued, with an empty name, and will now go and create a file called _tb.sv or fail somewhere far away. A die called inside command substitution does not die.
The fix is to check the status at the capture site, which you should be doing anyway:
name=$(derive_name "") || exit 1Chapter 18.4 §4 shows that the status does survive $( ) even though the exit does not — which is what makes that one-line guard work. The general rule: exit is only reliable where there is no subshell between it and the script. If a function might be captured, have it return and make the caller handle it.
8. A Complete Script
A build wrapper where every helper reports and only the top level decides:
#!/usr/bin/env bash
# build-all.sh — build each target, continuing past failures
# usage: build-all.sh <target> [target...]
#
# exit: 0 all built · 1 at least one failed · 2 unusable arguments
set -o nounset
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
die() { note "$*"; exit 2; } # named for what it does
# is_target <name> -> 0 if it is one we know how to build
is_target() {
case $1 in
rtl|tb|docs) return 0 ;;
*) return 1 ;;
esac
}
# build <name> -> 0 built, 1 build failed, 2 unknown target
build() {
if ! is_target "$1"; then
note "unknown target: $1"
return 2
fi
# stand-in for a real build; 'tb' fails, 'docs' is a no-op success
if [ "$1" = tb ]; then
note "build of $1 failed"
return 1
fi
printf 'built %s\n' "$1"
return 0
}
# ---- top level: the only place that exits --------------------------------
[ $# -ge 1 ] || die "need at least one target"
failed=0
unknown=0
for t in "$@"; do
build "$t"
case $? in
0) ;;
1) failed=$(( failed + 1 )) ;;
2) unknown=$(( unknown + 1 )) ;;
esac
done
printf -- '-- %d targets, %d failed, %d unknown\n' "$#" "$failed" "$unknown"
if [ "$unknown" -gt 0 ]; then exit 2; fi
[ "$failed" -eq 0 ]$ ./build-all.sh rtl docs
built rtl
built docs
-- 2 targets, 0 failed, 0 unknown
$ echo $?
0
$ ./build-all.sh rtl tb docs
built rtl
build-all.sh: build of tb failed
built docs
-- 3 targets, 1 failed, 0 unknown
$ echo $?
1
$ ./build-all.sh rtl bogus
built rtl
build-all.sh: unknown target: bogus
-- 2 targets, 0 failed, 1 unknown
$ echo $?
2
$ ./build-all.sh
build-all.sh: need at least one target
$ echo $?
2Five decisions from this chapter:
build returns three different numbers and the loop reads them with a case — Chapter 16.6's form. If build had called exit 1 on a failure, the docs target would never have been attempted and the summary would never have printed.
is_target is a pure predicate. It returns 0 or 1, prints nothing, and exits never — which is what lets build use it in a condition and lets a future caller use it anywhere else.
die exits, and is the only function that does. Its name is the documentation.
The loop continues past failures and the script still reports correctly. That is the entire payoff of return over exit: the script can do all the work and then tell the truth about it, rather than stopping at the first problem.
exit 2 beats exit 1 for unknown targets, because "you asked for something that does not exist" is a usage error and "the build broke" is a failure. Chapter 15.6 argued that distinction is the most valuable thing a script can offer a caller, and three distinct statuses here is what makes it available.
Practice
- Write the Section 1 script and run it. Confirm that neither
NOT REACHEDprints, and that only one of the two following lines does. - Write
check_dirwithexit 2and use it in a loop with|| continue. Show that the loop stops at the first bad directory, then fix it withreturn 2. - Write
f() { false; echo done; }andf() { echo done; false; }. Print$?for each. Which one lies, and what is the general rule? - Take the lying version and fix it three ways — reordering, capturing
rc, and an explicitreturn. Which do you prefer when the function also has to log? - Run
f() { return 256; }; f; echo $?andf() { return 255; }; f; echo $?. Then writecount_errorsthe wrong way (returning the count) and find an input that makes it report success. - Run
bash -c 'return 1'and read the whole error message. Then makereturnwork by putting it in a sourced file, and show it hands control back to the caller. - Put
exit 5in a sourced file and show it kills the sourcing script. State the rule the two experiments together imply. - Run the three
set -ecases from §6. Then write a predicate function, call it bare underset -e, and explain the abort. - Write
die()withexit 1, call it from a function, and capture that function with$( ). Confirm the script continues. Then add|| exit 1at the capture site and confirm it does not. - In
build-all.sh, changebuild's unknown-target branch fromreturn 2toexit 2. Run it onrtl bogus docsand list everything that no longer happens.
Check Your Understanding
- What does
returnend, and what doesexitend? - Why is
exitinside a helper harmless in a small script and a problem in a large one? - When is a function that exits acceptable?
f() { false; echo done; }— what is$?and why?return 256gives what, and what rule follows?- Where is
returnlegal besides a function? - A
diehelper is called inside$( ). What happens, and what is the fix?
9. Remember
returnends the function;exitends the script. Both stop the function.- A function reports, the top level decides. A helper that exits makes every surrounding
|| continuedead code. dieis the sanctioned exception, because its name is a promise. Never hide an exit behind a reporting name.- With no
return, a function's status is its last command's — so a trailingechomasks the failure for every caller. - Capture
rc=$?on the line after the command when the function must both log and report. returnwith no argument returns the last status.return 256is 0. Never return a count — print it and let the status say whether it worked.returnis also legal at the top of a sourced file, where it hands control back to the sourcing script;exitthere kills the caller.- Under
set -e, a bare failing call aborts but the same call in a condition or with||does not. Predicates belong in conditions. exitinside( )or$( )only ends the subshell. A captureddieprints and does not die; guard the capture site with|| exit.
Chapter 18.4 is the other half of that last point. The status survives command substitution and the variables do not — so getting an actual value out of a function is a choice between two mechanisms, each with a cost.
Where this fits
Part of the Linux & Bash curriculum.
