Why this matters
Functions are only useful if you can reason about them on their own: what goes in, what comes out, what else they touch. In bash the defaults work against you. Every variable is global unless you say otherwise, and there is no built-in way to return a string. This lesson covers the habits that make functions safe to reuse: local for everything, output for results, and status for success or failure.
Variables are global by default
#!/bin/bash
count_files() {
n=0
for f in "$1"/*; do (( n += 1 )); done
echo "$n"
}
n=100
count_files /etc > /dev/null
echo "n is now $n"
$ bash globals.sh
n is now 71
The function quietly overwrote the caller's n. In a 30-line script you may get away with it; in a 300-line script, two functions that both use i or f or tmp will eventually trample each other, and the bug shows up far from its cause.
local
local declares a variable that exists only for the duration of the function call:
count_files() {
local n=0 f
for f in "$1"/*; do (( n += 1 )); done
echo "$n"
}
Now the caller's n and f are untouched. When the function is called, bash creates the local variables in the function's call frame, hiding any variables of the same name outside; when it returns, the frame is discarded and the outer ones are visible again. local only works inside a function; at the top level you get local: can only be used in a function.
Habit: the first line of a function declares all its variables with local, including loop variables. local accepts several names and assignments at once.
Dynamic scope
bash differs from most languages in how it finds a variable. It looks in the current function's locals, then in the locals of the function that called it, then that one's caller, and so on, and finally the globals. This is dynamic scope: it depends on the call chain at run time.
level() { echo "level sees: $mode"; }
debug_run() { local mode=debug; level; }
mode=normal
debug_run # level sees: debug
level # level sees: normal
call stack while level runs (via debug_run) lookup of $mode
┌──────────────┐
│ level │ no local mode ──▶ not here
├──────────────┤
│ debug_run │ local mode=debug ──▶ found: debug
├──────────────┤
│ (script) │ global mode=normal
└──────────────┘
Two consequences: a function can see (and assign to!) its caller's locals, and an assignment to a name that is not local in the current function changes whichever variable is found first. Again, local for everything keeps this from biting.
Returning a value: print it
The bash way to return data is to write it to standard output and have the caller capture it with command substitution:
to_upper() {
local s=$1
printf '%s\n' "${s^^}"
}
name=$(to_upper "ada lovelace")
echo "$name" # ADA LOVELACE
${s^^} uppercases a string (${s,,} lowercases); more such operators in module 15. Use printf '%s\n' rather than echo for arbitrary data, since echo misbehaves on values like -n.
This style composes nicely: such functions work in pipelines and inside other substitutions, just like commands. Keep the rule clean, though: stdout is for the result; messages go to stderr. If a value-returning function also prints "working..." on stdout, the caller captures that as part of the value.
The cost: command substitution forks
$(to_upper x) runs the function in a subshell, a forked copy of the shell. That has two effects:
- Anything the function changes, globals included, is lost when the subshell exits. Here is a function that tries to count its calls: Each call ran in a fresh copy where
callsstarted at 0.calls=0 next_id() { (( calls += 1 )); echo "id-$calls"; } a=$(next_id); b=$(next_id) echo "$a $b calls=$calls" # id-1 id-1 calls=0 - A fork per call is slow in a loop over thousands of items. Usually it does not matter.
When you need a function to change state, or speed matters, assign to a variable whose name the caller chooses. bash 4.3+ has namerefs for this: local -n makes a local name an alias for another variable.
split_name() { # usage: split_name "First Last" first_var last_var
local -n _first=$2 _last=$3
_first=${1%% *}
_last=${1#* }
}
split_name "Grace Hopper" fn ln
echo "$ln, $fn" # Hopper, Grace
No subshell is involved; the assignments go straight into fn and ln. Use odd local names (here with _) so they do not collide with the caller's variable names.
Status and value together
A function can report both: print the value, and return a status saying whether it worked. The caller checks the status of the assignment:
lookup_uid() {
local line
line=$(grep "^$1:" /etc/passwd) || return 1
printf '%s\n' "$line" | cut -d: -f3
}
if uid=$(lookup_uid learner); then
echo "uid $uid"
else
echo "no such user" >&2
fi
An assignment var=$(cmd) has the status of cmd, so this works. But not when the assignment is part of local, export or declare:
f() {
local line=$(grep "^nobody123:" /etc/passwd) # status of `local`: always 0
echo "status: $?" # prints 0 even though grep failed
}
Declare first, assign on the next line: local line then line=$(...). ShellCheck, the shell linter, flags this as warning SC2155.
Recursion
A function may call itself. Each call gets its own call frame, so locals are per call:
countdown() {
local n=$1
(( n == 0 )) && { echo "liftoff"; return; }
echo "$n"
countdown $(( n - 1 ))
echo "back in call with n=$n"
}
countdown 2
2
1
liftoff
back in call with n=1
back in call with n=2
Without local, every call would share one global n, and after the inner calls return it would hold 0, so the "back in" lines would print n=0. bash has no tail-call optimization and each call uses memory, so deep recursion (tens of thousands of levels) can crash the shell; for those, use a loop.
Common mistakes
- No
local. Functions clobber each other's and the caller's variables. local x=$(cmd)and then checking$?. Always 0. Split declaration and assignment.- Printing messages on stdout in a value function. The caller captures them as part of the result. Send them to stderr with
>&2. - Expecting changes made inside
$(f)to persist. The function ran in a subshell. - Using
returnfor data. Status is a number from 0 to 255; print the data instead. localoutside a function.bash: local: can only be used in a function.
Exercises
dynamic and masked cover the two surprises in bash scoping. slug is a classic text function returning its result on stdout. fact is recursive; a test sources your script and checks that calling fact leaves the caller's variables alone.
Recap
- Variables are global unless declared
local; declare every function variablelocal. - bash scope is dynamic: lookups follow the call chain at run time.
- Return data by printing it; the caller captures it with
$(f). Return success with the status. $(f)runsfin a subshell: its variable changes are lost. Namerefs (local -n) let a function assign to a caller's variable without a subshell.local v=$(cmd)hides cmd's status; declare, then assign.- Recursion works because each call has its own locals.
Going further: help local and help declare (look at -n, -i, -r, -g). Try declare -g inside a function to create a global deliberately, and FUNCNAME (an array: try echo "${FUNCNAME[@]}" inside nested calls) to see bash's call stack.