Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 7 – Functions and script structure

Returning values from functions

A Bash function returns only a status, so its data travels on standard output or into a variable the caller names with printf -v, a nameref or a global.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Capture the output of a function with a command substitution and read its status
  • Explain why a captured function cannot change the variables of its caller
  • Write a function that stores its result in a variable the caller names
  • Send progress messages to standard error so that a capture collects only the data
  • Choose between a capture, an output parameter and a global for a function of your own

Before you start

What you’ll learn · Before you start

What you will learn

  • Capture the output of a function with a command substitution and read its status
  • Explain why a captured function cannot change the variables of its caller
  • Write a function that stores its result in a variable the caller names
  • Send progress messages to standard error so that a capture collects only the data
  • Choose between a capture, an output parameter and a global for a function of your own

Before you start

On this page

A function that has worked out an answer has nowhere to put it: return carries a number from 0 to 255 and nothing else, so verdict=$(grade 55) leaves verdict empty even though the function ran and succeeded. Text and numbers reach the caller on other channels, and the channel you pick decides something bigger than syntax: whether the function may also change the caller’s variables, or runs in a copy of the shell whose changes are thrown away.

You will capture a function’s output and read its status, watch a counter rise inside that copy and fall back to its old value, write results into variables the caller names with printf -v and a nameref: A variable made with declare -n or local -n that stands for another variable, so every use of it reads or changes the variable it names. More in “Indirection, namerefs and ${var@Q} transforms”, keep progress messages out of the captured data, and decide which channel fits a function of your own.

return hands back a number, so data leaves on standard output

return ends a function and sets its exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it”, the number the functions lesson used to say whether a check had passed. A status is one small number, so there is no room in it for a word, a line or a list. The session below calls grade, which returns 0 for a pass, and then captures the same call: the status is 0 both times, and the captured value is empty, because the function printed nothing at all.

A function that is meant to hand data back prints it instead. initials prints two characters, and who=$(initials Asha Mehta) stores them: a command substitution: $(command), or the older backquoted form: Bash runs the command in a subshell and puts its standard output in place of the substitution, with every trailing newline removed. More in “Command and process substitution” replaces the call with whatever the function wrote on its standard output: Descriptor 1, where a command writes its results: the terminal, unless > file, >> file or a pipe points it somewhere else. Also called stdout. More in “Redirection: stdin, stdout, stderr, /dev/null”. Watch both halves, and the third pair of lines where a value and a status arrive from one call:

return with nothing to capture, a printed value, and a value with its status Python · channels_session.py
from session import run

run("""
# return ends the function with a number, so there is nothing to capture
grade() { [ "$1" -ge 40 ] && return 0; return 1; }
grade 55; echo "status $?"
verdict=$(grade 55); echo "verdict=[$verdict] status $?"
# a function that PRINTS its answer can be captured
initials() { printf '%s%s' "${1:0:1}" "${2:0:1}"; }
who=$(initials Asha Mehta); echo "who=$who"
# the value and the status arrive together
digits() { case $1 in *[!0-9]*) return 1 ;; esac; printf '%s' "${#1}"; }
n=$(digits 560034); echo "n=$n status $?"
!n=$(digits 56A034)
echo "n=[$n] status $?"
""")

Output

# return ends the function with a number, so there is nothing to capture
$ grade() { [ "$1" -ge 40 ] && return 0; return 1; }
$ grade 55; echo "status $?"
status 0
$ verdict=$(grade 55); echo "verdict=[$verdict] status $?"
verdict=[] status 0
# a function that PRINTS its answer can be captured
$ initials() { printf '%s%s' "${1:0:1}" "${2:0:1}"; }
$ who=$(initials Asha Mehta); echo "who=$who"
who=AM
# the value and the status arrive together
$ digits() { case $1 in *[!0-9]*) return 1 ;; esac; printf '%s' "${#1}"; }
$ n=$(digits 560034); echo "n=$n status $?"
n=6 status 0
$ n=$(digits 56A034)
[exit status 1]
$ echo "n=[$n] status $?"
n=[] status 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 channels_session.py

digits counts the characters of its argument, but only when every one of them is a digit: n=$(digits 560034) printed 6 with status 0, and n=$(digits 56A034) left n empty with status 1. The two channels are independent, so one call can say both “here is the answer” and “this did not work”.

One detail of capturing is worth knowing before you rely on it: a command substitution deletes every newline at the end of the output, so a function that prints a file cannot hand back the blank lines it ends with. The command and process substitution lesson works through that rule and the sentinel trick that gets around it.

