🧩 Bash Functions in Depth

Dynamic scope, the four ways to return a result (and a 100x speed difference), the last-command status trap, call stacks, the nameref recursion bug, libraries and export -f, all run for real

Video: Bash functions in depth (Shell Scripting video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Bash functions in depth: scope, return values, namerefs, libraries and the traps
declare -f my_function; type -t my_function

Functions are how a shell script stops being a list of commands and becomes a program: named, reusable, testable pieces. The Bash Programming Tutorial (Lesson 7) shows how to write one. This page is about writing good ones: how variable scope really works (it is not what Python or C taught you), the four ways to get a result out of a function and what each one costs, why a function's exit status is often not what you think, how to build and load libraries, how to see the call stack when something goes wrong, and the handful of traps that every shell programmer falls into at least once.
Every example below was run for real with GNU bash 5.2.21 on Ubuntu 24.04, and the output is what came back, including two results that surprised the author: a recursive function that returned 0 instead of 120, and a 20-second loop that took 0.2 seconds after a one-line change. Where a feature is specific to bash (and not POSIX sh) it is said, and the POSIX vs bash page has the portable alternatives.

What a function is, and how to define one

A function is a named group of commands that runs in the CURRENT shell: no new process, so it sees and changes the same variables, the same working directory, the same traps and the same open files as the rest of the script. That is both its power (it can do things an external program cannot, like change a variable or cd) and the source of most of its traps. Two ways to define one; the first is portable and the one to use:
  name() { commands; }              POSIX form
  function name { commands; }       bash form (also accepted with parentheses: function name() { ...; })
The body is any compound command. Usually it is a brace group { ...; }, which runs in the current shell. If the body is parentheses ( ... ), the function runs in a SUBSHELL, so nothing it does to variables or the directory escapes. Both, run for real:
$ cat d2.sh
brace() { cd /tmp; x=changed_by_brace; }
paren() ( cd /var; x=changed_by_paren; echo "inside paren: $PWD x=$x" )
cd /etc; x=original
paren; echo "after paren: $PWD x=$x   (nothing leaked out)"
brace; echo "after brace: $PWD x=$x   (the function changed the caller's shell)"
$ bash d2.sh
inside paren: /var x=changed_by_paren
after paren: /etc x=original   (nothing leaked out)
after brace: /tmp x=changed_by_brace   (the function changed the caller's shell)
The paren form is a cheap way to make a function that "changes directory and does some work" without leaving the caller somewhere else; the price is a fork. Use braces by default.
A function must exist when the line that calls it RUNS, because bash defines it as it reads and executes the definition. Calling it earlier fails; defining functions in any order is fine as long as nothing runs until all are defined (which is why scripts end with a call to main):
$ cat d1.sh
echo "calling hello before it is defined:"
hello
hello() { echo "hello from the function"; }
hello
main() { helper; }          # main is defined first, helper later: fine, because main only RUNS after both exist
helper() { echo "helper ran"; }
main
$ bash d1.sh
calling hello before it is defined:
d1.sh: line 3: hello: command not found
hello from the function
helper ran
A call to hello on line 3 failed ("command not found": at that moment no such function existed), the call after the definition worked, and main could call helper even though helper was defined after it.

Scope: global by default, dynamic when local

In bash every variable is GLOBAL unless you declare it local. A function that sets i, count or result without local silently changes the caller's variable of the same name. This is the most common function bug in shell scripts. The loop variable of the caller is overwritten here:
$ cat s1.sh
count_files() { for i in 1 2 3; do :; done; }
for i in a b c; do count_files; echo "loop item: $i"; done | head -3
count_files2() { local i; for i in 1 2 3; do :; done; }
for i in a b c; do count_files2; echo "loop item: $i"; done | head -3
$ bash s1.sh
loop item: 3
loop item: 3
loop item: 3
loop item: a
loop item: b
loop item: c
The first loop printed "3" three times: after each call the function's own loop had left i set to 3. With local i the caller's loop was untouched. Rule: the first line of almost every function is a local declaration of EVERY variable it uses, including loop counters (local i), temporary values and the results of read.
local does not work like a block scope in C or Python: bash uses DYNAMIC scope. A local variable is visible not only in the function that declared it but in every function that function calls, until it returns:
$ cat s2.sh
inner() { echo "inner sees x=$x"; x=changed_by_inner; }
outer() { local x=outer_value; inner; echo "outer after inner: x=$x"; }
x=global_value
outer
echo "global after: x=$x"
echo "--- same name as a global, called from a different place"
show() { echo "show sees x=${x-<unset>}"; }
caller1() { local x=from_caller1; show; }
caller1; show
$ bash s2.sh
inner sees x=outer_value
outer after inner: x=changed_by_inner
global after: x=global_value
--- same name as a global, called from a different place
show sees x=from_caller1
show sees x=global_value
inner read outer's local x and even modified it (outer then saw changed_by_inner), while the global x stayed untouched. And the function show printed a different value depending on who called it. Two consequences. First, a function that reads a variable it did not declare or receive as an argument has a hidden dependence on its callers; pass values as arguments. Second, a function can accidentally change its caller's local variable by writing to a name the caller also uses: name your locals clearly, and never assign to a variable you did not declare.
Other forms of local, all tested:
  local -r max=3       read-only inside the function (assigning later is an error: "max: readonly variable")
  local -i n=2+2       integer: the right side is evaluated as arithmetic (n became 4)
  local IFS=,          change IFS for the function only; the caller's value comes back automatically
  local -              save the shell options (set -x and friends) and restore them when the function returns (bash 4.4 and newer)
  declare -g NAME=...  set a GLOBAL from inside a function on purpose (use sparingly, and document it)
$ cat s3.sh      (shortened)
g() { local IFS=,; set -- a b c; echo "inside g: $*"; }
g; echo "outside: IFS is back to default: [$(printf %q "$IFS")]"
h() { local -; set -x; echo "traced line"; }
echo "before h"; h 2>&1 | sed 's/^/   /'; echo "after h: tracing is off again"
k() { local -r max=3; local -i n=2+2; echo "n=$n max=$max"; max=4 2>&1; }
$ bash s3.sh
inside g: a,b,c
outside: IFS is back to default: [$' \t\n']
before h
   + echo 'traced line'
   traced line
after h: tracing is off again (off)
n=4 max=3
s3.sh: line 13: max: readonly variable
Inside g the joined output used a comma (that is what "$*" does with IFS=,), and outside the default IFS was back. local - turned set -x on only for the function. Assigning to the read-only max printed an error.
Two small traps. Unsetting a local does not bring the global back; it just leaves the name unset in that scope:
$ v=global; shadow() { local v=local; unset v; echo "after unset of a local: v=${v-<unset>}"; }; shadow; echo "outside: v=$v"
after unset of a local: v=<unset> (the global is hidden, not restored)
outside: v=global
And local only works inside a function:
$ local x=1
g2.sh: line 10: local: can only be used in a function
Finally the masking trap, described in detail on the Error Handling page: local v=$(cmd) throws away the status of cmd, because the status you get is that of local itself. Declare first, assign on the next line:
$ cat s3.sh      (the other part)
f1() { local out=$(false); echo "f1: \$?=$? (the failure is hidden)"; }
$ bash s3.sh
f1: $?=0 (the failure is hidden)
f1 reported status 0 although false failed. Write local out; out=$(false) and the failure is visible (and set -e can see it).

Arguments: counting, defaults, arrays and callbacks

Inside a function, $1, $2, "$@", $# and shift refer to the FUNCTION's arguments, not the script's. (The script's are back after the function returns.) Check the count at the top of any function that needs them, and fail with a message that names the function:
$ cat c1.sh      (the argument checks)
need2() { (( $# == 2 )) || { echo "usage: need2 SRC DST (got $# arguments)" >&2; return 2; }; echo "ok: $1 -> $2"; }
need2 a b; need2 a; echo "status $?"
req() { local name=${1:?req: missing name}; echo "name=$name"; }
( req alice; req ) 2>&1
$ bash c1.sh
ok: a -> b
usage: need2 SRC DST (got 1 arguments)
status 2
name=alice
c1.sh: line 17: 1: req: missing name
The first style checks $# and returns 2 (the "bad usage" convention). The second, ${1:?message}, is one line and aborts with the message; note it ends the SHELL (in the lab the call was inside a subshell so the rest of the script survived) and the message carries a script name and line number rather than a function name. Use the first form for reusable library functions, and ${1:?} for short internal helpers. Give optional arguments a default with ${1:-default}.
To pass ALL the arguments through to another command, use "$@" in quotes (see the Arguments page); to pass an ARRAY, expand it with "${arr[@]}", quoted, so that elements with spaces stay whole:
$ count_args() { echo "received $# arguments"; }
$ files=("a b.txt" "c.txt"); count_args "${files[@]}"; count_args ${files[@]}; count_args "${files[*]}"
received 2 arguments
received 3 arguments
received 1 arguments
Quoted "${files[@]}" gave the two elements; unquoted ${files[@]} split "a b.txt" in two (3 arguments); "${files[*]}" glued everything into one argument. The function then receives a normal argument list, and a loop over "$@" or a plain for n; do ...; done sees each element:
$ total() { local sum=0 n; for n in "$@"; do (( sum += n )); done; echo "$# numbers, sum $sum"; }
$ nums=(10 20 "30"); total "${nums[@]}"
3 numbers, sum 60
Callbacks. A function name is just a word, so you can pass one to another function and call it with "$fn" (quoted, with its arguments). This gives you map and filter helpers with no eval:
$ map() { local fn=$1 x; shift; for x in "$@"; do "$fn" "$x"; done; }
$ square() { echo $(( $1 * $1 )); }
$ map square 2 3 4 | tr '\n' ' '
4 9 16
$ filter() { local pred=$1 x; shift; for x in "$@"; do "$pred" "$x" && printf '%s\n' "$x"; done; }
$ is_even() { (( $1 % 2 == 0 )); }
$ filter is_even 1 2 3 4 5 6 | tr '\n' ' '
2 4 6
The callback style is handy for "run this action on each item" (retry, log, dry-run wrappers; see the Production Script Template page, where run() receives a whole command). For long argument lists with options, parse them with getopts or a while/case loop, and make OPTIND local (see the Arguments page).

Returning results: four channels

A function returns information in four ways. Pick ONE per function and document it.
1. Status (the exit code, 0 to 255): for yes/no answers and success or failure. Read it with $? or use the function directly in if. Only the low eight bits count, and the argument must be a number:
$ cat r1.sh
check() { [[ -r $1 ]]; }
check /etc/hostname && echo "status 0 -> true"; check /nonexistent || echo "status $? -> false"
big() { return 300; }; big; echo "return 300 gives status $?"
neg() { return -1; }; neg; echo "return -1 gives status $?"
str() { return abc; }; str; echo "return abc: status $?"
$ bash r1.sh
status 0 -> true
status 1 -> false
return 300 gives status 44
return -1 gives status 255
r1.sh: line 7: return: abc: numeric argument required
return abc: status 2
300 wrapped around to 44 (300 mod 256), -1 became 255, and a non-number is an error with status 2. So never use the status to carry a value larger than 255 (a count, a size, a port); that is what the other channels are for.
2. Standard output, captured with $( ): for the actual data. This is the most common way and has three traps, all reproduced:
$ cat r2.sh      (shortened)
get_port() { echo "looking up port..."; echo 8080; }
port=$(get_port); echo "port=[$port]"
get_port2() { echo "looking up port..." >&2; echo 8080; }
port=$(get_port2 2>/dev/null); echo "port=[$port] (log went to stderr)"
nl() { printf 'line\n\n\n'; }
x=$(nl); echo "captured ${#x} characters (the newlines are gone)"
oops() { echo "partial"; exit 5; }
v=$(oops); echo "after v=\$(oops): script still running, \$?=$?, v=[$v]"
both() { echo "value"; return 2; }
val=$(both); st=$?; echo "val=[$val] status=$st"
$ bash r2.sh
port=[looking up port...
8080]
port=[8080] (log went to stderr)
captured 4 characters (the newlines are gone)
after v=$(oops): script still running, $?=5, v=[partial]
val=[value] status=2
First, EVERYTHING the function writes to stdout becomes the result: the progress message "looking up port..." ended up inside the variable. A function that returns data through stdout must send all logging and messages to stderr (>&2), as the second version does. Second, $( ) strips trailing newlines, so a result is never exactly what was printed (4 characters "line", not "line" plus three newlines). Third, exit inside a function called through $( ) only ends the command-substitution subshell: the script went on, with $? holding the exit code 5 and the variable holding the partial output. When you need both a value and a status, capture the value and read $? on the NEXT command, as in val=$(both); st=$?.
3. A global variable: the function sets a variable with an agreed name and the caller reads it. It has no fork and no output problems, at the cost of an invisible link between caller and function (a name that must be unique and documented). Convention: an upper-case name starting with the function name, or REPLY (what read uses):
$ parse() { RESULT_HOST=${1%%:*}; RESULT_PORT=${1##*:}; }
$ parse db.example.test:5432; echo "host=$RESULT_HOST port=$RESULT_PORT"
host=db.example.test port=5432
4. A variable named by the caller (bash 4.3 and newer): the caller passes the NAME of a variable and the function writes into it, either with a nameref (local -n) or with printf -v. It has the clarity of a return value without the fork:
$ join_path() { local -n _out=$1; _out="$2/$3"; }
$ join_path p /var www; echo "nameref: $p"
nameref: /var/www
$ fmt() { printf -v "$1" '%05d' "$2"; }
$ fmt padded 42; echo "printf -v: $padded"
printf -v: 00042
local -n _out=$1 makes _out another name for the caller's variable, so assigning to _out sets the caller's p. printf -v NAME FORMAT ARGS stores the formatted text in the variable NAME instead of printing it. Use an unusual name for the nameref (a leading underscore) so it cannot collide with the caller's variable; the next paragraph shows what happens when it does.
Arrays. A function cannot "return an array" through stdout in a safe way (it would be one string). Use a nameref to fill the caller's array, or print one element per line (or NUL-separated) and read it with mapfile:
$ cat r4.sh      (shortened)
words() { local -n _arr=$1; _arr=(alpha "two words" gamma); }
words list; printf '%d items; second=[%s]\n' "${#list[@]}" "${list[1]}"
words _arr 2>&1 | head -2
lines() { printf '%s\n' "alpha" "two words" "gamma"; }
mapfile -t got < <(lines); echo "mapfile got ${#got[@]} items; second=[${got[1]}]"
names() { printf '%s\0' "a b" $'c\nd' "e"; }
mapfile -d '' -t nm < <(names); echo "${#nm[@]} items; second has a newline: $([[ ${nm[1]} == *$'\n'* ]] && echo yes)"
fill() { local -n _m=$1; _m[host]=web1; _m[port]=22; }
declare -A cfg; fill cfg; echo "host=${cfg[host]} port=${cfg[port]}"
$ bash r4.sh
3 items; second=[two words]
r4.sh: line 3: local: warning: _arr: circular name reference
r4.sh: line 3: warning: _arr: circular name reference
mapfile got 3 items; second=[two words]
3 items; second has a newline: yes
host=web1 port=22
The nameref filled the array list (3 items, with the element containing a space intact); mapfile -t reads one element per line; mapfile -d '' reads NUL-terminated items, which survive even a newline inside a value; and a nameref can fill an associative array too. The two "circular name reference" warnings were the collision: the caller passed the name _arr, which is also the name of the function's own nameref, so the nameref pointed at itself. Choose nameref names that callers will never use.

What each channel costs: 20,000 calls timed

Command substitution starts a subshell (a fork) for every call. That is invisible in a script that calls a function a few times and enormous in a loop. The same trivial function (add two numbers), called 20,000 times through each channel, timed with EPOCHREALTIME:
$ bash p1.sh
 20256 ms  r=$(add_stdout ...)     command substitution (forks a subshell)
   163 ms  add_global ...; use $RES a global variable
   230 ms  add_nameref ... r        local -n nameref
   214 ms  add_printfv ... r        printf -v
    72 ms  r=$(( i + 1 ))           no function at all
Twenty seconds against a fifth of a second: the stdout channel was roughly a hundred times slower (88 to 124 times, depending on the channel it is compared with), about one millisecond per call on this machine. The three no-fork channels cost about the same as each other, and a function call itself is cheap in bash: 72 ms for the plain arithmetic loop against 163 to 230 ms with a call, which is only a few microseconds per call (about 4.5 for the global-variable version). Guidelines: use $( ) where the function is called a handful of times, and a global, a nameref or printf -v in loops that run thousands of times (parsing every line of a big file, building strings in a tight loop). If you find a slow script that calls small functions in a loop, this is the first thing to check.

The status of a function is the status of its last command

A function returns the status of the last command it ran, unless you write return. That makes some innocent-looking functions fail, and others hide failures:
$ cat g1.sh
announce() { [[ -n ${1:-} ]] && echo "has argument: $1"; }
announce x; echo "with argument: status $?"
announce;   echo "without argument: status $? (the failed [[ ]] was the last command)"
announce2() { [[ -n ${1:-} ]] && echo "has argument: $1"; return 0; }
announce2; echo "with explicit return 0: status $?"
cleanup() { rm -f /nonexistent_file_zz 2>/dev/null; echo "cleanup done"; }
cleanup; echo "status $? (the echo succeeded, the earlier failure is invisible)"
echo "--- under set -e a function ending in a false test kills the script"
( set -e; announce; echo "this line is not reached" ); echo "subshell exit: $?"
$ bash g1.sh
has argument: x
with argument: status 0
without argument: status 1 (the failed [[ ]] was the last command)
with explicit return 0: status 0
cleanup done
status 0 (the echo succeeded, the earlier failure is invisible)
--- under set -e a function ending in a false test kills the script
subshell exit: 1
announce with no argument is not a failure in the programmer's mind (there is simply nothing to announce), yet it returned 1, because the last command was a [[ ]] that was false, and under set -e that ended the script. cleanup is the opposite: rm failed, but the final echo succeeded, so the caller saw 0. The fix is to say what you mean: end with an explicit return 0 (or return 1), or restructure with if. A good habit: end every function that is not a predicate with an explicit return, or make sure its last command is the one whose status you want.
Predicates. A function used as a condition (if is_port "$x"; then) should be a PREDICATE: it prints nothing and returns 0 (true) or non-zero (false). Name it with is_, has_ or can_, and let the last command be the test:
$ cat g3.sh      (shortened)
is_port() { [[ $1 =~ ^[0-9]+$ ]] && (( $1 >= 1 && $1 <= 65535 )); }
for p in 22 70000 abc ""; do is_port "$p" && echo "[$p] valid port" || echo "[$p] not a port"; done
bad_is_port() { echo "checking $1"; [[ $1 =~ ^[0-9]+$ ]]; }
out=$(bad_is_port 22); echo "captured text instead of a clean status: [$out]"
$ bash g3.sh
[22] valid port
[70000] not a port
[abc] not a port
[] not a port
captured text instead of a clean status: [checking 22]
The predicate handled a valid port, a number out of range, text and an empty argument. The second version printed a message, which pollutes any output captured around it. Predicates must be silent. Remember also that set -e is switched off for the WHOLE body of a function while it runs as the condition of if, ||, && or !: see the Error Handling page.

Errors, call stacks and RETURN traps

return ends the function (with a status); exit ends the whole shell (or the subshell that runs the function). A reusable library function should return, and leave the decision to exit to the caller; only top-level script code and die helpers call exit.
When something fails deep in a call chain, "line 14" is not enough: you want to know which function called which. Bash keeps the stack in three arrays, FUNCNAME (function names, innermost first, ending in "main" for the top level), BASH_LINENO (the line each call was made from) and BASH_SOURCE (the file), and the caller builtin prints one frame at a time (line, function and file):
$ cat t1.sh
trace() {
  local i=0 frame
  echo "stack (innermost first):"
  while frame=$(caller $i); do echo "  #$i $frame"; ((i++)); done
}
c() { trace; }
b() { c; }
a() { b; }
a
echo "--- FUNCNAME array from inside c2:"
c2() { echo "FUNCNAME=(${FUNCNAME[*]})  BASH_LINENO=(${BASH_LINENO[*]})"; }
b2() { c2; }; a2() { b2; }; a2
die() { echo "error in ${FUNCNAME[1]}() called from line ${BASH_LINENO[0]}: $*" >&2; exit 1; }
validate() { [[ $1 =~ ^[0-9]+$ ]] || die "not a number: $1"; }
validate 12; validate abc
$ bash t1.sh
stack (innermost first):
  #0 8 c t1.sh
  #1 9 b t1.sh
  #2 10 a t1.sh
  #3 11 main t1.sh
--- FUNCNAME array from inside c2:
FUNCNAME=(c2 b2 a2 main)  BASH_LINENO=(14 14 14 0)
error in validate() called from line 16: not a number: abc
Each caller frame shows "LINE FUNCTION FILE": c was called from line 8, b from line 9, a from line 10, and main (the top-level script) from line 11. Inside die, ${FUNCNAME[1]} is the function that called die (validate) and ${BASH_LINENO[0]} the line where die was called, so the message names both. Put this in your die() and in an ERR trap (see the Error Handling page, which explains why set -E is needed for the ERR trap to fire inside functions): when a script fails at 03:00, "error in validate() called from line 16" is a result you can act on.
trap ... RETURN runs a command when the function returns, whatever the status: the right place to remove a temporary file. It is the function-level cousin of the EXIT trap:
$ cat t3.sh
work() {
  local tmp; tmp=$(mktemp)
  trap 'rm -f -- "$tmp"; echo "RETURN trap: removed temp file"' RETURN
  echo "working with $(basename "$tmp" | sed 's/tmp\..*/tmp.XXXX/')"
  [[ $1 == fail ]] && return 3
  return 0
}
work ok; echo "status $?"; work fail; echo "status $?"
$ bash t3.sh
working with tmp.XXXX
RETURN trap: removed temp file
status 0
working with tmp.XXXX
RETURN trap: removed temp file
status 3
The temp file was removed on both the success path and the early return 3. Two details, tested: by default the RETURN trap is NOT inherited by the functions that work calls; with set -T (functrace) it is, and then it fires for every nested return too:
$ bash t4.sh      (work calls helper)
--- default (functrace off)
work: calling helper
  helper running
work: helper done
  RETURN trap fired (in work or a callee?)
trap after return: [trap -- 'echo "  RETURN trap fired (in work or a callee?)"' RETURN]
--- with set -T (functrace on): the trap is inherited by the callee
work: calling helper
  helper running
  RETURN trap fired (in work or a callee?)
work: helper done
  RETURN trap fired (in work or a callee?)
Without set -T the trap fired once, when work itself returned; with it, it fired for helper as well. Note the last line of the first part: after the function returns, the trap definition is STILL SET (trap -p shows it), so clear it with trap - RETURN if the shell continues to run other code, and remember the variable it refers to ($tmp above) is local and gone.

Recursion, and the nameref trap

Bash functions can call themselves. The two things to know: each level through $( ) costs a fork, and a nameref resolves by NAME at the moment it is used. The classic recursive example, a factorial, written three ways:
$ cat n1.sh
# the buggy version: nameref + recursion
fact_bad() { local -n _r=$2; local n=$1 sub; (( n <= 1 )) && { _r=1; return; }; fact_bad $((n-1)) sub; _r=$(( n * sub )); }
fact_bad 5 res; echo "buggy nameref recursion: fact 5 = [$res] (should be 120)"
# fix 1: a global result variable, read right after the call
fact_global() { local n=$1; if (( n <= 1 )); then FACT=1; return; fi; fact_global $((n-1)); FACT=$(( n * FACT )); }
fact_global 10; echo "global result:    fact 10 = $FACT"
# fix 2: pass a name that cannot collide (the depth makes it unique)
fact_unique() { local n=$1 depth=$3; local -n _out=$2; local inner_$depth; if (( n <= 1 )); then _out=1; return; fi; fact_unique $((n-1)) "inner_$depth" $((depth+1)); local -n _in="inner_$depth"; _out=$(( n * _in )); }
fact_unique 10 result 0; echo "unique names:     fact 10 = $result"
$ bash n1.sh
buggy nameref recursion: fact 5 = [0] (should be 120)
global result:    fact 10 = 3628800
unique names:     fact 10 = 3628800
The buggy version printed 0 instead of 120, with no error. Why: the function passes the NAME "sub" to the recursive call. Inside that call, "sub" resolves in the callee's scope, where the callee has its own local called sub, so the nameref wrote into the callee's variable instead of the caller's, and the caller's sub stayed empty (0 in arithmetic). This is dynamic scope again, and it bites every nameref used in a recursive function. Fixes: use a global result variable (simplest, and fastest), generate a unique variable name per level (as fact_unique does with the depth), or call through $( ) and accept the fork. The speed difference is the same lesson as before:
$ ( 20 calls of fact 15 )
20 x fact 15, global result: 7 ms
20 x fact 15, command substitution: 318 ms
The global-result version was over forty times faster than the one that forked at every level.
Depth. Bash has no recursion limit by default (a runaway recursion eats memory and stack until it crashes). Set FUNCNEST to make it fail cleanly:
$ down() { local n=$1; (( n == 0 )) && return; down $((n-1)); }
$ FUNCNEST=50 down 10 && echo "depth 10 fine with FUNCNEST=50"
depth 10 fine with FUNCNEST=50
$ FUNCNEST=50 down 100; echo "depth 100 status: $?"
t2.sh: line 9: down: maximum function nesting level exceeded (50)
Set a limit when you write recursive functions over data you do not control (directory trees, user input). Often a loop with an explicit stack (an array) is the better tool in shell.

Names: namespaces, shadowing and aliases

Shell has one flat namespace for functions: a function named log or die in your script collides with one of the same name in any library you source, last definition wins. Bash allows :: in function names, which gives you a convention for prefixes (not true namespaces, and not POSIX):
$ cat lib_strings.sh      (a small library)
# lib_strings.sh - a small library: source it, do not run it
[[ ${BASH_SOURCE[0]} != "$0" ]] || { echo "lib_strings.sh is a library: source it, do not run it" >&2; exit 2; }
[[ -n ${_LIB_STRINGS_LOADED:-} ]] && return 0        # load once
readonly _LIB_STRINGS_LOADED=1
str::upper() { printf '%s' "${1^^}"; }
str::trim()  { local s=$1; s=${s#"${s%%[![:space:]]*}"}; s=${s%"${s##*[![:space:]]}"}; printf '%s' "$s"; }
str::repeat() { local out; printf -v out '%*s' "$2" ''; printf '%s' "${out// /$1}"; }
$ bash use_lib.sh
upper: HELLO   trim: [padded]   repeat: ababab
functions from the library: 3
Prefix every library function with the library name (str::upper, log::info, net::is_port) and keep private helpers behind an extra underscore (_str_helper). It also makes declare -F | grep 'str::' list exactly the library's functions, as the last line shows.
A function takes precedence over the program or builtin with the same name. That is useful for wrappers and dangerous by accident. A wrapper that adds an option, using command (or builtin) to reach the real one so it does not call itself:
$ cat o1.sh      (shortened)
ls() { echo "my ls wrapper"; command ls "$@"; }
cd /etc; ls -d ssh hostname 2>&1 | head -3
type -t ls; type -t cat; type -t cd; type -t if
unset -f ls; type -t ls
cd() { echo "wrapped cd to $1"; builtin cd "$@"; }
cd /tmp; pwd
$ bash o1.sh
my ls wrapper
hostname
ssh
function
file
builtin
keyword
file
wrapped cd to /tmp
/tmp
type -t says what a name is: function (our wrapper), file (an external program), builtin, keyword. After unset -f ls the name is the program again. The wrapper for cd calls builtin cd; without builtin, the function would call itself forever. The accident: a function called test silently replaces the builtin test in every if test -f ...:
$ cat g2.sh      (shortened)
test() { echo "my test function"; return 1; }
if test -f /etc/hostname; then echo "file exists"; else echo "else branch: the FUNCTION ran, not the builtin"; fi
[ -f /etc/hostname ] && echo "[ ] still works (a different name)"
unset -f test
if test -f /etc/hostname; then echo "builtin test works again"; fi
$ bash g2.sh
my test function
else branch: the FUNCTION ran, not the builtin
[ ] still works (a different name)
builtin test works again
The function ran instead of the builtin and "failed" a perfectly good file test, while [ ] (a different name) kept working, which makes the bug hard to find. Names to avoid for your own functions: test, read, echo, cd, type, source, help, time, kill, wait and the names of common programs. A quick check: type -t NAME before you choose it (a name that prints nothing is free; the lab found log=none, source=builtin, help=builtin).
Aliases are not functions. They are text substitutions done by the interactive shell, and they are NOT expanded in scripts unless you ask:
$ alias hi='echo alias-says-hi'
$ hi
o1.sh: line 12: hi: command not found
$ shopt -s expand_aliases; eval 'hi'
alias-says-hi
Even with expand_aliases an alias must be defined before the line that uses it is READ (hence the eval above). In scripts, always use functions. To look at what a function does: declare -f NAME prints its definition, declare -F lists the names, and type NAME shows both for any command:
$ add() { echo $(( $1 + $2 )); }
$ declare -f add
add ()
{
    echo $(( $1 + $2 ))
}

Libraries: sourcing, source guards and load-once

A library is a file of functions that you load with source (or . in POSIX) and that does nothing else when loaded. The pattern in the example above has four parts worth copying:
  1. A guard that refuses to RUN the file by mistake: [[ ${BASH_SOURCE[0]} != "$0" ]] || { echo "this is a library: source it" >&2; exit 2; }. When the file is sourced, BASH_SOURCE[0] (the file) differs from $0 (the script that sourced it); when it is run directly they are equal:
$ bash lib_strings.sh
lib_strings.sh is a library: source it, do not run it
[exit 2]
  2. A load-once guard, so that sourcing it twice (from two other libraries) does nothing: [[ -n ${_LIB_STRINGS_LOADED:-} ]] && return 0, then readonly _LIB_STRINGS_LOADED=1. (return is allowed at the top level of a sourced file.) The test script sourced the library twice and got a single set of functions.
  3. Only definitions at the top level: no output, no cd, no set -e, no traps, no exit at source time. A library that changes the caller's shell options is a library that breaks scripts later.
  4. A reliable path. Find the library relative to the SCRIPT, not to the current directory, which is whatever the user happened to be in: here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd); source "$here/lib_strings.sh".
$ cat use_lib.sh
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source "$here/lib_strings.sh"
source "$here/lib_strings.sh"                  # second source is a no-op
echo "upper: $(str::upper 'hello')   trim: [$(str::trim '   padded   ')]   repeat: $(str::repeat ab 3)"
declare -F | grep -c 'str::' | sed 's/^/functions from the library: /'
Libraries need documentation just as much as scripts: a comment before each function with its arguments, its output channel and its status. Install shared ones in one place (/usr/local/lib/yourproject/) and give them a version if other people depend on them. The same idea, the main guard for the SCRIPT itself, lets a test file source a script and call its functions: see the Production Script Template page.

Passing functions to other programs: export -f

A function exists only in the shell that defined it. A child process (xargs, find -exec, GNU parallel, ssh, another script) does not have it, even if that child is bash:
$ cat e1.sh
greet() { echo "hello, $1"; }
export -f greet
printf '%s\n' ana ben | xargs -I{} bash -c 'greet {}'
$ bash e1.sh      (clean version of the example)
hello, ana
hello, ben
$ greet2() { echo hi; }
$ printf '%s\n' ana | xargs -I{} bash -c 'greet2 {}' 2>&1 | head -2
bash: line 1: greet2: command not found
export -f greet put the function in the environment, so each bash that xargs started could call it; without export -f the child printed "command not found". Bash passes it as an environment variable whose name starts with BASH_FUNC_ and ends with %%:
$ f3() { echo one; }; export -f f3
$ env | grep -c '^BASH_FUNC_f3%%='
1
(This name is the result of a 2014 security fix, the "Shellshock" bug, which changed how bash stores exported functions.) Limits: it works only for bash children (sh and dash ignore it), it does not survive ssh unless the remote shell is bash and accepts the variable (usually it does not), and an environment full of exported functions makes every child process start slower. For parallel work the dependable pattern is: put the function in a library file, and have the child source it (bash -c 'source lib.sh; greet "$1"' _ ana), or put the code in a script of its own.

Writing and documenting a good function

Everything above condenses into a short style. A skeleton to copy:
  # str::pad_left WIDTH TEXT  -> stdout: TEXT padded with spaces to WIDTH
  # status: 0 ok; 2 bad arguments
  # logs go to stderr; no globals are read or written
  str::pad_left() {
    (( $# == 2 )) || { echo "str::pad_left: need WIDTH and TEXT" >&2; return 2; }
    local width=$1 text=$2
    printf '%*s' "$width" "$text"
    return 0
  }
and the rules it follows:
  Name it with a verb (parse_args, read_config, copy_files) or a predicate prefix (is_, has_), prefixed with the library name if it is shared
  One job, one return channel: a status for questions, stdout for data (all noise to stderr), a named variable for hot loops; say which in a comment above the function
  First lines: check $#, then local for EVERY variable (including loop counters), then work
  Read no variables that were not passed in, except documented constants; write no globals except documented ones (declare -g)
  End with an explicit return unless the last command's status is exactly what you want to return
  Do not call exit from a reusable function; return, or call a die helper that the top-level script owns
  Keep it short enough to read at once (about 30 lines); if it grows, split it
  Quote every expansion, as everywhere ("$1", "$@", "${arr[@]}")
  Do not define a function inside another function in routine code: it is created when the outer function runs and stays global
Run ShellCheck on the file: it flags unused arguments, missing local-ish declarations in the style SC2155 (the local v=$(cmd) mask), unquoted expansions and functions that are never called (ShellCheck was not installed on the lab machine, so no output is shown).

Tracing and testing functions

set -x shows each command as the shell runs it. Make the trace say WHERE it is by putting the file, line and function in PS4 (the prompt used for trace lines; each extra + is one more level of command substitution):
$ cat d3.sh
PS4='+ ${BASH_SOURCE##*/}:${LINENO} ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
inner() { local r=$(( $1 * 2 )); echo "$r"; }
outer() { local v; v=$(inner "$1"); echo "outer got $v"; }
set -x
outer 21
set +x
$ bash d3.sh
+ d3.sh:6 outer 21
+ d3.sh:4 outer(): local v
++ d3.sh:4 outer(): inner 21
++ d3.sh:3 inner(): local r=42
++ d3.sh:3 inner(): echo 42
+ d3.sh:4 outer(): v=42
+ d3.sh:4 outer(): echo 'outer got 42'
outer got 42
+ d3.sh:7 set +x
You can read the call chain: line 6 called outer, which on line 4 called inner inside a command substitution (the double ++), which computed 42. To trace just one function, use local - and set -x inside it (shown in the scope section).
To test functions, source the file and call them from a test script (the main guard on the Production Script Template page makes this possible, and that page shows a 15-line test harness with named assertions). Test the four things that go wrong: normal input, empty input, an input with spaces and quotes, and the failure path (what does it return, and does it print to the right stream). Run the function in a subshell ( f args ) when it may call exit.

Bash-only features (portability)

Everything on this page works in bash 5 (and most of it from bash 4.3). If the script must also run under sh or dash, know which parts are NOT portable (see the POSIX sh vs bash page for tested dash and BusyBox results):
  function name { }             use name() { } instead
  local                         not in POSIX, but dash, BusyBox and bash all have it
  local -n, declare -n          namerefs: bash 4.3+ only; use a global result variable, or eval with care
  printf -v                     bash only; in sh capture with $( ) or use a global
  mapfile, readarray            bash 4+; use while IFS= read -r
  name::space function names    bash only
  export -f                     bash only
  local - (restore options)     bash 4.4+ only
  FUNCNAME, BASH_LINENO, caller bash only
  trap ... RETURN, FUNCNEST     bash only
A portable function sticks to name() { }, local, positional arguments, stdout and status.

Common problems

A variable changed "by itself" after a function call           the function used it without local: declare every variable local; check with a search for assignments outside local
A function sees (or changes) a variable of its caller         dynamic scope: pass values as arguments, use unusual local names, never assign to names you did not declare
The captured result contains messages as well                  logging went to stdout: send it to stderr (>&2) and keep stdout for the result
The captured value lost its trailing newline                   $( ) strips trailing newlines: add a sentinel (x=$(cmd; echo x); x=${x%x}) or return through a nameref
exit inside a function did not stop the script                 the function ran inside $( ) or a pipeline (a subshell): return a status and let the caller exit
return 300 gave 44                                             statuses are 0 to 255: return a value through stdout, a global or a nameref
A function returns 1 but "nothing went wrong"                  its last command was a false test: end with return 0 (and remember set -e)
set -e did not catch a failure inside my function              the function was called in if, ||, && or !: check each step inside it yourself
Loop with $(f) is extremely slow                               one fork per call: use a global, a nameref or printf -v in hot loops
circular name reference warning                                the nameref has the same name as the variable the caller passed: use a leading underscore for nameref names
A recursive function returns 0 or garbage via nameref          the callee's local shadows the caller's variable of the same name: use a global result or unique names
command not found for a function I defined                     it is defined later in the file than the line that calls it, or the call is in a child process: define first, or export -f
My function replaced a command (if test ... broke)             the name collides with a builtin: type -t NAME before choosing; unset -f NAME to remove
An alias does not work in my script                            aliases are not expanded in scripts: use a function
Functions in a library are defined twice or the library runs   add the source guards: refuse direct execution and load once
xargs -P / find -exec cannot see my function                  child processes need export -f (bash only) or a script file that sources a library
maximum function nesting level exceeded                       FUNCNEST was reached: a recursion without an end, or a limit that is too low

Tips

Start every function with a $# check and local for every variable. Dynamic scope means a missing local is a bug in somebody else's code.
Pick one return channel per function: a status for questions, stdout for data, a named variable for speed. Send every message to stderr.
Never put local and a command substitution on one line (local v=$(cmd) hides the status); never let a predicate print.
End functions with an explicit return, and do not call exit from anything reusable.
Use $( ) freely when a function is called a few times; in a loop of thousands, use a global, a nameref or printf -v (about a hundred times faster on the lab machine).
Take care with namerefs: choose unusual names, and never use one across a recursive call without unique names.
Prefix library functions (lib::name), guard libraries against direct execution and double loading, and load them relative to the script's own path.
Check type -t NAME before naming a function after a command; use command and builtin inside wrappers.
Put the file, line and function in PS4 and use caller or FUNCNAME in die(), so a failure at 03:00 names the function that failed.
Test functions by sourcing the file, with the four cases: normal, empty, spaces and quotes, and failure.