What is a closure in Python, and what problem does it solve?
A closure is a function that retains access to variables from its enclosing scope even after that scope has finished executing. It is the mechanism behind decorators, factory functions, and stateful callbacks without needing a class.
How to think about it
A closure is Python’s way of packing a function together with the bit of state it needs to do its job. The outer function runs, returns an inner function, and then vanishes — yet the inner function keeps a live grip on the variables it borrowed from that outer scope. That captured state, outliving the place it was born, is the whole idea.
Interviewers reach for closures to check two things at once: whether you understand Python’s scoping — the LEGB ladder, Local then Enclosing then Global then Built-in — and whether you see functions as ordinary objects you can build and hand back. The follow-up is almost always the late-binding trap in a loop, so let’s walk straight into it.
A worked example
A factory takes a parameter, defines an inner function that uses it, and returns that inner function. Each call to the factory makes a fresh, independent closure — which is why double and triple never tread on each other. You can even peek at what was captured: Python keeps the borrowed variables in __closure__ as cell objects.
def make_multiplier(factor):
def multiply(x):
return x * factor # factor is a free variable, borrowed from outside
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print("double(5):", double(5))
print("triple(5):", triple(5))
print("Captured in double:", double.__closure__[0].cell_contents)
print("Captured in triple:", triple.__closure__[0].cell_contents)
# A configurable logger — the same pattern doing real work
def make_logger(prefix):
def log(msg):
print(f"[{prefix}] {msg}")
return log
info = make_logger("INFO")
error = make_logger("ERROR")
info("model trained")
error("NaN detected in batch")
# The trap: in a loop, every closure captures the SAME variable i
fns_broken = [lambda: i for i in range(3)]
print("Broken (all see final i):", [f() for f in fns_broken])
# The fix: pin the current value as a default argument
fns_fixed = [lambda i=i: i for i in range(3)]
print("Fixed (each owns its i):", [f() for f in fns_fixed])
double(5): 10
triple(5): 15
Captured in double: 2
Captured in triple: 3
[INFO] model trained
[ERROR] NaN detected in batch
Broken (all see final i): [2, 2, 2]
Fixed (each owns its i): [0, 1, 2]
Why it works
The borrowed variable lives in a cell — a small shared box reachable from both the outer and the inner scope. As long as the inner function is alive, that cell keeps its value alive too, safe from the garbage collector. And because every call to the factory allocates a brand-new cell, double and triple end up genuinely separate. That one fact — a new cell per factory call — explains both why closures remember and why they don’t collide.