Key idea

return sets a status, never a value: a function hands data back by printing it, and the caller collects the print with v=$(fn) while still reading the status of the call.

Checkpoint

Check: return against printed output

  1. Question 1 of 2 A function ends with return 0 and prints nothing. What does v=$(fn) put in v?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing: v is empty

    return sets the exit status, and only printed bytes reach a capture. To hand data back the function has to print it.

  2. Question 2 of 2 digits returns 1 for any argument that holds a non-digit. What is $? right after n=$(digits 56A034)?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1

    The assignment keeps the status of the command substitution, and digits returned 1 for the letter A, leaving n empty. With 560034 the same line gave 6 and status 0.

A capture runs the function in a copy of the shell

Printing has a price. The manual says a command substitution runs its command in a subshell, and a subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read” is a copy of the shell with variables of its own. The function really runs, and what it changes really changes, but in the copy. When the copy ends, the variables it set and the folder it moved to end with it; only a file it wrote stays, because the file system is shared.

bump adds one to tally and prints what it saw. Called inside a capture it printed “bump saw 3”; afterwards the caller still had tally=2. Called plainly, the same function left tally=3. BASH_SUBSHELL, which Bash keeps as the number of subshells the current command sits inside, said why: 0 in the caller and 1 inside the capture. A cd goes the same way, as the last pair of lines shows:

A counter and a cd that a capture loses, with BASH_SUBSHELL Python · subshell_session.py
from session import run

run("""
# a counter the function raises, read back by the caller
tally=2
bump() { tally=$(( tally + 1 )); echo "bump saw $tally"; }
note=$(bump); echo "$note"; echo "the caller still has tally=$tally"
bump; echo "after a plain call, tally=$tally"
# why: BASH_SUBSHELL counts the copies of the shell around the command
depth() { echo "running at depth $BASH_SUBSHELL"; }
depth
d=$(depth); echo "$d"
# the folder a capture moves to is lost as well
mkdir reports
enter() { cd reports; pwd; }
there=$(enter); echo "the function reached $there"; echo "the caller is in $PWD"
enter > /dev/null; echo "after a plain call, the caller is in $PWD"
""")

Output

# a counter the function raises, read back by the caller
$ tally=2
$ bump() { tally=$(( tally + 1 )); echo "bump saw $tally"; }
$ note=$(bump); echo "$note"; echo "the caller still has tally=$tally"
bump saw 3
the caller still has tally=2
$ bump; echo "after a plain call, tally=$tally"
bump saw 3
after a plain call, tally=3
# why: BASH_SUBSHELL counts the copies of the shell around the command
$ depth() { echo "running at depth $BASH_SUBSHELL"; }
$ depth
running at depth 0
$ d=$(depth); echo "$d"
running at depth 1
# the folder a capture moves to is lost as well
$ mkdir reports
$ enter() { cd reports; pwd; }
$ there=$(enter); echo "the function reached $there"; echo "the caller is in $PWD"
the function reached ~/demo/reports
the caller is in ~/demo
$ enter > /dev/null; echo "after a plain call, the caller is in $PWD"
after a plain call, the caller is in ~/demo/reports

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 subshell_session.py

Follow the call once more, this time as a sequence between the caller and the copy, and note what crosses back:

Interactive · message by message What a capture does with the counter Step through note=$(bump) and then the same call without a capture.
What a capture does with the counter, step 6 of 6. Messages so far: The caller to The capture: runs bump; The capture to The caller: bump saw 3.The callertally=3, keptThe capturegoneruns bumpbump saw 3

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The caller reaches the call. The shell is about to run note=$(bump). tally holds 2, and bump adds one to it and prints what it saw.
  2. A copy of the shell starts. A command substitution runs its command in a subshell, a copy of the shell with variables of its own. The copy inherits tally=2 and a pipe for sending its output back.
  3. The copy raises its own counter. Inside the copy, tally=$(( tally + 1 )) makes tally 3 and the function prints "bump saw 3". BASH_SUBSHELL read 1 there and 0 in the caller, which is how the session proved there were two shells.
  4. Only the output comes back. The standard output of the copy travels up the pipe. note takes the text "bump saw 3", and the status of the copy becomes the status of the assignment.
  5. The copy ends, and its tally too. The subshell exits. Its variables were never the caller's, so the caller still has tally=2. The function did its work, and the counter it raised is gone with the copy.
  6. The same call without a capture. Called plainly, bump runs in the shell that called it, so tally becomes 3 and stays 3. Nothing is captured, and the printed line goes straight to the terminal.

