understanding scoping correctly in nested bash functions
Bash functions share a single global namespace by default, every variable is global unless explicitly declared with local. Anyone unaware of this ends up having a helper function accidentally overwrite a variable in a completely different function, a bug that usually only becomes visible once two functions happen to use the same variable name like i or result.
Table of Contents
- 1. Why bash functions share a global namespace by default
- 2. The local keyword: confining a variable to one function
- 3. Dynamic scoping: why nested functions see the caller's local variables
- 4. Visibility across multiple function levels: the call stack matters, not text position
- 5. The typical bug: a missing local silently overwrites someone else's variable
- 6. The fix: declare every function-internal variable with local, without exception
- 7. Returning values from functions: globals, namerefs and command substitution
- 8. declare -g: intentionally making a variable global
- 9. local, global, declare -g and local -n compared
- 10. Summary
- 11. FAQ
1. Why bash functions share a global namespace by default
Unlike most modern programming languages, in Bash every variable assigned inside a function without an extra keyword is automatically global, meaning readable and overwritable by every other function and the rest of the script body. A function foo that sets result=5 thereby changes the same result variable a completely different function bar, called later, also sees and freely reuses.
This behavior is a deliberate design decision inherited from the Bourne shell tradition, where functions were originally thought of more as named command sequences than as isolated program units with their own storage area. In short, one-off scripts this rarely surfaces, but in longer-lived library functions with multiple callers it quickly becomes a source of hard-to-trace bugs.
2. The local keyword: confining a variable to one function
Writing local name=value at the start of a function creates a variable that is only visible inside that function and, as shown in the next section, inside the functions it calls. Once the function leaves its scope through return or reaching its natural end, the local variable is automatically removed, and a global variable with the same name, if one exists, becomes visible again unchanged.
It matters to treat local as its own command rather than a pure declaration: local x=$(command) on one line masks the exit code of command, because the exit code returned is that of local itself, not of the substitution. ShellCheck flags this as rule SC2155 and recommends splitting the declaration and the assignment onto two lines whenever the assignment's exit code needs to be checked.
process_file() {
local filename="$1"
local line_count
line_count=$(wc -l < "$filename") || return 1
echo "Lines in $filename: $line_count"
}
3. Dynamic scoping: why nested functions see the caller's local variables
Bash uses so-called dynamic scoping for local variables, not lexical scoping like JavaScript or Python. The difference is fundamental: visibility does not depend on where a function is written in the source, only on which call path reached it at runtime. If function A calls function B, B sees every local variable of A, even though B might be defined in a completely different part of the script.
This is fundamentally different from a JavaScript closure, where a nested function only sees the variables of the context it was defined in, regardless of where it is called from later. In Bash, exactly the opposite holds: what matters is the call stack at runtime, not the textual position of the function definition in the script.
outer() {
local value="from outer"
inner
}
inner() {
echo "inner sees: $value"
}
outer
# inner sees: from outer <- inner sees outer's local variable,
# even though inner is defined elsewhere
4. Visibility across multiple function levels: the call stack matters, not text position
This visibility continues across any number of call levels: if outer calls middle, and middle in turn calls inner, then inner sees both the local variables of middle and those of outer, as long as middle itself did not declare its own local variable of the same name that shadows the outer one.
If middle does declare its own local variable with the same name as outer's, then inside middle and everything middle calls from that point on, the closer declaration in middle takes over, while the original variable from outer is temporarily shadowed but not deleted, becoming visible again once middle returns. This behavior matches classic name shadowing, only at runtime via the call stack instead of via lexical blocks.
5. The typical bug: a missing local silently overwrites someone else's variable
The most common practical bug occurs when a helper function in a library sets a variable without local, and that name happens to collide with a variable in the calling function, especially with short, generic names like i, tmp, result, or line. The calling function then finds an unexpected value in its own variable, even though it never changed it itself, which makes debugging harder because the actual cause sits in an entirely different function.
This bug is particularly nasty inside loops: if a for i in ... loop calls a function that internally uses a variable i as a counter without local, that function overwrites the calling level's loop variable, which can cause an infinite loop or skipped iterations, depending on the value the function leaves behind in i when it exits.
# Missing local: reset_counter overwrites the caller's loop variable i
reset_counter() {
i=0 # no local -- overwrites any i in the call stack with the same name
}
for i in 1 2 3 4 5; do
echo "Iteration: $i"
reset_counter
done
# Iteration: 1
# Iteration: 1 <- i was reset to 0 by reset_counter, +1 by for repeats it
6. The fix: declare every function-internal variable with local, without exception
The reliable safeguard is declaring every internally needed variable in every function with local on first use, without exception, even if it is just a short-lived intermediate value. Additional modifiers like local -r for read-only values or local -i for guaranteed numeric variables make the intent even more explicit and surface accidental assignments earlier.
Static analysis with ShellCheck does not reliably catch every missing local declaration, because a variable set without local is syntactically perfectly valid. A proven substitute is a firm team convention to prefix all internal names in library functions with a function-specific prefix, for example _pf_tmp instead of tmp inside a function process_file, to make collisions less likely.
reset_counter() {
local i=0 # now local -- does not touch any i in the call stack
echo "internal counter reset: $i"
}
for i in 1 2 3; do
echo "Iteration: $i"
reset_counter
done
# Iteration: 1
# internal counter reset: 0
# Iteration: 2
# internal counter reset: 0
# Iteration: 3
7. Returning values from functions: globals, namerefs and command substitution
Because Bash functions can only return a numeric exit code, not an arbitrary return value, scripts commonly reach for one of two techniques: either printing the result with echo and capturing it at the call site via $(function) as command substitution, or writing the result directly into a variable named by the caller. For the latter, local -n provides a nameref, a local variable that acts as a reference to a variable the caller names.
A nameref lets a function write directly into a caller's variable without needing an unprotected global variable, and makes the intent explicit right in the function signature, showing which variable is meant to be changed. Command substitution with $(...) is easier to read by comparison, but, as covered in the command substitution article, comes with process overhead and its own subshell, which can matter for very large return values or inside tight loops.
get_config_value() {
local -n out_ref="$1" # nameref to the variable named by the caller
out_ref="production"
}
get_config_value target_value
echo "Configuration: $target_value"
# Configuration: production
8. declare -g: intentionally making a variable global
Sometimes a genuinely global variable is what you want, for example a cache or a call counter meant to persist across multiple function calls. Setting such a variable inside a function with declare -g name=value instead of local makes that intent explicit in the code, instead of relying on Bash's default global behavior, which afterward cannot be distinguished from an accidentally missing local.
The difference between a deliberate declare -g and an accidentally global variable is not in runtime behavior, both technically produce the same global visibility, but purely in readability for humans: declare -g clearly marks a deliberate design decision in the source, while a plain name=value without any keyword gives the next reader no way to tell whether globality was intentional or a mistake.
9. local, global, declare -g and local -n compared
Which tool is right for a nested function depends on whether a value is only needed within one call, needs to be passed to sub-functions, or genuinely needs to persist across multiple calls. The table below summarizes the four patterns.
| Pattern | Visibility | Survives function end | Typical use |
|---|---|---|---|
local name=value |
Calling function and its sub-functions | No | Intermediate values, the default in every function |
Plain name=value |
Global, entire script | Yes | Usually a bug when unintentional |
declare -g name=value |
Global, entire script | Yes | Deliberate cache or counter across calls |
local -n ref="$1" |
Reference to the caller's variable | No, only the reference itself | Return values without a global variable |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Local and Global Variables: The Essentials at a Glance
Basic rule
Without local, every bash variable is global. local confines visibility to the function and its call chain.
Dynamic scoping
local variables are visible through the call stack, not through text position in the script, unlike closures in JavaScript or Python.
Typical bug
A missing local in a helper function silently overwrites a same-named variable in the calling function, often only visible with generic names like i or tmp.
Deliberate globals
declare -g makes an intentionally global variable explicit in the code and distinguishes it from an accidentally missing local.