Functions · about 45 min

Scope, local and returning values

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:

  1. 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 calls started 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
  2. 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

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

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.

Exercises

Exercise 1 · quiz

Dynamic scope

What does this print?

x=global
show() { echo "$x"; }
outer() {
    local x=outer
    show
}
outer
show
Answer
  • outer / global

bash uses dynamic scope: when show looks up x, bash searches the chain of active calls, innermost first. show was called by outer, which has a local x, so the first call prints outer. When outer returns, its local is gone, and the second show sees the global. Most languages you may learn later (Python, C, Rust) use lexical scope instead, where only where the function is written matters, not who called it.

Exercise 2 · quiz

Where did the error go?

fetch fails (status 1). What does each line print?

f1() { local data=$(fetch); echo $?; }
f2() { local data; data=$(fetch); echo $?; }

Pick the outputs of f1 and f2.

Answer
  • f1: 0, f2: 1

local data=$(fetch) is one command: the builtin local. Its status is whether local succeeded (it did, 0), which replaces fetch's status. Declaring first and assigning separately keeps the substitution's status, because a plain assignment's status is that of the last command substitution in it. The same applies to export and declare.

Exercise 3 · write code

A function that returns text

Write a function slugify that takes one string as its argument and prints a "slug" version of it, suitable for a file name or URL:

  1. convert to lowercase,
  2. replace every run of characters that are not a-z or 0-9 with a single -,
  3. remove any - at the start or end.

Then the script reads lines from standard input and, for each line, prints ORIGINAL => SLUG using slug=$(slugify "$line"). Use local for every variable inside the function. Lines may contain spaces and punctuation.

Tools you may use inside the function: tr '[:upper:]' '[:lower:]', sed, or bash's ${s,,} (lowercase) and ${s//pattern/replacement}.

File: script.sh

Starting point

#!/bin/bash
slugify() {
    :
}
while IFS= read -r line; do
    echo "$line => $(slugify "$line")"
done

Example

input

Hello World
My Trip to Paris (2026)!
  --Already-slugged--  
C++ & Rust

expected output

Hello World => hello-world
My Trip to Paris (2026)! => my-trip-to-paris-2026
  --Already-slugged--   => already-slugged
C++ & Rust => c-rust

Example

input

!!!

expected output

!!! =>
Hint 1

Lowercase: local s=${1,,}. Then pass it through sed: sed -E 's/[^a-z0-9]+/-/g'.

Hint 2

Two more sed expressions trim the ends: s/^-+// and s/-+$//. Finally printf '%s\n' "$s" prints the result for the caller to capture.

Reference solution
#!/bin/bash
slugify() {
    local s=${1,,}
    s=$(printf '%s' "$s" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')
    printf '%s\n' "$s"
}
while IFS= read -r line; do
    slug=$(slugify "$line")
    echo "$line => $slug"
done

Exercise 4 · write code

Recursion needs local

Write a recursive function fact N that prints N factorial (N! = 1 x 2 x ... x N, and 0! = 1) by calling itself: fact N = N x fact N-1. Do not use a loop.

The script prints N! = RESULT for each argument. bash integers are 64-bit, so arguments go up to 20.

The starter is almost right but uses a global variable inside the function; find out why that breaks recursion and fix it.

File: script.sh

Starting point

#!/bin/bash
fact() {
    n=$1
    if (( n <= 1 )); then
        echo 1
    else
        sub=$(fact $(( n - 1 )))
        echo $(( n * sub ))
    fi
}
for a in "$@"; do
    echo "$a! = $(fact "$a")"
done

Example with arguments 5

expected output

5! = 120

Example with arguments 0 1 10 20

expected output

0! = 1
1! = 1
10! = 3628800
20! = 2432902008176640000
Hint 1

Does the starter fail? Try it: bash script.sh 5. Each $(...) runs in a subshell, which hides many scope bugs, so this one may still work. Look at the tests: one checks the source.

Hint 2

Every variable a function uses for itself should be declared local: local n=$1 sub.

Reference solution
#!/bin/bash
fact() {
    local n=$1 sub
    if (( n <= 1 )); then
        echo 1
        return
    fi
    sub=$(fact $(( n - 1 )))
    echo $(( n * sub ))
}
for a in "$@"; do
    echo "$a! = $(fact "$a")"
done