Only two things come back from the copy: the bytes it printed and its exit status. That is the whole contract, and it explains a long list of surprises, among them a function whose counter never grows, a list that never gains an item and a read whose answer is gone by the next line. The reading files line by line lesson shows the same loss with a pipe into while, which is a subshell for the same reason.

A total that stays at zero

Wrong: add_to_total() { total=$(( total + $1 )); } called as out=$(add_to_total 5), after which total is unchanged and out is empty. Right: call it plainly, add_to_total 5, and read $total afterwards; capture only functions whose answer is something they print.

Checkpoint

Check: The subshell a capture starts

  1. Question 1 of 2 depth() { echo "$BASH_SUBSHELL"; }. At the prompt of an interactive shell, what does d=$(depth) store in d?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 1

    A command substitution runs its command one subshell deep, so BASH_SUBSHELL reads 1 inside the capture and 0 when the same function is called plainly.

  2. Question 2 of 2 tally is 2. After note=$(bump), where bump raises tally by one and prints it, what does the caller see?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: note holds the printed line and tally is still 2

    The session printed "bump saw 3" from inside the capture and then "the caller still has tally=2".

An output parameter lets the caller name the variable to fill

The way to keep a function’s work is to let it run in the caller’s own shell and have it write the answer where the caller can read it. An output parameter: An argument that names the variable a function must write its result into, so the caller receives the value without capturing the function's output in a subshell. does that: the caller passes the name of a variable, and the function writes into it.

printf -v name '%s' "$value" is the portable-in-Bash form, there since bash-3.1, and from bash-4.1 it can write into an array element too. A nameref, local -n out=$1, makes the alias explicit: assignments to out land in the variable named by $1. The indirection and namerefs lesson covers namerefs in full, including the mistake that bites when the caller passes the nameref’s own name. The third form needs no argument at all: the function sets an agreed global variable: A shell variable that no running function declared local, so every function of the script can read it and change it, and its value outlives the call that set it., which is what Bash’s own read does with REPLY.

three-ways.sh answers one question, “what is the value of this key?”, through all three channels, so the three calls can be compared line by line:

three-ways.sh: one value, handed back three ways Shell · three-ways.sh
#!/usr/bin/env bash
# Three ways for a function to hand back one value: print it, write it with printf -v, write it through a nameref.
# Each one answers the same question: what is the value of this key in the settings text?

settings='retries=4
timeout=30
region=south'

# 1. Print the value; the caller captures the output.
value_of() {
  local line
  while IFS= read -r line; do
    case $line in
      "$1="*) printf '%s' "${line#*=}"; return 0 ;;
    esac
  done <<< "$settings"
  return 1
}

# 2. Write the value into the variable named by the first argument (printf -v, Bash 3.1 and later).
value_into() {
  local line
  while IFS= read -r line; do
    case $line in
      "$2="*) printf -v "$1" '%s' "${line#*=}"; return 0 ;;
    esac
  done <<< "$settings"
  return 1
}

# 3. The same, through a nameref (Bash 4.3 and later).
# shellcheck disable=SC2034  # out is a nameref: assigning it writes the variable the caller named
value_ref() {
  local -n out=$1
  local line
  while IFS= read -r line; do
    case $line in
      "$2="*) out=${line#*=}; return 0 ;;
    esac
  done <<< "$settings"
  return 1
}

main() {
  local printed written referred
  printed=$(value_of timeout)
  printf 'printed:  timeout=%s\n' "$printed"

  value_into written region
  printf 'printf -v: region=%s\n' "$written"

  value_ref referred retries
  printf 'nameref:  retries=%s\n' "$referred"

  if printed=$(value_of proxy); then
    printf 'printed:  proxy=%s\n' "$printed"
  else
    printf 'printed:  no proxy key (status %d)\n' "$?"
  fi
}

main "$@"

The run shows each form filling a variable, and the last call shows the printing form reporting a key that is not there. The commands after it do the same thing at the prompt, and the final line marks the limit of printf -v:

three-ways.sh, the two writers at the prompt, and dash refusing printf -v Python · three_ways_session.py
from session import files, run

