skip to content

Local Variables, Scope and Recursion

Every variable is global unless you say local, and bash scoping is dynamic — a function can see its caller's locals, which surprises anyone expecting lexical scope. The sharp edge is `local x=$(cmd)`, where local's own success hides the command's failure from set -e.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

In bash, a variable assigned inside a function is still set after the function returns and can overwrite a caller's variable of the same name. Why does bash behave this way, and what makes a variable exist only for the duration of the function?

level: juniorimportance: must knowfreq 78%

answer

  1. no implicit function scope
  2. one shell-wide variable table
  3. the keyword that saves and restores
  4. declare inside a function behaves the same
  5. global still is not exported

basics

~20 s

Bash variables are global by default, so an assignment inside a function writes into the one shell-wide namespace and survives the return. Declaring the name with local (or declare) inside the function creates a temporary copy that disappears when the function returns.

solid answer

~50 s

Bash has a single shell-wide variable namespace, and a function body is just code running in that same shell — there is no implicit function scope. So `count=5` inside a function is the same assignment it would be at the top of the script: it creates or overwrites the shell's `count` and leaves it set after the function returns. To get a variable that only lives for the call, declare it with `local` (or, equivalently inside a function, `declare`/`typeset`): bash saves any previous value, gives the function its own, and restores the old one on return. Because of that, the usual discipline is that every variable a function uses for its own bookkeeping — including loop counters like `i` — gets declared `local` on the line where the function first needs it. Note that global here means shell-wide, not exported: without `export`, external commands still do not see the variable in their environment.

code

bash · 11 lines
bash
#!/usr/bin/env bash
count=1

bump_global() { count=99; }
bump_local()  { local count=42; echo "inside bump_local: $count"; }

bump_global
echo "after bump_global: $count"   # 99 - the function wrote the caller's variable

bump_local
echo "after bump_local:  $count"   # 99 - unchanged; the local vanished on return

go deeper

for a junior

Be ready to say plainly that bash variables are global unless declared with local inside a function, and to show the one-line fix for a helper that clobbers a caller's variable.

for a middle

Explain the save-and-restore mechanics local performs per invocation, that declare inside a function does the same thing, and that declare -g is the deliberate escape hatch in bash 4.2 and later.

for a senior

Show the discipline you enforce in shared shell libraries: locals for all function bookkeeping including loop counters, a small readonly set of deliberate globals, and how you spot the clobber during review rather than in production.

for a principal

Own the tradeoff of a language with one namespace and no compiler to catch collisions: what conventions and review gates you mandate for shell in production, and at what size or complexity the risk justifies moving the logic out of bash.

