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.
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:
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
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:
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
#!/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:
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
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:
#!/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:
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
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:
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
- 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
ext_into VAR FILEputs the extension of a file name, the part after its last dot, into the variable named byVAR.ext_into e report.tar.gzmust leaveeasgzwith status 0, andext_into e READMEmust give status 1 and leaveeas it was.max_into VAR ARRAYNAMEreads an array the caller names and puts its largest value into the variable named byVAR. Withmarks=(63 91 48 77),max_into top marksmust leavetopas 91 with status 0, and an empty array must give status 1 and leavetopas it was.to_upper_into VAR TEXTputsTEXTin upper case into the variable named byVAR, soto_upper_into shout "chennai 600001"leavesshoutasCHENNAI 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.
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.
-
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.
-
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.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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
returncarries 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, alocal -nnameref from bash-4.3, or an agreed global such asREPLY, 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); thenworks, and whylocal v=$(fn)hides a failure behind the status oflocal, 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
- Bash Reference Manual: Shell Functions (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Command Substitution (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Builtins (printf -v, declare -n, local) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Variables (BASH_SUBSHELL, REPLY) (Chet Ramey (GNU Bash maintainer))
- Bash NEWS (printf -v in bash-3.1, namerefs in bash-4.3, ${ command; } in bash-5.3) (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.9.5 Function Definition Command) (The Open Group and IEEE)
- POSIX.1-2024: return (special built-in) (The Open Group and IEEE)
- ShellCheck SC2155: Declare and assign separately to avoid masking return values (ShellCheck)
- ShellCheck SC2034: foo appears unused. Verify it or export it (ShellCheck)
Report a problem with this lesson
Kept only in this browser. Your Learn progress