files("three-ways.sh")
run("""
bash three-ways.sh
shellcheck three-ways.sh; echo "shellcheck said nothing, status $?"
# the same two writers, typed at the prompt: no capture, no subshell
fill_in() { printf -v "$1" '%s' "$2"; }
fill_in city Chennai; echo "city=$city"
hold() { local -n slot=$1; slot=$2; }
hold state Tamil-Nadu; echo "state=$state"
# a writer can report how its own work went, because its status is free
fill_digits() { case $2 in *[!0-9]*) return 1 ;; esac; printf -v "$1" '%s' "$2"; }
fill_digits pin 600001 && echo "pin=$pin"
!fill_digits pin 60O001
echo "pin is still $pin, status was 1"
# printf -v is Bash only: dash has no such option
!dash -c 'printf -v x "%s" hi'
""")

Output

$ bash three-ways.sh
printed:  timeout=30
printf -v: region=south
nameref:  retries=4
printed:  no proxy key (status 1)
$ shellcheck three-ways.sh; echo "shellcheck said nothing, status $?"
shellcheck said nothing, status 0
# the same two writers, typed at the prompt: no capture, no subshell
$ fill_in() { printf -v "$1" '%s' "$2"; }
$ fill_in city Chennai; echo "city=$city"
city=Chennai
$ hold() { local -n slot=$1; slot=$2; }
$ hold state Tamil-Nadu; echo "state=$state"
state=Tamil-Nadu
# a writer can report how its own work went, because its status is free
$ fill_digits() { case $2 in *[!0-9]*) return 1 ;; esac; printf -v "$1" '%s' "$2"; }
$ fill_digits pin 600001 && echo "pin=$pin"
pin=600001
$ fill_digits pin 60O001
[exit status 1]
$ echo "pin is still $pin, status was 1"
pin is still 600001, status was 1
# printf -v is Bash only: dash has no such option
$ dash -c 'printf -v x "%s" hi'
dash: 1: printf: Illegal option -v
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_ways_session.py

Three facts from that run are worth keeping. A writer keeps its status for itself, so fill_digits could refuse 60O001 with status 1 and leave the old value in place. ShellCheck does not follow a nameref, so without the directive above value_ref it reports the assignment to out as SC2034, “appears unused”; its page calls that an intended decision for variables reached only through indirection, and the directive’s comment says why the warning does not apply here. And printf -v is Bash’s own: dash answered “printf: Illegal option -v” with status 2.

Which of the four channels you can reach for depends on the oldest shell your script has to run on, so the table lists each one with the release that brought it and the call it leads to:

Channel Arrived in The caller writes
Printed output every shell v=$(fn arg)
printf -v bash-3.1 fn v arg, then reads $v
Nameref local -n bash-4.3 fn v arg, then reads $v
An agreed global every shell fn arg, then reads $REPLY

The last row is the one to watch: a global needs no argument, which is why it is tempting, and gives every caller the same name, which is why a nested call can overwrite a value its caller had not read yet.

Bash 5.3 adds two brace forms

${ fn; } captures output without forking a child, and ${| fn; } expands to REPLY after the function has run, so a function can hand back a value with neither a print nor a subshell. Both are new in bash-5.3, and older Bash versions, dash and macOS’s /bin/bash 3.2.57 reject them, so keep them for scripts whose Bash you control.

Checkpoint

Check: Output parameters and globals

  1. Question 1 of 2 fill_in() { printf -v "$1" %s "$2"; }. What does fill_in city Chennai do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It sets the caller variable city to Chennai and prints nothing

    In the session fill_in city Chennai left city=Chennai with no output and no subshell.

  2. Question 2 of 2 fill_digits writes with printf -v only when the text is all digits, else returns 1. pin is 600001; what follows fill_digits pin 60O001?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: pin is still 600001 and the status is 1

    A writer keeps its status for its own verdict, which is why the session could print "pin is still 600001, status was 1".

Data on standard output, messages on standard error

Once standard output is the channel for data, anything else printed there becomes part of the data. A progress line, a warning, a “reading file…” note: each one is collected by the caller’s capture and arrives glued to the value.

stderr-logs.sh counts the lines of a log file twice. count_loud prints its progress line normally; count_quiet sends the same line to standard error: Descriptor 2, where a command writes its warnings and error messages, kept apart from its results so that they still show when standard output goes to a file or a pipe. Also called stderr. More in “Redirection: stdin, stdout, stderr, /dev/null” with >&2. Both functions end by printing the count, and both are captured the same way:

stderr-logs.sh: the same count, with the note on each channel Shell · stderr-logs.sh
#!/usr/bin/env bash
# A function whose progress line was captured together with its answer, and the same function with the line sent to
# standard error instead. Both report the number of lines in a log file.