## One namespace by default Most languages give a function its own scope: names bound inside it are invisible outside. Bash does not. A shell function is not a separate environment — it is a named block of commands executed by the same shell process, against the same variable table the rest of the script uses. An assignment is an assignment wherever it appears. ```bash count=1 bump() { count=99; } bump echo "$count" # 99 — the function wrote the script's variable ``` That is the whole rule, and it explains both directions of surprise: a function can silently clobber a caller's variable, and a value a function set is still readable afterwards (which is one way scripts return data, though a dedicated mechanism is usually better). ## What local actually does `local NAME` may only be used inside a function; at the top level bash reports `local: can only be used in a function` and returns non-zero (ShellCheck flags the same mistake as SC2168). Inside a function it does three things: 1. saves the current value and attributes of `NAME`, if any; 2. gives this invocation of the function a fresh instance of `NAME` (unset until assigned, unless you assign on the same line); 3. restores the saved state when the function returns. ```bash count=1 bump() { local count=42; echo "inside: $count"; } bump # inside: 42 echo "$count" # 1 — the caller's value came back ``` Because the save/restore is per invocation, each recursive call of a function also gets its own copy of its locals. `local` accepts several attribute options, the common ones being `-r` for a read-only local, `-a` for an indexed array and `-i` for an integer. You can declare several names in one statement: `local a b c=3`. ## declare and typeset inside a function `declare` (and its older synonym `typeset`) behaves like `local` when it is used inside a function: `declare tmp=1` in a function body creates a function-local `tmp`. That equivalence trips people who use `declare -a items` inside a function expecting a global array. If you genuinely want a function to create or modify a global, bash 4.2 and later provide `declare -g NAME=value`, which forces the assignment into the global scope even from inside a function. Doing that deliberately and with an obvious naming convention is fine; doing it by accident is the bug this question is about. ## Global is not the same as exported "Global" in bash means visible to everything running in this shell. It says nothing about child processes. Environment variables are a separate, narrower thing: only names marked with `export` (or assigned as a command prefix, `FOO=bar cmd`) are copied into the environment of an external command the script runs. ```bash MSG=hello # shell variable, global to this shell export LOG_LEVEL=debug # also placed in the environment of children printenv MSG # prints nothing — printenv is a separate process ``` So a function assigning a global has not "leaked it to the system"; it has leaked it to the rest of the script, which is bad enough. ## Why it bites in real scripts The classic failure is a shared library of helper functions where two authors both used `i`, `tmp`, `file` or `result`. A helper called from inside a caller's loop resets `i`, and the loop either runs forever or exits early — with no error message, because nothing illegal happened. The second classic is a helper that sets `result` and a caller that forgot to reset it, so a call that failed early leaves the previous call's value in place and the script proceeds on stale data. The habit that prevents both: **declare every variable a function uses for itself as `local`, on the first line that needs it, loop counters included.** Reserve unqualified globals for a small, deliberately named set of script configuration values, ideally marked `readonly` so a stray assignment fails loudly instead of quietly. ```bash process_files() { local dir=$1 count=0 f for f in "$dir"/*; do count=$((count + 1)) done printf '%d\n' "$count" } ``` One caveat worth knowing: `local` is not in POSIX. Every shell you are likely to meet — bash, dash, ksh, BusyBox ash — supports it, but a strictly POSIX script cannot rely on it being specified, which is one more reason a script that needs real function scope is more honestly written with a `#!/usr/bin/env bash` shebang.

  • What does `declare tmp=1` do inside a function compared with the same line at the top level of a script?
    Inside a function, `declare` behaves like `local`: `tmp` is function-scoped and is restored on return. At the top level it just creates a global with whatever attributes you asked for. If you need a function to write a global explicitly, bash 4.2 and later offer `declare -g tmp=1`, which bypasses the function scope.
  • What happens if you use `local` outside a function?
    Bash refuses it: the builtin prints `local: can only be used in a function` and returns a non-zero status. Under `set -e` that aborts the script. ShellCheck reports the same mistake statically as SC2168, which is why it shows up in linting long before it shows up at runtime.
  • A function sets a variable without exporting it. Which things can see it?
    Everything running in the same shell, including functions it calls and the rest of the script after it returns. Subshells forked from the shell inherit a copy. External commands do not — they are separate processes and only receive names marked with `export`, or an assignment used as a command prefix.

saying these in an interview costs you the question

  • Says bash functions scope their variables automatically, like Python
  • Thinks local is only a style or readability convention
  • Claims local can be used at the top level of a script
  • Believes a function's assignments are discarded when it returns
  • Confuses global with exported and expects child commands to see it

context

open as a page

A bash script runs under `set -e`. Inside a function, the line `local out=$(some-command)` lets the script continue even when some-command exits non-zero, while the same assignment written without `local` aborts. Why does the status disappear, and how do you write it so the failure is caught?

level: seniorimportance: must knowfreq 44%

basics

~20 s

local is a builtin command, so the line's exit status is local's own status — almost always 0 — and the command substitution's failure is thrown away. Split it into two lines: declare with local on one line, assign on the next, so the assignment carries the real status.

open as a page

In a bash script, an outer function declares `local retries=3` and then calls a helper function that reads `$retries` — and the helper sees the value 3 even though it never declared or received it. Which scoping rule does bash use to allow this, and how does it differ from the lexical scoping of C, Java or Python?

level: middleimportance: should knowfreq 46%

basics

~20 s

Bash uses dynamic scoping: a local variable is visible to the function that declared it and to everything it calls, for as long as that call is on the stack. Lexical scoping, by contrast, resolves names from where code is written, not from who called it.

open as a page

You maintain a growing set of shared bash function libraries, and an incident traced back to a helper whose undeclared variable overwrote a caller's value mid-loop. Given that bash variables are global by default and its scoping is dynamic, what conventions would you mandate to stop function state from colliding, and what would tell you the codebase has outgrown bash?

level: principalimportance: should knowfreq 28%

basics

~20 s

Mandate that every function declares all its working variables local on entry, reserve a small set of prefixed read-only globals for configuration, pass data as explicit arguments, and enforce it with linting and review. Move off bash when the logic needs real data structures, error handling or testability.

open as a page

Bash functions may call themselves. What limits how deep a recursive bash function can go, what does bash's FUNCNEST variable do, and how can such a function tell how deep it currently is?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

By default bash imposes no recursion limit, so a runaway recursive function grows the call stack until the shell exhausts memory and dies. Setting FUNCNEST to a positive number caps nesting depth, and the FUNCNAME array records the current call stack.

open as a page