Functions & Locals
`local`, return values vs exit codes, scope rules — and why bash functions don't return what you think they return.
Functions & Locals
Bash has functions, but they don't work like functions in any other language. They take arguments by position, return only an exit status, leak variables to the caller by default, and have no first-class function values. Once you internalise these constraints, they're useful. Until then, they cause subtle bugs.
Defining a function
Two equivalent forms — prefer the modern one:
greet() { # ✅ modern, POSIX-portable
echo "hi, $1"
}
function greet { # old keyword form, no parens needed
echo "hi, $1"
}
Don't write function greet() { — it's neither POSIX nor canonical bash. Pick one style.
Arguments are positional
Inside a function, args are $1, $2, …, just like a script:
greet() {
local name=$1
local title=${2:-friend} # default if 2nd arg missing
echo "hi, $title $name"
}
greet "Sam" # → "hi, friend Sam"
greet "Sam" "Mr." # → "hi, Mr. Sam"
The script's positional args are shadowed inside the function. To access the script's args from inside, you must pass them in (or save them first into named globals).
echo "$@" # at script level: script args
my_func "$@" # forward script args to my_func
my_func() {
echo "$@" # NOW $@ is my_func's args
}
local — the rule that prevents most bugs
By default, every variable assignment in a bash function is global:
counter() {
i=0 # ❌ leaks! caller's `i` is now 0
while (( i < 5 )); do
(( i++ ))
done
}
i=42
counter
echo "$i" # 5 — surprise; expected 42
The fix is local:
counter() {
local i=0
while (( i < 5 )); do
(( i++ ))
done
}
local is valid only inside functions. It scopes the variable to the function's lifetime — including any nested functions called from within.
Make local the first thing every function does. Set up your locals at the top, then write logic. Treat unlocaled assignments inside a function as a bug.
Returning values
return N sets the function's exit status ($? for the caller). It's an integer 0–255.
is_even() {
(( $1 % 2 == 0 )) # (( )) returns 0 if expression is true
return $?
}
if is_even 4; then echo "yes"; fi
For string or numeric values, the convention is to echo to stdout and have the caller capture:
basename_no_ext() {
local f=${1##*/}
printf '%s\n' "${f%.*}"
}
name=$(basename_no_ext "/var/log/system.log")
# name=="system"
Two important caveats:
- Capturing with
$()runs the function in a subshell. Local variable changes don't persist; trap handlers register only inside the subshell;cddoesn't propagate. The function is essentially a closed black box from the caller's perspective. returnmaskslocal's exit code.local x=$(may_fail)— the local builtin's success masksmay_fail's exit. Declare and assign on separate lines if you care about the inner exit:
local x
x=$(may_fail) || die "may_fail failed"
ShellCheck flags this as SC2155.
Dynamic scope (the bash quirk)
Bash uses dynamic scope, not lexical:
inner() {
echo "from inner: $x" # sees outer's local x
}
outer() {
local x="outer-x"
inner
}
outer # prints: from inner: outer-x
If you want a function to be self-contained, declare all its variables local. Otherwise an outer caller's variables will leak through.
This is the opposite of every modern language. Treat it as a footgun.
Exit codes from inside functions
The function's exit code is the exit code of its last command, unless return N is used:
my_func() {
echo "hello"
false # last command — exits 1
} # function returns 1
my_func; echo "$?" # 1
This catches a lot of "but my function works" surprises. Be explicit:
my_func() {
echo "hello"
false
return 0 # explicit success
}
…or restructure so the meaningful command is last.
declare/typeset — types for locals
local is the function-scoped form of declare (also spelled typeset). The -i flag declares an integer; -r makes it readonly; -A makes it associative; -a makes it indexed.
my_func() {
local -i count=0 # integer
local -r MAX=100 # readonly
local -a items=() # indexed array
local -A seen=() # associative
}
local -i count makes count++ work without (( )) because subsequent assignments are evaluated as arithmetic. Convenient but rarely used; most bash uses (( )) explicitly.
Listing and inspecting functions
declare -F # list function NAMES only
declare -f my_func # show my_func's body
declare -f # show ALL function bodies
type my_func # tell me what kind of thing my_func is
unset -f my_func # remove the definition
Useful when sourcing libraries (. ./lib/utils.sh) and wondering what got pulled in.
export -f — exporting functions
Bash can export functions to subprocesses:
my_func() { echo "hi"; }
export -f my_func
bash -c 'my_func' # works in the child shell
This is bash-specific (POSIX sh doesn't have it). And it has a security history — the Shellshock vulnerability (CVE-2014-6271) was exactly an exploit of how bash parsed exported functions from environment variables. Avoid export -f for anything that crosses a trust boundary.
Common patterns
The die helper. Every bash script grows one:
die() {
echo "error: $*" >&2
exit 1
}
[[ -d "$dir" ]] || die "not a directory: $dir"
Boolean function via exit code.
file_is_old() {
local f=$1
local age=$(( $(date +%s) - $(stat -f %m "$f") ))
(( age > 86400 )) # last expression IS the return
}
if file_is_old "$path"; then archive "$path"; fi
Argument validation.
require_arg() {
local var=$1
[[ -n ${!var:-} ]] || die "$var is required"
}
require_arg HOME
require_arg USER
${!var} is indirect expansion — value of the variable named by $var.
Bash vs zsh
zsh is mostly compatible, with two notable differences:
localworks the same, but zsh also hasprivate(zsh 5.4+) for true private function scope where dynamic-scope leakage doesn't apply.function name { ... }— zsh requires a space before{and supports it in places bash doesn't. Stay with thename() { ... }form for portability.- zsh has anonymous functions:
() { local x=1; echo "$x"; }— runs immediately as a scoped block. Bash needs( ... )(subshell) for the same effect.
When to leave bash
If you're writing functions that:
- Return structured data (records, arrays, JSON).
- Need closures.
- Need higher-order behaviour (functions as args).
- Need real testing with mocks and fixtures.
…you're past bash's sweet spot. Python's def is one keyword, gives you all of the above, and ships with every modern OS.
Common bugs
Forgetting local. A loop variable i in a function clobbers the caller's i. Symptom: weird off-by-N bugs in the caller. Fix: local i at top of every function.
return instead of echo for values. return "$result" only works if $result is 0–255. Strings get truncated to 0 (or worse, an error). Use echo and capture with $().
local x=$(cmd) masks cmd's exit code. local always succeeds, so set -e won't see cmd failing. Split into local x; x=$(cmd) || die "...".
Dynamic scope leak. A function that uses i without local i will pick up the caller's i. Audit every function for unlocaled assignments — shellcheck does this for you.
Tools in the wild
3 tools- cliShellCheckfree tier
SC2034 (unused var), SC2155 (declare-and-assign masking exit), SC2178 (array-as-scalar). Catches local/global confusions.
- clibats-corefree tier
Test bash functions in isolation — assert outputs, exit codes, side effects. Indispensable past 100 lines of script.
- clibashunitfree tier
Modern bash test framework — TAP output, mocking, parameterised tests. Newer alternative to bats-core.