# Writes its progress line on standard output, where a capture collects it with the answer.
count_loud() {
  echo "reading $1"
  wc -l < "$1" | tr -d ' '
}

# Writes its progress line on standard error, which no capture collects.
count_quiet() {
  echo "reading $1" >&2
  wc -l < "$1" | tr -d ' '
}

main() {
  local loud quiet
  loud=$(count_loud "$1")
  printf 'loud:  [%s]\n' "$loud"
  if [ "$loud" -gt 1 ]; then
    printf 'loud:  more than one line\n'
  else
    printf 'loud:  the test could not read it as a number\n'
  fi

  quiet=$(count_quiet "$1")
  printf 'quiet: [%s]\n' "$quiet"
  if [ "$quiet" -gt 1 ]; then
    printf 'quiet: more than one line\n'
  fi
}

main "$@"

Read the two values the run prints, and the test that follows each of them:

A progress line inside the value, then the same function with the note on standard error Python · stderr_session.py
from session import files, run, write

files("stderr-logs.sh")
write("app.log", "start\nERROR disk full\nretry\ndone\n")
run("""
bash stderr-logs.sh app.log
# the message still reaches the terminal, and 2> /dev/null shows what the data channel carries
bash stderr-logs.sh app.log 2> /dev/null
# a function can report progress and hand back a value at the same time
pick() { echo "looking for $1" >&2; grep -c "$1" app.log; }
hits=$(pick ERROR); echo "hits=$hits"
echo "the line above came from the terminal, not from hits"
""")

Output

$ bash stderr-logs.sh app.log
loud:  [reading app.log
4]
stderr-logs.sh: line 21: [: reading app.log
4: integer expected
loud:  the test could not read it as a number
reading app.log
quiet: [4]
quiet: more than one line
# the message still reaches the terminal, and 2> /dev/null shows what the data channel carries
$ bash stderr-logs.sh app.log 2> /dev/null
loud:  [reading app.log
4]
loud:  the test could not read it as a number
quiet: [4]
quiet: more than one line
# a function can report progress and hand back a value at the same time
$ pick() { echo "looking for $1" >&2; grep -c "$1" app.log; }
$ hits=$(pick ERROR); echo "hits=$hits"
looking for ERROR
hits=1
$ echo "the line above came from the terminal, not from hits"
the line above came from the terminal, not from hits

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stderr_session.py

The loud version put “reading app.log” and the number 4 into one variable, and the arithmetic test on it failed with “integer expected” instead of comparing anything. The quiet version gave the clean value 4, and the note still appeared on the terminal, because standard error was never captured: that is the point of having two channels rather than one. Running the script with 2> /dev/null makes it plainer still, as the second call shows: the notes vanish and both values stay exactly as they were.

So a function that hands data back keeps a simple rule: the result goes to standard output, everything a person needs to read goes to standard error. The redirection lesson covers the descriptors behind >&2.

Key idea

Treat a function’s standard output as its return value: print the result and nothing else there, and send every message meant for a person to standard error with >&2.

Checkpoint

Check: Data and messages apart

  1. One question count_loud prints "reading app.log" and then 4, both on standard output. What does loud=$(count_loud app.log) hold?

    Choose one answer.

    Show the answer: One question

    Answer: Both lines, the note and the number, in one value

    The session printed loud as "reading app.log" and 4 together, and then [ "$loud" -gt 1 ] answered "integer expected"; count_quiet, with the note sent to standard error, gave the clean value 4.

A function can hand back a value and a status at once

A capture used as an assignment keeps the status of the command inside it, so one line can collect a value and test whether the call worked. if p=$(port_of https); then printed the port, and if p=$(port_of gopher) took the other branch and reported status 1. This is the shape to reach for whenever a lookup may fail: no sentinel value to invent, no output to parse.

One builtin: A command that is part of the shell itself rather than a separate program, such as cd, echo or type in Bash. help NAME documents it. More in “Commands, options, arguments and help” breaks it. local, like declare and export, has a status of its own, and on a line such as local p=$(port_of "$1") that status is what the caller sees, so a failed call reads as a success. Declaring the variable first keeps the two apart. The session runs both forms and then lets ShellCheck read the file:

A value with its status, local masking it, and what ShellCheck says Python · status_session.py
from session import run, write

write("report.sh", """#!/usr/bin/env bash
port_of() { case $1 in http) echo 80 ;; https) echo 443 ;; *) return 1 ;; esac; }

one_line() {
  local p=$(port_of "$1")
  echo "one line: status $?, port [$p]"
}

two_lines() {
  local p
  p=$(port_of "$1")
  echo "two lines: status $?, port [$p]"
}

one_line gopher
two_lines gopher
""")
run("""
port_of() { case $1 in http) echo 80 ;; https) echo 443 ;; *) return 1 ;; esac; }
if p=$(port_of https); then echo "https uses port $p"; fi
if p=$(port_of gopher); then echo "port $p"; else echo "no port for that name, status $?"; fi
bash report.sh
!shellcheck report.sh
""")

Output

$ port_of() { case $1 in http) echo 80 ;; https) echo 443 ;; *) return 1 ;; esac; }
$ if p=$(port_of https); then echo "https uses port $p"; fi
https uses port 443
$ if p=$(port_of gopher); then echo "port $p"; else echo "no port for that name, status $?"; fi
no port for that name, status 1
$ bash report.sh
one line: status 0, port []
two lines: status 1, port []
$ shellcheck report.sh

