Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./r.sh
  in with_return
after with_return: status=1 — script continues
  in with_exit
$ echo $?
7

return 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

The difference between returning from a function and exiting. Returning ends the function, hands a number back to the caller, and lets the script carry on deciding what to do about it. Exiting ends the whole script immediately, so nothing after the call runs and the caller never gets to decide anything.Returning ends thefunctionand nothing elseA number goes back tothe callerthe same number any commandreportsThe caller decides whatto doretry, warn, fall back, orgive upExiting ends the wholescriptfrom wherever it was calledNothing after the callrunsno cleanup, no summary, nofallbackThe caller neverdecideswhich is right only for a realdead end12
Returning stops the function and hands a number to whoever called it, so the caller still gets to choose what that number means — retry, warn, fall back, or give up. Exiting stops the whole script from wherever it was reached, so nothing after the call runs and the caller never gets the choice at all.

2. Why It Matters Only Later

In a short script, every failure is fatal and exit inside a helper is harmless:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
check_dir() { [ -d "$1" ] || { echo "no such dir: $1" >&2; exit 2; }; }
check_dir "$1"
# ... the rest

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

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

Measured, with good1 and good2 existing and nope not:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./c1.sh good1 nope good2
processing good1
no such dir: nope
$ echo $?
2

good2 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./c2.sh good1 nope good2
processing good1
c2.sh: no such dir: nope
processing good2
SUMMARY reached
$ echo $?
0

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { echo hi > /dev/null; false; }; f; echo "status=$?"
status=1
$ f() { false; echo hi > /dev/null; }; f; echo "status=$?"
status=0

The 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.

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { false; return; }; f; echo "status=$?"
status=1
$ f() { true;  return; }; f; echo "status=$?"
status=0

Useful, 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { return 255; }; f; echo $?
255
$ f() { return 256; }; f; echo $?
0
$ f() { return -1; };  f; echo $?
255

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# 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"; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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=0

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'return 1'
bash: return: can only `return' from a function or sourced script

Note the second half of that message, because it is a real feature:

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ printf 'echo "  lib line 1"\nexit 5\n' > fatal.sh
$ bash -c 'source ./fatal.sh; echo "  NEVER PRINTED"'
  lib line 1
$ echo $?
5

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

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

A 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":

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set -e
has_errors run.log            # aborts the script when there are no errors!
if has_errors run.log; then   # correct

Chapter 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.

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

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
name=$(derive_name "") || exit 1

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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 ]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./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 $?
2

Five 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

  1. Write the Section 1 script and run it. Confirm that neither NOT REACHED prints, and that only one of the two following lines does.
  2. Write check_dir with exit 2 and use it in a loop with || continue. Show that the loop stops at the first bad directory, then fix it with return 2.
  3. Write f() { false; echo done; } and f() { echo done; false; }. Print $? for each. Which one lies, and what is the general rule?
  4. Take the lying version and fix it three ways — reordering, capturing rc, and an explicit return. Which do you prefer when the function also has to log?
  5. Run f() { return 256; }; f; echo $? and f() { return 255; }; f; echo $?. Then write count_errors the wrong way (returning the count) and find an input that makes it report success.
  6. Run bash -c 'return 1' and read the whole error message. Then make return work by putting it in a sourced file, and show it hands control back to the caller.
  7. Put exit 5 in a sourced file and show it kills the sourcing script. State the rule the two experiments together imply.
  8. Run the three set -e cases from §6. Then write a predicate function, call it bare under set -e, and explain the abort.
  9. Write die() with exit 1, call it from a function, and capture that function with $( ). Confirm the script continues. Then add || exit 1 at the capture site and confirm it does not.
  10. In build-all.sh, change build's unknown-target branch from return 2 to exit 2. Run it on rtl bogus docs and list everything that no longer happens.

Check Your Understanding

  1. What does return end, and what does exit end?
  2. Why is exit inside a helper harmless in a small script and a problem in a large one?
  3. When is a function that exits acceptable?
  4. f() { false; echo done; } — what is $? and why?
  5. return 256 gives what, and what rule follows?
  6. Where is return legal besides a function?
  7. A die helper is called inside $( ). What happens, and what is the fix?

9. Remember

  • return ends the function; exit ends the script. Both stop the function.
  • A function reports, the top level decides. A helper that exits makes every surrounding || continue dead code.
  • die is 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 trailing echo masks the failure for every caller.
  • Capture rc=$? on the line after the command when the function must both log and report.
  • return with no argument returns the last status.
  • return 256 is 0. Never return a count — print it and let the status say whether it worked.
  • return is also legal at the top of a sourced file, where it hands control back to the sourcing script; exit there 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.
  • exit inside ( ) or $( ) only ends the subshell. A captured die prints 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.