7.3 Variable Scopes, the LEGB Rule, and Closures
Key Takeaways
- Python resolves identifier references sequentially using the LEGB rule: Local -> Enclosing -> Global -> Built-in.
- Any assignment to an identifier within a function binds it to the local scope during compilation, raising an UnboundLocalError if referenced prior to assignment.
- The global keyword rebinds module-level variables from local scope, while nonlocal rebinds identifiers in the nearest enclosing non-global function scope.
- A closure occurs when an inner function retains access to free variables from its enclosing lexical scope even after the outer function has completed execution.
- Closures capture variable names rather than value snapshots (late binding); freezing loop variables requires default parameter arguments (def f(x=i):).
Variable Scopes, the LEGB Rule, and Closures
In Python, the accessibility and lifetime of identifiers (variables, functions, classes) are governed by scoping rules and namespaces. When Python code references or modifies a variable, the interpreter uses a deterministic lookup hierarchy to locate the corresponding binding. Combining lexical scoping with Python's first-class functions gives rise to closures—a fundamental mechanism for data encapsulation and functional programming.
1. Namespace Hierarchy and the LEGB Resolution Rule
A scope is a textual region of a Python program where a namespace is directly accessible. When an identifier is referenced, Python searches up to four nested scopes in strict sequential order, known as the LEGB Rule:
Local (L) ──▶ Enclosing (E) ──▶ Global (G) ──▶ Built-in (B)
The Four Scopes Defined
- Local (L): Identifiers defined inside the currently executing function, method, or lambda (including formal parameters and locally assigned variables). Local scopes are created upon function invocation and destroyed upon function exit.
- Enclosing (E): Identifiers in the local scope of any enclosing (outer) functions from inner to outer. This exists in nested function definitions.
- Global (G): Identifiers defined at the top level of the current module file, or identifiers explicitly declared with the
globalkeyword inside a function. - Built-in (B): Identifiers provided in Python's standard
builtinsmodule, loaded automatically upon interpreter startup (e.g.,print,len,range,ValueError,int,open).
x = "GLOBAL" # Global Scope (G)
def outer():
x = "ENCLOSING" # Enclosing Scope (E) relative to inner()
def inner():
x = "LOCAL" # Local Scope (L)
print(x) # Resolves to LOCAL
inner()
print(x) # Resolves to ENCLOSING
outer()
print(x) # Resolves to GLOBAL
If an identifier is not found in any of the four LEGB scopes, Python raises a NameError.
2. Scope Mutation: The global and nonlocal Keywords
Reading a variable searches the LEGB hierarchy. However, assigning to a variable follows a completely different rule: by default, any assignment inside a function creates or rebinds a local variable in that function's namespace.
The Local Shadowing Trap and UnboundLocalError
When Python compiles a function, it scans the function body. If an identifier appears on the left-hand side of an assignment (=, +=, -=, etc.) anywhere in the function, Python marks that identifier as strictly local for the entire duration of the function.
counter = 0 # Global variable
def increment():
# Attempting to read counter, then assign to it
counter = counter + 1 # Raises UnboundLocalError!
# Calling increment() fails:
# UnboundLocalError: local variable 'counter' referenced before assignment
Because counter = ... is present, Python designates counter as local. When the right-hand side counter + 1 is evaluated, the local counter has not yet been assigned a value, triggering an UnboundLocalError.
The global Keyword
To rebind a module-level global variable from within a local function scope, declare the identifier using global:
counter = 0
def increment():
global counter # Instructs Python: bind 'counter' to the module global scope
counter += 1
increment()
increment()
print(counter) # 2 (Global variable successfully modified)
The nonlocal Keyword (Python 3)
The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing function scope (excluding the global module scope):
def outer():
count = 10
def inner():
nonlocal count # Rebinds 'count' in outer()'s scope
count += 5
print("Inner count:", count)
inner() # Inner count: 15
print("Outer count:", count) # Outer count: 15
outer()
Critical nonlocal Rules for the PCAP Exam
- Enclosing Scope Requirement: The identifier specified in
nonlocal xmust exist in an outer function scope. If no matching identifier is found in any enclosing function scope, Python raises a compile-timeSyntaxError: no binding for nonlocal 'x' found. - No Global Binding:
nonlocalcannot bind to module-level global variables. If a variable exists only in the global scope,nonlocalwill fail with aSyntaxError.
global_val = 100
def bad_scope():
# SyntaxError: no binding for nonlocal 'global_val' found
nonlocal global_val
global_val += 1
Scope Modification Comparison
| Keyword | Target Scope | Behavior on Missing Target | Applicable In |
|---|---|---|---|
| (none) | Local | Creates a new local variable | Functions, Lambdas |
global | Global (Module-level) | Creates a new global variable if not present | Functions, Lambdas |
nonlocal | Nearest Enclosing Function | Raises SyntaxError if not found | Nested Functions |
3. First-Class Functions and Higher-Order Patterns
In Python, functions are instances of types.FunctionType. They can be:
- Assigned to variables:
f = math.sqrt; print(f(25))$\rightarrow$5.0 - Passed as arguments:
map(func, seq) - Stored in data structures:
ops = {'+': add, '-': subtract} - Returned from other functions: Function factories and closures
def get_multiplier(factor):
def multiply(number):
return number * factor
return multiply # Returning the function object itself
4. Lexical Closures and Cell Objects
A closure is a function object that remembers and retains access to values in enclosing lexical scopes even after the enclosing scope has finished execution and returned.
Anatomical Requirements for a Closure
For a closure to exist, three conditions must be met:
- There must be a nested function (an inner function defined inside an outer function).
- The inner function must reference at least one free variable (a variable defined in the outer enclosing function).
- The outer function must return the inner function object.
def make_greeter(greeting):
# 'greeting' is in the enclosing scope of 'greet'
def greet(name):
return f"{greeting}, {name}!"
return greet
# outer function completes execution and returns
say_hello = make_greeter("Hello")
say_hola = make_greeter("Hola")
print(say_hello("Alice")) # Hello, Alice!
print(say_hola("Bob")) # Hola, Bob!
Internal Closure Mechanics: __closure__ and Cell Objects
How does say_hello remember "Hello" after make_greeter() has finished execution and its stack frame has been destroyed?
Python allocates a special cell object on the heap for any variable referenced by an inner function. The returned function object stores a tuple of these cell objects in its __closure__ attribute:
print(type(say_hello.__closure__)) # <class 'tuple'>
print(len(say_hello.__closure__)) # 1
cell = say_hello.__closure__[0]
print(cell.cell_contents) # 'Hello'
If a nested function does not reference any free variables from an enclosing scope, func.__closure__ is None.
5. The Late-Binding Loop Closure Trap and Fixes
One of the most frequently tested advanced scoping questions on the PCAP exam is the late-binding loop closure trap.
The Problem: Late Binding
In Python, closures bind to variable names, not the snapshot value of the variable at the moment the closure is created. The variable is looked up at the time the inner function is called (invoked), not when it is defined.
# TRAP: Creating closures in a loop
multipliers = []
for i in range(4):
multipliers.append(lambda x: x * i)
# When the loop finishes, i is 3
results = [m(2) for m in multipliers]
print(results) # [6, 6, 6, 6] (NOT [0, 2, 4, 6]!)
When m(2) executes, it looks up i in the enclosing scope. By the time any lambda is called, the loop has completed and i holds its final value: 3. Every lambda multiplies by 3 ($2 \times 3 = 6$).
The Fix: Freezing Values with Default Parameter Arguments
Default parameter values in Python are evaluated at function definition time, not invocation time. By binding the loop variable to a default argument, each lambda freezes the current loop value in its own local parameter namespace:
# SOLUTION: Freeze loop variable via default argument
fixed_multipliers = []
for i in range(4):
fixed_multipliers.append(lambda x, step=i: x * step)
results = [m(2) for m in fixed_multipliers]
print(results) # [0, 2, 4, 6] (Correct!)
During each loop iteration, step=i captures a snapshot of the current value of i ($0, 1, 2, 3$) as the default argument for that specific function instance.
What is the result of executing the following Python code?
val = 50
def compute():
val = val + 10
return val
print(compute())
Consider the following script:
x = 10
def outer():
def inner():
nonlocal x
x += 5
return x
return inner()
print(outer())
What is printed to standard output by the following Python program?
funcs = [lambda: i * 10 for i in range(3)]
output = [f() for f in funcs]
print(output)
What is the output of running the following Python counter program?
def make_accumulator(base):
total = base
def add(amount=1):
nonlocal total
total += amount
return total
return add
acc = make_accumulator(100)
print(acc(5), acc(10))