Closures in Python Explained by Tracing Through the Code
DEV Community

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.