Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 18 · Functions and Script Decomposition

local, Scope and Accidental Globals

This is the chapter that decides whether a script with twenty functions works. Bash has no block scope and no implicit function scope — every assignment you do not explicitly declare is to one shared namespace — and on top of that it scopes dynamically, so a function can see and change the variables of whatever called it.

Intermediate21 min readLinuxPart IBashFunctionsScopelocalScripting

Part I · Module 18 · Chapter 18.5 · local, Scope and Accidental Globals

Chapter 18.2 showed that $1 is properly per-call — a function's arguments are its own and the caller's come back untouched. It is reasonable to assume ordinary variables behave the same way. They do not.

1. Every Undeclared Assignment Is Shared

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ i=CALLER
$ f() { for i in 1 2 3; do :; done; }
$ f
$ echo "caller i=$i"
caller i=3

The caller's i is gone. The function did not have its own i — there is only one i, and the loop walked it to 3.

One word fixes it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ i=CALLER
$ f() { local i; for i in 1 2 3; do :; done; }
$ f
$ echo "caller i=$i"
caller i=CALLER

Note what was unprotected there: a loop variable. Not a value anybody intended to share — the throwaway name in a for. That is the shape this bug almost always takes.

A shared namespace, and a declared one

Why a function's variables have to be declared local. Left undeclared, every assignment a function makes is to the one shared set of variables, so two functions that happen to pick the same short name corrupt each other. Declaring them keeps each call's values to itself, and also hides them from anything it calls.An undeclaredassignment is sharedthere is only one set ofvariablesTwo functions pick thesame namea counter, an index, atemporaryAnd quietly corrupteach othera count comes back wrong, withno errorA declared variable isthe call's owncreated on entry, discarded onreturnEach call gets a freshoneso even a function callingitself is safeAnd it is hidden fromcalleeswhich can otherwise see andchange it12
Without a declaration there is one set of variables for the whole script, so two functions that happen to choose the same short name write to the same place and quietly spoil each other's work. A declared variable belongs to the call that made it, is discarded when that call returns, and is also hidden from anything the function goes on to call.

2. The Bug You Will Actually Ship

The loop demo is too obvious to be convincing. Here is the real shape — two reasonable functions, written at different times, that both happen to use n:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# bug.sh

count_lines() {
  n=$(wc -l < "$1" | tr -d ' ')     # no local
  echo "$n"
}

report() {
  for f in "$@"; do
    n=$(count_lines "$f")
    printf '%-10s %s lines\n' "$f" "$n"
  done
}

process() {
  n=0                                # my counter
  for f in "$@"; do
    n=$(( n + 1 ))
    count_lines "$f" > /dev/null     # ... which count_lines overwrites
  done
  echo "processed $n files"
}

printf 'a\nb\n' > x.txt; printf 'c\n' > y.txt
report  x.txt y.txt
process x.txt y.txt
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./bug.sh
x.txt      2 lines
y.txt      1 lines
processed 1 files

Two files, "processed 1 files". No error, no warning, nothing in a log. process incremented n to 1, called count_lines, which assigned n=2 for x.txt; then n=$(( n + 1 )) made it 3, and count_lines set it to 1 for y.txt. The loop ended with n=1 and the script confidently reported a number that is neither right nor obviously wrong.

Now notice three things about that bug:

report works perfectly. It captures count_lines' output and never uses n itself, so it is immune. The bug lives in process, which is a function you would not think to look at while debugging count_lines.

count_lines is not wrong in isolation. Every test of count_lines passes. It is wrong only in combination, which is why this survives unit testing and arrives in production.

One local in count_lines fixes it — and so does one local in process. Either function declaring its own n breaks the collision. Which is the argument for doing it everywhere: you cannot know which of the two future authors will remember.

3. Bash Is Dynamically Scoped

Here is the fact that surprises people who know C, Python or Java — and that makes local protect more than you would guess.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ outer() { local secret=OUTERS; inner; }
$ inner() { echo "  inner can see secret=$secret"; }
$ outer
  inner can see secret=OUTERS

$ inner
  at top level secret=[]

inner can read outer's local variable. Not because they are related — inner is defined separately and knows nothing about outer — but because local in Bash means "visible to this call and everything it calls", not "visible to this function's body".

That is dynamic scope: what a name means depends on the call stack at run time, not on where the code sits. Called from outer, inner sees OUTERS; called from the top level, it sees nothing.

And it is not read-only:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ outer() { local v=1; inner; echo "  outer v is now $v"; }
$ inner() { v=99; }
$ outer
  outer v is now 99

inner reached into its caller and changed a local. outer declared v as local and still had it modified by a function that never mentions outer.

The protection is for inner to declare its own:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ outer() { local v=1; inner; echo "  outer v still $v"; }
$ inner() { local v=99; }
$ outer
  outer v still 1