In report.sh line 5:
  local p=$(port_of "$1")
        ^-- SC2155 (warning): Declare and assign separately to avoid masking return values.

For more information:
  https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ...
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 status_session.py

one_line reported status 0 for a call that had failed; two_lines, with local p on its own line, reported 1. ShellCheck marked the first one as SC2155, “Declare and assign separately to avoid masking return values”, and exited

  1. The command and process substitution lesson treats this rule and the other statuses an assignment can leave.

With both channels understood, the choice between them is a short list of questions about your own function: which shell has to run it, how often it is called, and whether the value must keep its bytes. Answer them in the table and watch the options drop out:

Interactive · answer and compare Which channel should your function use? Answer the three questions about your own function and watch options drop out.
Must the function run under POSIX sh, a script whose shebang names /bin/sh?
Is the function called thousands of times in one run of the script?
Must the value keep the newlines at its end exactly as they were?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to hand a value back, against the things that decide between them
Channel Starts a subshell Keeps the changes it makes Keeps trailing newlines Runs in POSIX sh Risk of a name clash
Printed output The caller reads: what the function printed Poor one fork per call; BASH_SUBSHELL read 1 inside the capture and 0 outside Poor the copy ends with the call, so the counter stayed at 2 Poor a capture deletes every newline at the end of the output Good plain POSIX shell, so dash and sh have it Good no variable name crosses the call
printf -v The caller reads: a variable it named Good nothing is forked; the function runs in the calling shell Good anything else it sets is still there after the call Good the bytes given to printf are the bytes stored Poor dash answered "printf: Illegal option -v" with status 2 Mixed a local of the same name inside the function takes the write
A nameref, local -n The caller reads: a variable it named Good no child process, so the call count hardly matters Good writes straight into the variable the caller named Good a plain assignment keeps the value exactly Poor local -n arrived in Bash 4.3 and is not in the standard Poor a name that matches the nameref gives a circular reference
A global or REPLY The caller reads: an agreed variable Good no fork and no pipe between the two shells Good the value outlives the call that set it Good an assignment stores the value as it is Good a plain assignment, so every shell has it Poor one shared name, so a nested call overwrites it
Bash 5.3 ${| fn; } The caller reads: REPLY, without a fork Good the function runs in the calling shell; nothing is forked Good the command runs in the current shell, as a function would Good REPLY is used as it is, newlines at the end included Poor Bash 5.3 and later only; older shells say bad substitution Mixed Bash makes REPLY local to the substitution and restores it, but callers still share the one name
How the answers decide
  • “Must the function run under POSIX sh, a script whose shebang names /bin/sh?” Yes, sh has to run it: rules out printf -v, A nameref, local -n and Bash 5.3 ${| fn; }. dash has no printf -v, no namerefs and no brace capture, so printed output or an agreed global name is what is left.
  • “Must the function run under POSIX sh, a script whose shebang names /bin/sh?” No, Bash is guaranteed: adds a caveat to Bash 5.3 ${| fn; }. The brace forms need Bash 5.3, so a script that may meet an older Bash keeps away from them.
  • “Is the function called thousands of times in one run of the script?” Yes, in a hot loop: rules out Printed output. Every capture forks a child shell and reads a pipe, and that cost is paid once per call.
  • “Must the value keep the newlines at its end exactly as they were?” Yes, byte for byte: rules out Printed output. A capture deletes the newlines at the end, so what the caller gets is not what the function printed.
  • “Is the function called thousands of times in one run of the script?” Yes, in a hot loop and “Must the function run under POSIX sh, a script whose shebang names /bin/sh?” No, Bash is guaranteed: adds a caveat to A global or REPLY. A global costs nothing per call but gives every caller the same name, so nested calls have to agree on it.
  • “Is the function called thousands of times in one run of the script?” No, a handful of times and “Must the function run under POSIX sh, a script whose shebang names /bin/sh?” No, Bash is guaranteed and “Must the value keep the newlines at its end exactly as they were?” No, a plain word or number: adds a caveat to A nameref, local -n. For a few calls the clearest form wins; a nameref still needs a name the function does not use itself.

