21.6 Scope Pitfalls: UnboundLocalError and Late-Binding Closures
One of the most common and initially confusing issues encountered by Python programmers is the UnboundLocalError. This error arises from a misunderstanding of how Python’s scoping rules interact with variable assignment. The core principle is that any variable to which a value is assigned anywhere within a function body is treated as a local variable for the entire scope of that function, unless explicitly declared otherwise.
The UnboundLocalError Explained
This error occurs when a local variable is referenced before it has been assigned a value. The confusion often stems from the fact that a variable of the same name exists in an enclosing scope, leading the programmer to believe the inner function will use that value. However, because an assignment exists later in the function, Python binds the variable name to the local scope from the outset. When the code tries to read from this local variable before it has been assigned, the UnboundLocalError is raised.
x = 10 # Global variable
def my_func():
print(x) # Trying to read the global 'x'
x = 20 # This assignment makes 'x' a local variable
my_func()
Output:
UnboundLocalError: local variable 'x' referenced before assignment
The Python compiler sees the assignment x = 20 inside my_func() and consequently classifies x as a local variable for the entire function block. The print(x) statement then tries to access this local variable before it has been assigned, causing the error. This happens at runtime, but the scoping decision is made at compile time.
Resolving with the global and nonlocal Statements
To fix an UnboundLocalError where you intend to modify a variable from an outer scope, you must explicitly declare your intent to the Python interpreter using the global or nonlocal keywords.
The global keyword is used to declare that a variable belongs to the global (module-level) scope.
x = 10 # Global variable
def my_func():
global x # Explicitly declare we are using the global 'x'
print(x) # Now this reads from the global scope
x = 20 # And this modifies the global variable
my_func()
print(x) # Output: 20
The nonlocal keyword is used in nested functions to declare that a variable belongs to the nearest enclosing scope that is not global. This is essential for updating variables in enclosing (but non-global) scopes.
def outer():
count = 0 # Variable in the enclosing (non-global) scope
def inner():
nonlocal count # Declare we are using the 'count' from outer()
count += 1 # Now we can modify it
return count
return inner
counter = outer()
print(counter()) # Output: 1
print(counter()) # Output: 2
Late Binding Closures in Loops
A related and notoriously tricky pitfall involves the creation of closures within loops. The issue is one of late binding: the inner function doesn’t capture the current value of the variable from the outer scope at definition time; instead, it binds the variable’s name, and looks up its value when the function is called. In a loop, this often means all created functions end up referencing the same variable, which has its final value from the loop.
functions = []
for i in range(3):
def inner():
return i
functions.append(inner)
# What do you expect this to print?
for f in functions:
print(f()) # Output: 2, 2, 2
All three inner functions are bound to the same variable i. By the time they are called, the loop has completed and the value of i is 2.
Solving Late Binding with Default Arguments
The most common and Pythonic solution to the late-binding closure problem is to use default arguments. Default arguments are evaluated at the time the function is defined, not when it is called. This effectively captures the value of the variable at that moment in the loop.
functions = []
for i in range(3):
def inner(x=i): # 'i' is evaluated and stored as the default value for 'x'
return x
functions.append(inner)
for f in functions:
print(f()) # Output: 0, 1, 2
By using x=i, we are creating a new parameter x whose default value is the current value of i. The inner function now returns x, which is a local variable with a value fixed at the time of the function’s creation. This pattern is clean, readable, and effectively breaks the late-binding closure by creating an early-binding snapshot of the desired value.
Best Practices and Key Takeaways
- Be Explicit: Always use the
globalornonlocalkeyword if you intend to modify a variable from an outer scope. Relying on the lookup order for reading is fine, but modification requires an explicit declaration. - Avoid Shadowing: Be cautious when reusing variable names from outer scopes. Choosing a different, more descriptive name for a local variable can often prevent
UnboundLocalErrorand make code clearer. - Understand Binding: Remember that closures bind to variable names, not values. When creating functions dynamically in loops or conditional blocks, consider if you need to capture the current value. The default argument trick (
arg=value) is the standard solution for this. - Compiler vs. Runtime: Recognize that the scoping of a variable (whether it is considered local) is determined when the function is compiled. The error manifests at runtime when the code path attempts to access an unassigned local variable.