bash · level 4

Functions & Locals

`local`, return values vs exit codes, scope rules — and why bash functions don't return what you think they return.

150 XP

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:

  1. Capturing with $() runs the function in a subshell. Local variable changes don't persist; trap handlers register only inside the subshell; cd doesn't propagate. The function is essentially a closed black box from the caller's perspective.
  2. return masks local's exit code. local x=$(may_fail) — the local builtin's success masks may_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:

  • local works the same, but zsh also has private (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 the name() { ... } 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
  • ShellCheckfree tier

    SC2034 (unused var), SC2155 (declare-and-assign masking exit), SC2178 (array-as-scalar). Catches local/global confusions.

    cli
  • bats-corefree tier

    Test bash functions in isolation — assert outputs, exit codes, side effects. Indispensable past 100 lines of script.

    cli
  • bashunitfree tier

    Modern bash test framework — TAP output, mocking, parameterised tests. Newer alternative to bats-core.

    cli