Python Closures: Lexical Scope, Late Binding, and Decorators
Learn how Python closures work: nested functions, lexical scope, the nonlocal keyword, the late-binding loop trap, and how decorators use closures under the hood.
A Python closure is an inner function that retains access to variables from its enclosing scope, even after the outer function has returned.
That one sentence covers the definition. What makes closures interesting, and what trips up students in campus placement coding interviews, is why that works and where it breaks.
What Makes a Function a Closure
Three conditions must all be true:
- The function is defined inside another function.
- It references at least one variable from the enclosing function’s scope (not global, not its own local).
- The outer function returns or stores the inner function for later use.
Miss any one of these and you have a regular nested function, not a closure. Here is the simplest possible example:
def outer(x):
def inner(y):
return x + y # x comes from the enclosing scope
return inner
add_ten = outer(10)
print(add_ten(5)) # 15
print(add_ten(3)) # 13
add_ten is a closure. x lives inside outer, but add_ten retains a reference to it after outer has finished. Python stores that reference in the dunder attribute __closure__ on the returned function object, as a tuple of cell objects with one cell per captured variable.
You can inspect it directly:
print(add_ten.__closure__) # (<cell at 0x...>,)
print(add_ten.__closure__[0].cell_contents) # 10
If a function has no captured variables, __closure__ is None. That is the quick diagnostic to confirm whether a function is actually a closure or a regular nested function.
How LEGB Scoping Makes Closures Possible
Python resolves names using the LEGB rule, documented in the Python language reference on naming and binding:
- Local — names bound in the current function
- Enclosing — names in any surrounding function (this is where closures hook in)
- Global — module-level names
- Built-in — names from
builtins
When inner references x, Python walks up the chain and finds it in the Enclosing slot. The runtime then holds that binding alive inside a cell object. The enclosing scope is not garbage-collected while the closure exists, because the cell keeps a live reference to the captured variable.
Without this mechanism, returning an inner function would leave it with dangling references to an already-collected frame. The cell is what makes closures predictable across call boundaries.
Consider a slightly extended example to see the Enclosing slot in action:
def make_multiplier(factor):
def multiply(x):
return x * factor # factor lives in the Enclosing slot
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(double(7)) # 14
Each call to make_multiplier creates a fresh closure with its own cell holding a different value of factor. The two closures do not share state.
The nonlocal Keyword: Mutating Enclosing Variables
Reading an enclosing variable is automatic. Reassigning it is not.
def counter():
count = 0
def increment():
count += 1 # UnboundLocalError
return count
return increment
The assignment count += 1 tells Python that count is local to increment. But it is read before being assigned, which raises UnboundLocalError. This is a common placement interview trap: the code looks correct at first glance.
The fix, introduced in PEP 3104, is the nonlocal keyword:
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c = counter()
print(c()) # 1
print(c()) # 2
print(c()) # 3
nonlocal count tells Python to find count in the enclosing scope and rebind it there rather than creating a new local. The state is completely private. Nothing outside counter() can read or reset count directly.
This counter pattern shows up in placement interviews at both service-tier and product-tier companies. The interviewer presents a function that returns a stateful callable, then asks for the output sequence. Knowing that nonlocal is what enables rebinding, and that without it the function raises an error, is the entire answer.
One practical note: nonlocal does not work with global variables. It only reaches one level up at a time to the nearest enclosing scope. If you need to rebind a module-level name, use global instead.
The Late-Binding Gotcha
Late binding is the single most common closure bug, and it surfaces almost every time closures appear in placement coding rounds.
funcs = [lambda: i for i in range(5)]
print(funcs[0]()) # Expect 0, get 4
print(funcs[3]()) # Expect 3, get 4
All five lambdas return 4. Each lambda captures the variable i, not its value at creation time. By the time any lambda is called, the loop has finished and i is 4.
The fix: capture the current value as a default argument.
funcs = [lambda i=i: i for i in range(5)]
print(funcs[0]()) # 0
print(funcs[3]()) # 3
Default arguments are evaluated at function definition time, not call time. Each lambda gets its own snapshot of i baked in as a default. The distinction between definition-time and call-time evaluation is exactly what the interviewer is testing.
The same trap appears with def statements inside loops, not just lambdas:
def make_funcs():
funcs = []
for i in range(3):
def f():
return i # late binding — all three will return 2
funcs.append(f)
return funcs
The default-argument fix works here too: def f(i=i): return i.
Students who have built fluency with Python function composition patterns tend to spot this trap faster. The Python example programs collection on FACE Prep covers the foundational patterns that make closure questions tractable in timed placement drives.
Closures as Decorator Scaffolding
Every Python decorator is a closure. The @ syntax is compact, but the mechanics are identical to the counter and multiplier examples above.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print("Done")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3)
# Calling add
# Done
wrapper is a closure. It closes over func, which belongs to the log_calls scope. The @log_calls syntax is shorthand for add = log_calls(add). When add is called, wrapper runs, finds func in its enclosing scope, and delegates to it.
@functools.wraps(func) copies the original function’s metadata onto wrapper. Without it, the closure would shadow the wrapped function’s name in tracebacks and documentation tools; add.__name__ would be "wrapper" instead of "add".
You can stack decorators. Each decorator wraps the previous one in a new closure. The innermost function ends up captured by each outer wrapper in sequence.
When a Closure Beats a Class
A closure is the right tool when you need one callable with a small amount of private state. Compare the counter above to its class equivalent:
class Counter:
def __init__(self):
self._count = 0
def __call__(self):
self._count += 1
return self._count
Both work. The closure is fewer lines and has genuinely private state: there is no ._count attribute to inspect or reset from outside. The class is easier to extend: add a reset() method, expose the count for logging, or subclass it for specialised behaviour.
A calculator program in Python illustrates this trade-off. A single-operation accumulator fits neatly in a closure. Once you add undo, memory recall, or a display log, the class structure becomes worth the extra code.
The decision rule is simple: how many operations does this state need to support? One operation, one callable: use a closure. Multiple operations or external access needed: use a class.
The same private-state guarantee that makes the closure counter useful, hiding a running count that nothing outside can reset, is the pattern for building LLM tool chains: encapsulating an API key, tracking a token budget, or wrapping a retry policy around a model call. If you want to apply Python patterns like these in a real project rather than a practice exercise, TinkerLLM (₹299 entry) is where that counter and decorator pattern meets actual API calls.
Primary sources
Frequently asked questions
What are the three conditions for a Python closure?
The function must be nested inside another function, it must reference at least one variable from the enclosing scope (not global, not its own local scope), and the outer function must return or store the inner function for later use.
What is the nonlocal keyword in Python?
nonlocal tells Python that a name belongs to an enclosing (non-global) scope. Without it, any assignment to that name creates a new local variable, which raises an UnboundLocalError if the variable is read before that assignment.
Why do closures in a for loop all return the same value?
Because closures use late binding: the variable is looked up at call time, not at the moment the closure is created. By the time any closure runs, the loop variable holds its final value. Fix: capture each iteration value as a default argument.
What is the __closure__ dunder attribute in Python?
Every closure has a __closure__ attribute, which is a tuple of cell objects, one per captured variable. Each cell holds a live reference to the enclosed variable. If a function has no captured variables, __closure__ is None.
Are Python decorators closures?
Yes. A decorator wraps a function inside another function (the wrapper), which closes over the original function and any added logic, then returns the wrapper. That returned wrapper is a closure.
When should I use a closure instead of a class?
Use a closure when you need a single callable with a small amount of private state, such as a counter, a rate limiter, or a multiplier. Use a class when the state is complex, needs multiple methods, or needs to be introspected or reset externally.
Can closures cause memory leaks in Python?
Potentially. If a closure retains a reference to a large object in its enclosing scope, that object stays in memory as long as the closure exists. Use the tracemalloc module or objgraph to diagnose closure-related memory retention.
A self-paced playground for building with LLMs.
TinkerLLM is FACE Prep's sister property. A guided environment for shipping real LLM applications, the kind of project that earns a paragraph on your resume, not a line.
Try TinkerLLM (₹299 launch)