A #!/bin/sh script keeps to a capture or a global, because printf -v and namerefs are Bash’s own; a writer starts no child process at all; and a global is the cheapest and the least careful, because every caller shares the one name.

A failure hidden behind local

Wrong: local first=$(head -n 1 -- "$1") followed by a test of $?, which reads the status of local, 0 even when the file is missing. Right: local first on one line, then first=$(head -n 1 -- "$1") || return 1, which returns 1 for a missing file.

Checkpoint

Check: A value with its status

  1. Question 1 of 2 port_of returns 1 for an unknown name. Inside a function, which form lets the caller see that 1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: local p on one line, then p=$(port_of "$1") on the next

    A line that runs a builtin reports the builtin's status. Declaring and assigning separately keeps the two apart.

  2. Question 2 of 2 A helper in a #!/bin/sh script must hand a value back to its caller. Which channel is left?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Printed output captured with $( ), or an agreed global variable

    The trade-off table drops the three Bash-only forms as soon as you answer that sh has to run the function.

Practise: writers that fill the caller’s variables

Write these three functions in your own shell without a single $( ) inside them, using printf -v or a nameref for every result, then run the sample calls and compare what you get:

  1. ext_into VAR FILE puts the extension of a file name, the part after its last dot, into the variable named by VAR. ext_into e report.tar.gz must leave e as gz with status 0, and ext_into e README must give status 1 and leave e as it was.
  2. max_into VAR ARRAYNAME reads an array the caller names and puts its largest value into the variable named by VAR. With marks=(63 91 48 77), max_into top marks must leave top as 91 with status 0, and an empty array must give status 1 and leave top as it was.
  3. to_upper_into VAR TEXT puts TEXT in upper case into the variable named by VAR, so to_upper_into shout "chennai 600001" leaves shout as CHENNAI 600001.

Practice that adapts to you

Practise this lesson

12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What can return hand back to the caller of a function?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: A number from 0 to 255, as the exit status

    The manual and POSIX both define return as ending a function with a given exit status.

  2. Question 2 of 12 initials prints the first letter of each of its two arguments. What is in who after who=$(initials Asha Mehta)?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: AM

    The session printed who=AM. A capture collects the bytes of standard output, newline or not.

  3. Question 3 of 12 A lookup may fail and, when it works, yields a value. Which design needs no invented sentinel?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Print the value, and report failure with a non-zero status

    The session used it twice: digits gave 6 with status 0 and nothing with status 1, and three-ways.sh reported a missing key as status 1.

  4. Question 4 of 12 enter() { cd reports; pwd; } is called as there=$(enter) from ~/demo. Where is the caller afterwards?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Still in ~/demo, while there holds the path of ~/demo/reports

    A capture is a copy of the shell: the current folder, variables, traps and options it changes are all its own.

  5. Question 5 of 12 log_line appends its argument to a variable buffer and prints ok. It is always called as s=$(log_line "$msg"). Why is buffer empty at the end?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Each call appends in a new subshell, which ends with the capture

    Call such a function plainly and read the variable afterwards, or let the function print what the caller needs.

  6. Question 6 of 12 Which Bash version brought namerefs, the local -n and declare -n form?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: bash-4.3

    printf -v works from bash-3.1 and writes into array elements from bash-4.1, so it reaches older shells than a nameref does.

  7. Question 7 of 12 A function sets REPLY instead of taking a variable name. What is the main drawback?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Every caller shares the one name, so a nested call overwrites it

    Bash's own read uses REPLY this way. It is the cheapest channel and the least careful one.

  8. Question 8 of 12 ShellCheck reports SC2034, "out appears unused", on a nameref a function assigns. What is the right response?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Keep the nameref and add a disable directive with the reason

    three-ways.sh carries the directive above value_ref, and ShellCheck 0.11.0 then reported nothing at all.

  9. Question 9 of 12 Where should a function send a line that tells the user what it is doing?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Standard error, with >&2

    In the session the quiet function printed its note on standard error and still handed back the clean count 4.

  10. Question 10 of 12 You suspect a function leaks messages into its value. Which one-line check shows it?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Run v=$(fn) 2> /dev/null and look at what v holds

    The session ran the script with 2> /dev/null for the same reason: the notes vanished and the values stayed as they were.

  11. Question 11 of 12 What does if p=$(port_of https); then echo "port $p"; fi do?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Stores the printed port and runs the branch when port_of succeeded

    The session printed "https uses port 443" and then, for an unknown name, "no port for that name, status 1".

  12. Question 12 of 12 port_of returns 1 for gopher. What does echo $? print right after export p=$(port_of gopher)?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: 0, because the line reports the status of export

    The manual gives export a return status of its own, zero unless an option or a name is invalid, so the failed lookup disappears and p is simply empty. Write export p on one line and the assignment on the next, as with local, whenever the status matters.