So local does two jobs: it stops you clobbering your caller, and it stops your callees clobbering you. Both matter, and the second is the one people do not expect.

4. local Is Per Call, Not Per Function

Each invocation gets its own, which is what makes recursion safe:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ rec() { local d=$1; if [ "$d" -gt 0 ]; then rec $(( d - 1 )); fi; echo "  unwinding d=$d"; }
$ rec 3
  unwinding d=0
  unwinding d=1
  unwinding d=2
  unwinding d=3

Four values of d alive at once, each belonging to its own call, each restored as the stack unwinds. Without local, every level would share one d and the unwinding would print 0 four times.

local is also only legal inside a function:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'local x=1'
bash: local: can only be used in a function

At the top level of a script every variable is global by definition. If you want a script-level variable that functions should not touch, there is no mechanism to enforce it — the convention is ALL_CAPS for script-level and lowercase for function-local, which is worth following precisely because nothing checks it.

5. local's Other Forms

local takes the same attributes as declare:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { local -r k=fixed; k=changed; }
$ f
bash: k: readonly variable

local -r is readonly for the life of the call — useful for a validated argument you want to be sure nothing further down reassigns. local -i declares an integer, so assignments are evaluated arithmetically. local -a is an indexed array (Chapter 19.2) and local -A an associative one (Chapter 19.6, which needs Bash 4).

You can declare several at once, with or without values:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
local n rc line
local total=0 skipped=0

And unset inside a function removes the local, leaving the caller's value intact:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v=OUTER
$ f() { local v=INNER; unset v; echo "  after unset in fn: [$v]"; }
$ f
  after unset in fn: []
$ echo "  caller: [$v]"
  caller: [OUTER]

6. The Assignment Trap, Where It Lives

Chapter 18.4 §5 measured this and it belongs here too, because local is where you write it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ f() { local v=$(false); echo "  local v=\$(false) -> status $?"; }; f
  local v=$(false) -> status 0
$ f() { local v; v=$(false); echo "  split            -> status $?"; }; f
  split            -> status 1

local v=$(cmd) reports local's status, which is success. The command's status is discarded.

This is the single most common way a function-heavy script loses an error, because local v=$(...) is such a natural line to write and it is wrong in exactly the situation you care about. The fix is mechanical:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
local v
v=$(cmd) || return 1

Declare on one line; assign on the next, with the guard. Two extra characters, and the function can detect failure.

7. A Complete Script

The bug.sh collision, fixed, and grown into something that would survive review:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# audit.sh — report line counts and a total across a file list
# usage: audit.sh <file> [file...]
#
# exit: 0 all readable · 1 at least one was not · 2 bad usage
#
# convention: ALL_CAPS is script level; every function declares its own.

set -o nounset

