Bash functions look like functions from other languages and behave differently in three important ways: variables are global unless you say otherwise, return cannot return data, and scope is dynamic rather than lexical. All three cause real bugs.
Topic 1: Definition and Invocation
# POSIX form — portable, preferred
greet() {
echo "hello, $1"
}
# Bash keyword form — parentheses optional, not portable
function greet {
echo "hello, $1"
}
greet world # called like a command, NOT greet("world")
A function is invoked exactly like a command: name first, arguments separated by spaces, no parentheses and no commas. greet("world") is a syntax error, and greet(world) is worse — it is valid-looking and wrong.
Functions must be defined before use, because bash reads the file top to bottom. This is why the main "$@" at the bottom pattern works: every definition has been read by the time main runs.
declare -f greet # print the function's definition
declare -F # list all function NAMES
unset -f greet # remove it
type greet # is it a function, builtin, alias, or file?
type is the one to remember during debugging — it tells you whether the ls being run is the binary, an alias, or a function someone defined in a sourced library.
Topic 2: Arguments
A function’s arguments shadow the script’s while it runs:
process() {
echo "function got $# args"
echo "first: $1"
echo "all: $*"
}
process a b c # $1 inside is 'a', NOT the script's first argument
| Inside a function | Refers to |
|---|---|
$1, $2, … | The function’s arguments |
$# | How many the function received |
"$@" | All of them, boundaries preserved |
$0 | Still the script name — not the function |
${FUNCNAME[0]} | The function’s own name |
$0 not being the function name catches people writing error messages. Use ${FUNCNAME[0]}:
validate() {
[[ -n $1 ]] || { echo "${FUNCNAME[0]}: argument required" >&2; return 2; }
}
FUNCNAME is an array holding the whole call stack — ${FUNCNAME[1]} is the caller, which is how the stack traces in the error-handling lesson work.
Forwarding arguments:
wrapper() {
echo "running: $*" # $* for DISPLAY
real_command "$@" # "$@" for EXECUTION
}
That pairing is the rule from the quoting lesson, and functions are where it matters most.
Topic 3: Scope — Global by Default
This is the difference that bites hardest. Every variable is global unless declared local.
counter=10
increment() {
counter=$(( counter + 1 )) # modifies the GLOBAL
temp="scratch" # creates a GLOBAL that outlives the function
}
increment
echo "$counter" # 11
echo "$temp" # scratch — leaked out of the function
increment() {
local counter=$1 # now shadows the global
local temp="scratch"
counter=$(( counter + 1 ))
echo "$counter"
}
Declare every function variable local. The failure mode is nasty: a helper that uses i as a loop counter, called from inside a loop that also uses i, produces an infinite loop or silently skipped iterations — and nothing in the code looks wrong.
local accepts the same options as declare:
local -r CONST="fixed" # readonly
local -i count=0 # integer — arithmetic without $(( ))
local -a items=() # indexed array
local -A map=() # associative array
local -n ref=$1 # nameref
Dynamic scoping — the genuinely surprising part:
Bash uses dynamic scope, not lexical. A local variable is visible to every function called from the declaring function.
inner() {
echo "$secret" # visible! Even though inner never declared it
}
outer() {
local secret="from outer"
inner
}
outer # prints: from outer
In a lexically scoped language inner could not see secret. In bash it can, because local pushes onto a stack that stays live for the duration of the call. This is occasionally useful — it is how namerefs and temporary IFS overrides work — and it means a helper can be silently affected by a caller three frames up. Prefix internal variables (_tmp) to reduce the odds of a collision.
Topic 4: Returning Values
return sets an exit status, not a value. It takes 0–255 and nothing else.
bad() {
return "hello" # error: not a number
return 1000 # silently becomes 1000 % 256 = 232
}
Three real mechanisms:
1. stdout plus command substitution — the idiomatic way:
get_ip() {
local iface=$1
ip -4 addr show "$iface" | awk '/inet / {print $2}' | cut -d/ -f1
}
ip_address=$(get_ip eth0)
The cost is a subshell and a fork. Fine once; expensive in a loop over 10,000 items.
Diagnostics must go to stderr in any function that returns via stdout, or your log lines end up captured as the return value:
get_ip() {
echo "looking up $1" >&2 # stderr — NOT captured
ip -4 addr show "$1" | awk '/inet / {print $2}' | cut -d/ -f1
}
2. A nameref — no subshell, mutates the caller’s variable (bash 4.3+):
get_ip_into() {
local -n out=$1 # out is an ALIAS for the caller's variable
local iface=$2
out=$(ip -4 addr show "$iface" | awk '/inet / {print $2}' | cut -d/ -f1)
}
get_ip_into result eth0 # pass the NAME
echo "$result"
This is the only way to return an array from a function, and the only way to avoid the fork in a hot loop.
3. Exit status, for predicates:
is_running() {
systemctl is-active --quiet "$1"
}
if is_running nginx; then
echo "up"
fi
A function whose job is to answer yes/no should return 0/non-zero and print nothing. Testing it directly with if is cleaner than capturing output and comparing strings.
The local trap:
process() {
local result=$(failing_command) # exit status LOST — `local` succeeded
echo "$?" # 0, always
}
local is itself a command, and its status is what $? holds. Split the two lines:
process() {
local result
result=$(failing_command) || return 1 # now the status propagates
}
ShellCheck flags this as SC2155, and it silently defeats set -e — one of the pitfalls from the error-handling lesson.
Topic 5: Practical Patterns
Guard clauses over nesting:
deploy() {
local target=${1:?target required}
local artifact=${2:?artifact required}
[[ -f $artifact ]] || { echo "missing: ${artifact}" >&2; return 3; }
ping -c1 -W2 "$target" >/dev/null 2>&1 || { echo "unreachable: ${target}" >&2; return 4; }
rsync -az "$artifact" "${target}:/srv/"
}
${1:?message} validates and errors in one expansion. Each guard returns a distinct code so the caller can distinguish “bad input” from “network down”.
Recursion works, with a depth guard:
walk() {
local dir=$1 depth=${2:-0}
(( depth > 10 )) && { echo "too deep: ${dir}" >&2; return 1; }
local entry
for entry in "$dir"/*; do
[[ -d $entry ]] && walk "$entry" $(( depth + 1 ))
[[ -f $entry ]] && echo "$entry"
done
}
Bash has a FUNCNEST limit but no tail-call optimisation, and deep recursion is slow. For filesystem walking, find is the right answer — this pattern is for tree structures find cannot express.
Exporting to subshells:
export -f my_function # bash only
find . -type f -exec bash -c 'my_function "$0"' {} \;
printf '%s\n' "${items[@]}" | xargs -P4 -I{} bash -c 'my_function "$@"' _ {}
export -f is what lets xargs and find -exec call a function you defined. Without it the subshell has never heard of it.
Try it yourself: Write a function that uses i as an unlocalised loop counter, call it from inside a loop that also uses i, and watch the outer loop misbehave. Then add local i and confirm it is fixed.
Common mistake: Writing return "$value" expecting to get data back. It truncates to value % 256, so returning a count of 300 gives you 44. Print to stdout and capture, or use a nameref.