Closures in Python Explained by Tracing Through the Code
Most explanations of closures start with a definition. This one starts with code. Understanding what a closure actually is by watching one form step by step.
Read this and tell me what it prints:
def outer():
x = 10
def inner():
print(x)
return inner
func = outer()
func()
If you said 10, you are right. But the more interesting question is why it prints 10 when outer has already finished executing and its local scope should be completely gone. That question is the entire point of closures.
What a Closure Actually Is
When outer returns the inner function, Python notices that inner references a variable from the enclosing scope (x). Rather than letting that variable disappear when outer's stack frame is destroyed, Python keeps it alive by attaching it directly to the inner function object. This combination - a function plus the variables it has captured from an enclosing scope - is called a closure.
You can see the captured variables directly by inspecting the function:
func = outer()
print(func.__closure__) # Output: (<cell object at 0x...>,)
print(func.__closure__[0].cell_contents) # Output: 10
The variable x lives inside a cell object attached to the function. It persists as long as the function object exists, regardless of what happened to the original scope where it was defined.
Tracing a Closure Step by Step
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter())
print(counter())
print(counter())
Let us trace each execution step:
| Call | count before | operation | count after | returns |
|---|---|---|---|---|
counter() |
0 | count += 1 |
1 | 1 |
counter() |
1 | count += 1 |
2 | 2 |
counter() |
2 | count += 1 |
3 | 3 |
Output:
1
2
3
The variable count persists between calls because it lives in the closure cell, not in a fresh local scope. Each call to counter accesses and modifies the exact same underlying count object.
The Late Binding Trap
This is the closure behavior that catches the most developers off guard during technical screens.
functions = []
for i in range(5):
def func():
return i
functions.append(func)
print([f() for f in functions])
Most people expect [0, 1, 2, 3, 4]. Actual output: [4, 4, 4, 4, 4].
The reason: all five functions close over the same variable i in the enclosing scope, not over the value of i at the time each function was created. By the time the functions are called, the loop has finished and i is 4. All five functions look up i and find 4.
The fix is to capture the current value as a default argument:
functions = []
for i in range(5):
def func(i=i):
return i
functions.append(func)
print([f() for f in functions]) # Output: [0, 1, 2, 3, 4]
Default arguments are evaluated at function definition time, not at call time. This means each function captures the current value of i at that specific iteration as its own private default parameter.
Two Closures, One Shared State
def make_account(balance):
def deposit(amount):
nonlocal balance
balance += amount
return balance
def withdraw(amount):
nonlocal balance
balance -= amount
return balance
return deposit, withdraw
dep, with_ = make_account(100)
print(dep(50))
print(with_(30))
print(dep(20))
Both deposit and withdraw close over the same balance variable. Changes made through one function are completely visible to the other.
Output:
150
120
140
This pattern is a simple way to create stateful objects without using formal classes. It is also a pattern that appears in interviews specifically because the shared state behavior surprises people who think of closures as isolated instances.
Three Interview Problems on Closures
Problem 1
def multiplier(n):
def multiply(x):
return x * n
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(5))
print(triple(5))
print(double(triple(2)))
Output:
10
15
12
Each call to multiplier creates a new closure with its own independent n. double has n=2, and triple has n=3. The evaluation double(triple(2)) processes triple(2) first which gives 6, then double(6) which gives 12.
Problem 2
def outer(x):
def inner(y):
def innermost(z):
return x + y + z
return innermost
return inner
result = outer(1)(2)(3)
print(result)
Output:
6
Three nested closures each capturing one variable from their enclosing scope. The innermost function has immediate access to all three layers. 1 + 2 + 3 = 6.
Problem 3
adders = [lambda x, n=n: x + n for n in range(4)]
print([f(10) for f in adders])
Output:
[10, 11, 12, 13]
The n=n pattern captures the current value of n at each iteration. Without it, you would get [13, 13, 13, 13] due to the late binding trap.
Practice Coding Exercises
Closures are one of the more frequently tested Python concepts in mid to senior level interviews because they reveal whether a candidate understands name lookup, scope boundaries, and object lifetime at a deeper level than surface syntax.
Practice tracing closure problems and predicting their exact terminal output at PyCodeIt. The platform serves up custom dry-run code logic to help you build true pattern recognition. Test your skills at pycodeit.com.
Written by the developer behind PyCodeIt, a free AI-powered Python dry-run practice platform for technical interview preparation.
Comments
No comments yet. Start the discussion.