Interview questions

Warm-up (fresher to 2–5 years): why can return not hand text back, and which channels does a function use for data?

return ends a function and sets its exit status, and a status is one small number, 0 to 255, so there is nowhere in it for a word or a line. A capture of such a function is empty: with grade 55 returning 0, verdict=$(grade 55) left verdict empty and the status 0. Data travels on other channels. The function can print it, and the caller collects the print with v=$(fn), which runs the function in a subshell. Or the caller names a variable and the function writes into it, with printf -v "$1" '%s' "$value" or a nameref, which costs no subshell and keeps every other change the function makes. An agreed global variable, often REPLY, is the same idea without the argument.

Two harder questions build on this. In one, a report that looks a value up for every row runs for minutes; in the other, a settings parser has to hand back three different results from one call. Try each before you open it:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A billing report calls rate=$(lookup "$region") once per row, and takes minutes on 20,000 rows, although the lookup only reads an associative array. Six other scripts call lookup. Explain where the time goes, how you would measure it, and how you would change the interface without breaking those callers.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    You are writing parse_line for a settings file. For a good line it must hand back a key and a value; for a bad one, a reason a person can read. Its caller loops over 400 lines and reports every bad one. Design the interface and defend it.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why can return not hand text back to the caller?

    return only sets the exit status, one number from 0 to 255. A function hands data back by printing it, and the caller collects the print with v=$(fn) while still reading the status.

  2. What is lost when a function runs inside v=$(fn)?

    Everything except its printed bytes and its status: the capture runs the function in a subshell, so a counter it raises or a folder it changes to is gone when the copy ends.

  3. How can a function fill a variable without running in a subshell?

    Through an output parameter: the caller names the variable, and the function writes it with printf -v "$1" '%s' "$value" (Bash 3.1), a local -n nameref (Bash 4.3), or an agreed global such as REPLY.

  4. Why must a function that returns data print its messages with >&2?

    Standard output is its return value. A progress line or warning printed there is captured along with the value, and the caller's next test on it fails.

  5. Why does if v=$(fn); then work, while local v=$(fn) hides a failure?

    A plain assignment keeps the status of the command it captured. local reports its own status, which is 0, so the failure is lost: ShellCheck reports it as SC2155. Declare first, then assign.

Key takeaways

  • return carries an exit status and nothing else, so a function hands data back by printing it, and the caller collects that print with a command substitution while still reading the status of the call.
  • A capture runs the function in a subshell, a copy of the shell: only the printed bytes and the status cross back, so a counter the function raises or a folder it changes to is lost when the copy ends.
  • An output parameter keeps the work in the caller’s shell: printf -v "$1" '%s' "$value" from bash-3.1, a local -n nameref from bash-4.3, or an agreed global such as REPLY, and the function’s status is left for reporting how the call went.
  • Standard output belongs to the data: send progress lines and warnings to standard error with >&2, or a capture collects them along with the value and the next test on it fails.
  • An assignment from a capture keeps the status of its command, which is why if v=$(fn); then works, and why local v=$(fn) hides a failure behind the status of local, the slip ShellCheck reports as SC2155.

Terms in this lesson

global variable
A shell variable that no running function declared local, so every function of the script can read it and change it, and its value outlives the call that set it.
output parameter
An argument that names the variable a function must write its result into, so the caller receives the value without capturing the function's output in a subshell.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.