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?
answer
- who called, not where written
- the call stack decides the binding
- one table, saved and restored
- callee can also assign it
- common names like i and tmp collide
basics
~20 sBash 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.
solid answer
~50 sBash scopes locals dynamically. When a function declares `local retries=3`, bash pushes a new binding for that name that stays in effect until the function returns — and any function called from inside it resolves `retries` through the same live call stack, so the helper reads 3 without being passed anything. In a lexically scoped language the helper would only see names visible where it was *written*, so this would be an undefined-variable error. Two consequences matter in practice. First, a helper can accidentally read or write a caller's local just by using a common name like `i` or `tmp`, and neither party gets a warning. Second, you cannot rely on it: the helper works only when it happens to be called from something that defined `retries`, so it breaks the moment it is reused elsewhere. Treat the visibility as an accident to defend against, and pass values as explicit arguments.
code
bash · 10 lines#!/usr/bin/env bash
show() { echo "helper sees: ${retries-unset}"; }
outer() {
local retries=3
show # dynamic scope: the helper resolves retries through the live call stack
}
outer # helper sees: 3
show # helper sees: unsetgo deeper
Know the term dynamic scoping and be able to state that a function called from another function can see that function's local variables while the call is in progress.
Explain the mechanism: one variable table with a save-and-restore around the declaring call, so the binding is chosen by the call stack rather than by where the code is written. Contrast that with lexical scoping in one sentence.
Demonstrate the operational stance: declare every function-internal name local including loop counters, pass data as arguments, and be able to describe a real collision bug and how you would have caught it in review or with linting.
Own the design consequence: a language whose name resolution depends on the caller cannot offer real encapsulation, so argue for naming conventions, library boundaries and a size threshold beyond which shared shell libraries should be replaced.
## Two ways to resolve a name When code mentions `retries`, the language has to decide which binding that means. There are two families of answer: - **Lexical (static) scoping** — the one almost every modern language uses. The binding is decided by the *text* surrounding the reference: enclosing blocks, then enclosing functions, then module/global. Who called the function is irrelevant, and you can determine the answer by reading the source. - **Dynamic scoping** — the binding is decided by the *call stack at runtime*. The most recent still-active declaration of that name wins, no matter which function made it. Bash is dynamically scoped. This is not a quirk of `local`; it is how `local` is defined to work. ## What the runtime actually does There is one variable table for the shell. `local retries=3` does not create a private namespace; it saves whatever `retries` currently holds, installs a new value in the same table, and schedules the old value to be restored when the function returns. While the function is running, that new value simply *is* the shell's `retries` — so anything running during that window sees it, including functions called several levels down. ```bash show() { echo "helper sees: ${retries-unset}"; } outer() { local retries=3; show; } outer # helper sees: 3 show # helper sees: unset ``` The same call to `show` prints different things depending on who called it. That is the definition of dynamic scoping, and it is why you cannot decide what a bash function reads by reading the function alone. ## The two consequences that matter **A callee can clobber a caller's local.** Visibility runs both ways: the helper can also *assign* `retries`, and the assignment lands on the caller's binding, not on a fresh global. The realistic version of this bug uses a boring name: ```bash helper() { for i in 1 2 3; do :; done; } # no local i main() { local i for i in a b c; do helper echo "$i" # still a, b, c? only because for reassigns i each pass done } ``` Any loop or counter in the caller that the callee also touches is a live hazard, and nothing in bash reports it. This is the single strongest argument for declaring *every* function-internal variable, loop counters included, as `local`. **Inherited visibility is not an interface.** A helper that reads `$retries` because some caller happened to define it has an invisible dependency. It works today and fails the first time someone calls it from a different function, or in a unit test, or after a refactor moves the declaration. There is no compile-time signal — you get an empty string, and under `set -u` you get an abort at a line that looks fine. The fix is ordinary discipline: pass the value in as a positional parameter, and make the dependency explicit in the function's own text. ```bash retry_count() { local retries=$1; ...; } outer() { local retries=3; retry_count "$retries"; } ``` ## Where the visibility stops Dynamic scope follows the call stack of *this shell*. A subshell — a forked copy of the shell — starts from a snapshot, so it can read the caller's locals but its own assignments die with it; that isolation is the subject of its own topic. External commands are different again: they are separate programs and receive only the exported environment, so a local variable is invisible to them unless it was exported. One related nuance is `unset` inside a function. Because `unset` operates on the current dynamic binding, unsetting a name a caller declared local can expose an outer binding rather than removing the variable outright. It is a corner most scripts should simply avoid: do not unset names you did not declare. ## How to talk about it A crisp interview answer names the rule (dynamic scoping), states the mechanism (one variable table, save/restore around the call), contrasts it with lexical scoping in one sentence, and then lands on the practical stance: bash gives you the visibility whether you want it or not, so the engineering job is to declare locals everywhere and pass data explicitly, treating any reliance on a caller's variable as a defect waiting for a refactor.
- If dynamic scoping lets a helper read a caller's variable for free, why not use it as the calling convention?Because it is an invisible dependency. The helper only works when it happens to be called from a function that declared the name, so it breaks under reuse, refactoring or unit tests, and the failure is an empty value rather than an error. Pass positional arguments instead, so the dependency is written down in the function itself.
- Can the called function change the caller's local, or only read it?It can change it. Visibility runs both ways: an assignment inside the callee lands on the currently active binding, which is the caller's local. That is exactly how a helper's undeclared loop counter silently corrupts a caller's loop, and why every function-internal variable should be declared local.
- Does the same visibility extend into a subshell or into an external command?A subshell is a forked copy of the shell, so it can read the caller's locals, but its own changes die with it. An external command is a different process entirely and receives only the exported environment, so plain locals are invisible to it unless exported.
Lexical scoping is a name resolved by the room you are standing in; dynamic scoping is a name resolved by shouting up the corridor of everyone who called you, and taking the first answer.
saying these in an interview costs you the question
- Says bash uses lexical scoping like C or Python
- Thinks a helper sees the caller's local only if it is exported
- Assumes the callee can read but never overwrite the caller's local
- Treats inherited visibility as a supported way to pass parameters
- Claims locals are private to the declaring function in all cases