Functions, Scope & Return Values

Why bash variables are global by default, the two ways to get data out of a function, and the scoping rules that make shell functions behave unlike every other language you know.

intermediate 17 min lesson hands-on task included

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 functionRefers to
$1, $2, …The function’s arguments
$#How many the function received
"$@"All of them, boundaries preserved
$0Still 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.