Python Closures and Scoping Rules Explained
Understand how Python closures capture variables, the LEGB scope resolution rule, and common pitfalls like late binding in loops.
What you'll learn
- ✓How the LEGB rule determines variable lookup order
- ✓What closures are and how they capture enclosing state
- ✓The late-binding gotcha in loops and how to fix it
- ✓Using nonlocal and global correctly
Prerequisites
None — this post is self-contained.
Closures are one of Python’s most useful features, but they confuse developers who do not understand scoping rules. A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished executing. This article explains how Python resolves names, how closures capture variables, and the traps you need to avoid.
The LEGB Rule
When Python encounters a name, it searches four scopes in order:
- Local — names defined inside the current function
- Enclosing — names in any enclosing function (for nested functions)
- Global — names defined at the module level
- Built-in — names in the
builtinsmodule (print,len, etc.)
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local" (L)
inner()
print(x) # "enclosing" (E from outer's perspective, L from outer's own)
outer()
print(x) # "global" (G)
If you remove the local assignment in inner, Python finds x in the enclosing scope:
def outer():
x = "enclosing"
def inner():
print(x) # "enclosing" -- found in E scope
inner()
What Is a Closure?
A closure occurs when a nested function references a variable from its enclosing scope, and the enclosing function has returned. The inner function “closes over” the variable, keeping it alive.
def make_multiplier(factor):
def multiply(x):
return x * factor # 'factor' is captured from enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
After make_multiplier returns, factor would normally be garbage collected. But because multiply references it, Python keeps it alive in the closure.
Inspecting Closures
You can see what a closure captures:
print(double.__closure__)
# (<cell at 0x...: int object at 0x...>,)
print(double.__closure__[0].cell_contents)
# 2
print(double.__code__.co_freevars)
# ('factor',)
Closures Capture Variables, Not Values
This is the most important thing to understand. Closures capture the variable itself, not its value at the time of capture. The variable is looked up when the closure is called, not when it is defined.
def make_functions():
functions = []
for i in range(5):
def f():
return i # Captures the variable 'i', not its current value
functions.append(f)
return functions
funcs = make_functions()
print([f() for f in funcs])
# [4, 4, 4, 4, 4] -- NOT [0, 1, 2, 3, 4]
All five functions share the same i variable, and by the time they are called, the loop has finished and i is 4.
Fix 1: Default Argument Binding
Force early binding by capturing the current value as a default argument:
def make_functions():
functions = []
for i in range(5):
def f(i=i): # Default arg captures current value of i
return i
functions.append(f)
return functions
print([f() for f in make_functions()])
# [0, 1, 2, 3, 4]
Fix 2: Factory Function
Create a new scope for each iteration:
def make_functions():
def make_f(i):
def f():
return i # Each f closes over its own 'i'
return f
return [make_f(i) for i in range(5)]
print([f() for f in make_functions()])
# [0, 1, 2, 3, 4]
Fix 3: functools.partial
from functools import partial
def identity(x):
return x
funcs = [partial(identity, i) for i in range(5)]
print([f() for f in funcs])
# [0, 1, 2, 3, 4]
The nonlocal Keyword
By default, assigning to a variable inside a function creates a new local variable. To modify a variable in the enclosing scope, use nonlocal:
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
def get():
return count
return increment, get
inc, get = counter()
print(inc()) # 1
print(inc()) # 2
print(inc()) # 3
print(get()) # 3
Without nonlocal, the line count += 1 would raise UnboundLocalError because Python sees the assignment and treats count as local.
nonlocal vs global
total = 0
def outer():
total = 10
def inner_nonlocal():
nonlocal total # Refers to outer's 'total'
total += 1
def inner_global():
global total # Refers to module-level 'total'
total += 1
inner_nonlocal()
print(f"outer's total: {total}") # 11
inner_global()
outer()
print(f"module total: {total}") # 1
nonlocal targets the nearest enclosing scope. global skips all enclosing scopes and goes straight to the module level.
Practical Closure Patterns
Memoization
def memoize(func):
cache = {} # Captured by the closure
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # 832040 (fast, thanks to caching)
Configuration Factories
def create_logger(prefix, level="INFO"):
def log(message):
print(f"[{level}] {prefix}: {message}")
return log
db_log = create_logger("DATABASE")
api_log = create_logger("API", level="DEBUG")
db_log("Connection established") # [INFO] DATABASE: Connection established
api_log("Request received") # [DEBUG] API: Request received
Encapsulating State
Closures can replace simple classes when you only need state and one or two functions:
def rate_limiter(max_calls, period_seconds):
import time
calls = []
def allow():
nonlocal calls
now = time.time()
calls = [t for t in calls if now - t < period_seconds]
if len(calls) < max_calls:
calls.append(now)
return True
return False
return allow
limiter = rate_limiter(max_calls=3, period_seconds=10)
print(limiter()) # True
print(limiter()) # True
print(limiter()) # True
print(limiter()) # False
Key Takeaways
Python resolves names using the LEGB rule: Local, Enclosing, Global, Built-in. Closures capture variables by reference, not by value, which causes the classic loop-binding bug. Use default arguments or factory functions to force early binding. Use nonlocal to modify enclosing variables and global only when you genuinely need module-level state. Closures are the foundation of decorators, callbacks, and many functional patterns in Python.
Related articles
- Python Lambda Functions in Python: When and Why
A practical guide to Python lambda functions — syntax, use with sorted, map, and filter, common patterns, and the situations where a named def is the clearer choice.
- Python Default and Keyword Arguments in Python
A practical guide to Python function arguments — defaults, keyword arguments, *args, **kwargs, positional-only and keyword-only parameters, and the mutable default gotcha.
- Python Functions in Python: def, return, and Arguments
A practical guide to Python functions — defining with def, returning values, arguments and parameters, docstrings, and the habits that make functions worth reusing.
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.