SCRIPT_NAME=${0##*/}

note() { printf '%s: %s\n' "$SCRIPT_NAME" "$*" >&2; }
die()  { note "$*"; exit 2; }

# count_lines <file> -> prints the count; 1 if unreadable
count_lines() {
  local f=$1
  local n
  n=$(wc -l < "$f") || return 1
  printf '%s\n' "$(( n ))"
}

# longest_name <file>... -> prints the width of the longest basename
longest_name() {
  local f base w
  local max=0
  for f in "$@"; do
    base=${f##*/}
    w=${#base}
    (( w > max )) && max=$w
  done
  printf '%s\n' "$max"
}

# report <width> <file>... -> prints a line per file; 1 if any was unreadable
report() {
  local w=$1; shift
  local f n
  local bad=0
  for f in "$@"; do
    if ! n=$(count_lines "$f"); then
      note "cannot read $f"
      bad=$(( bad + 1 ))
      continue
    fi
    printf '%-*s %5s\n' "$w" "${f##*/}" "$n"
  done
  return "$(( bad > 0 ))"
}

# total <file>... -> prints the sum of the readable files' counts
total() {
  local f n
  local sum=0
  for f in "$@"; do
    n=$(count_lines "$f") || continue
    sum=$(( sum + n ))
  done
  printf '%s\n' "$sum"
}

# ---- top level -----------------------------------------------------------

[ $# -ge 1 ] || die "usage: $SCRIPT_NAME <file> [file...]"

width=$(longest_name "$@")
rc=0

report "$width" "$@" || rc=1

printf -- '%-*s %5s\n' "$width" "TOTAL" "$(total "$@")"

exit "$rc"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ printf 'a\nb\n' > x.txt
$ printf 'c\n'    > y.txt
$ printf 'd\ne\nf\n' > a-much-longer-name.txt

$ ./audit.sh x.txt y.txt a-much-longer-name.txt
x.txt                      2
y.txt                      1
a-much-longer-name.txt     3
TOTAL                      6
$ echo $?
0

$ printf 'z\n' > locked.txt && chmod 000 locked.txt
$ ./audit.sh x.txt locked.txt y.txt
x.txt          2
./audit.sh: line 9: locked.txt: Permission denied
audit.sh: cannot read locked.txt
y.txt          1
./audit.sh: line 9: locked.txt: Permission denied
TOTAL          3
$ echo $?
1

$ ./audit.sh
audit.sh: usage: audit.sh <file> [file...]
$ echo $?
2

Six decisions this chapter is responsible for:

Every function declares every variable it assigns. count_lines declares f and n; longest_name declares f base w max; report declares w f n bad; total declares f n sum. Three of those four use f and n, and they are called from each other — report calls count_lines while holding its own n, and total does the same. Without the declarations this is bug.sh again, with two separate collisions.

report calls count_lines while using n itself — the exact shape of §2's bug — and it is safe only because both declare n. Remove either local n and the report's numbers go wrong without an error.

local f=$1 copies the argument into a named local rather than using $1 throughout. That is a readability choice rather than a correctness one, and it pays off in report, where $1 is consumed by shift and $f is the loop item.

n=$(count_lines "$f") is guarded by if ! and by || continue, which works because n was declared separately — on its own local n line. Written as local n=$(count_lines "$f") neither guard would ever fire.

SCRIPT_NAME is ALL_CAPS and set once, following the convention the header documents. Nothing enforces it; the comment is the enforcement.

One honest blemish in that second transcript: Permission denied appears twice, because report and total each call count_lines on the same unreadable file and wc complains both times. The script's own message appears once, from report. Fixing it properly means counting once and reusing the number — which needs somewhere to keep a list of per-file counts, and that is an array. Module 19.

report returns $(( bad > 0 )), measured as 0 when bad is 0 and 1 when it is 3 — an arithmetic expression standing in for a status, which reads cleanly and is exactly Chapter 16.3's (( )) truth convention used on purpose.

Practice

  1. Run the §1 loop demo with and without local i. Then do the same with a while loop and a counter instead of a for, and confirm the behaviour is identical.
  2. Type out bug.sh and run it. Confirm "processed 1 files". Then fix it by adding local to count_lines only, re-run, and then fix it instead by adding local to process only. Both work — what does that tell you about where the bug "is"?
  3. In bug.sh, explain in one sentence why report is unaffected.
  4. Build the dynamic-scope demo: outer with local secret, and a separately-defined inner that prints it. Call inner from outer and then directly. Explain both results.
  5. Make inner assign to the caller's local v and confirm outer sees the change. Then protect outer with one word in inner.
  6. Write a recursive function with local d=$1 and without it. Run both at depth 3 and compare the unwinding output.
  7. Run bash -c 'local x=1'. What does the error tell you about where local is meaningful, and what convention takes its place at script level?
  8. Write f() { local -r k=1; k=2; } and run it. Then find a place in audit.sh where local -r would be a genuine improvement.
  9. Run both forms of local v=$(false) and print $?. Then break audit.sh's count_lines by collapsing the two lines, chmod 000 a file, and show that the error is no longer detected.
  10. In audit.sh, delete local n from report only. Run it on three files and explain the numbers you get.

Check Your Understanding

  1. What is the scope of a variable a function assigns without declaring it?
  2. In bug.sh, two files produce "processed 1 files". Trace why.
  3. Why does report escape the bug that hits process?
  4. A function declares local secret. Can a function it calls read it?
  5. Can that callee change it, and what prevents that?
  6. Why is local per call rather than per function?
  7. What does local v=$(cmd) report, and what is the fix?

8. Remember

  • An undeclared assignment inside a function is global. There is no block scope and no implicit function scope.
  • Declare everything a function assigns, including loop variables, argument copies and one-line temporaries.
  • The realistic bug is two functions sharing a short name — a count comes back wrong with no error, and the function at fault passes every test of its own.
  • Either side declaring breaks the collision, which is why you declare on both.
  • Bash is dynamically scoped. A callee can read its caller's locals, because local means "this call and everything it calls".
  • And a callee can modify them. local protects you in both directions.
  • If a helper needs a value, pass it as an argument. Positional parameters are properly per-call; variables are not.
  • local is per call, which is what makes recursion safe.
  • local is only legal in a function. At script level the convention is ALL_CAPS, enforced by nothing but review.
  • local -r, local -i, local -a, local -A take the same attributes as declare.
  • local v=$(cmd) discards the command's status. Declare, then assign, then guard.

Chapter 18.6 closes the module by moving these functions out of the script entirely, into a file other scripts can source — which shares everything this chapter has been careful to contain, and so needs a convention of its own.

Where this fits

Part of the Linux & Bash curriculum.