Closures in Python Explained

python closures explained simply: it's a nested function that remembers values from the scope it was created in, even after that outer scope has technically finished running. This is one of those concepts that sounds abstract until you see it in action — and once it clicks, you'll start recognizing it everywhere, including inside every decorator you've ever used.

What Is a Closure?

A closure is a nested (inner) function that remembers and can access variables from its enclosing function's scope — even after that enclosing function has already returned and, in principle, finished executing.

def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

hello_greeter = make_greeter("Hello")
print(hello_greeter("Alex"))   # Hello, Alex!

Notice something genuinely strange here: make_greeter("Hello") already finished running by the time hello_greeter("Alex") gets called. And yet, greet() still remembers greeting, correctly using "Hello" — a value that belonged to a function call that's long since ended.

A real-world framing

Think of a closure like a function carrying a small backpack of values from the place it was created. Wherever that function gets used later, it still has access to whatever it packed at creation time — regardless of whether the place it came from still exists.

The three requirements

For a closure to exist, three conditions need to be met:

  1. A nested function — a function defined inside another function.

  2. A free variable — the inner function references a variable from the enclosing function's scope (covered in detail next).

  3. The outer function returns the inner function — rather than just calling it internally.

Miss any one of these, and you don't actually have a closure — just a regular nested function call, with nothing captured for later use.

How Closures Actually Work Under the Hood

Free variables

A "free variable" is a name used inside a function but not defined there — Python has to look elsewhere to resolve it. In the example above, greeting is a free variable from greet()'s perspective: it's not a parameter or local variable of greet() itself, but greet() still uses it, resolving it from the enclosing scope of make_greeter().

This connects directly to the LEGB scope resolution rule covered in the earlier variable scope article — greeting gets found specifically in the Enclosing scope, one level out from greet()'s own Local scope.

Why the variable survives after the outer function returns

Normally, a function's local variables are discarded once that function finishes running — that's exactly how local scope works, as covered in the scope article. But when an inner function captures a variable from its enclosing scope, Python does something different: it stores that variable in a special internal object (called a "cell"), which the inner function keeps a reference to. Because the inner function still holds that reference, the variable isn't garbage-collected along with the rest of the outer function's now-finished local scope — it persists for as long as the inner function itself exists.

Peeking under the hood

You can actually inspect what a closure has captured, purely as a debugging curiosity — not something you'd typically do in everyday code:

def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

hello_greeter = make_greeter("Hello")

print(hello_greeter.__code__.co_freevars)   # ('greeting',) — the names of captured variables
print(hello_greeter.__closure__)             # (<cell at 0x...: str object at 0x...>,)
print(hello_greeter.__closure__[0].cell_contents)   # Hello — the actual captured value

__code__.co_freevars shows the names of the captured variables, and __closure__ gives you access to the actual cell objects holding their current values — genuinely useful when you're trying to understand or debug closure behavior, though not something you'd write in typical application code.

Practical Examples: Factory Functions and Stateful Counters

Factory function pattern

A "factory function" is one that generates and returns other, specialized functions — closures are exactly what makes this pattern work:

def make_multiplier(factor):
    def multiplier(number):
        return number * factor
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15

double and triple are two entirely separate closures, each remembering a different value of factor — even though both were created from the exact same make_multiplier function. This is a genuinely practical pattern: generating a family of related, pre-configured functions without duplicating code for each one.

Stateful closures: a counter

Closures can also maintain and update their own internal state across multiple calls:

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()
print(counter())   # 1
print(counter())   # 2
print(counter())   # 3

Each call to counter() genuinely remembers and builds on the result of the previous call — count persists across calls, held alive by the closure, exactly the same underlying mechanism as the greeting example earlier.

Reading vs. reassigning: the nonlocal requirement

This connects directly to the earlier variable scope article: reading a captured variable from within the inner function requires no special keyword at all. But reassigning it — as the counter example does with count += 1 — requires the nonlocal keyword, for exactly the same reason nonlocal was needed in the general scope discussion:

def make_counter():
    count = 0

    def broken_increment():
        count += 1   # missing 'nonlocal' — this fails
        return count

    return broken_increment

counter = make_counter()
counter()
# UnboundLocalError: local variable 'count' referenced before assignment

Without nonlocal, Python treats count += 1 as creating a brand-new local variable inside broken_increment(), exactly the same shadowing problem covered in detail in the scope article — not modifying the captured one from the enclosing scope at all.

The Classic Pitfall: Late Binding in Loops

The trap

This is one of the most well-known "gotchas" in Python, and it genuinely surprises even experienced developers the first time they hit it: creating multiple closures inside a loop, where each one is meant to capture a different value of the loop variable, but they all end up sharing the same final value instead.

functi []
for i in range(3):
    def multiplier():
        return i * 10
    functions.append(multiplier)

for f in functions:
    print(f())
# 20
# 20
# 20

You might expect 0, 10, 20 — one for each value i took on during the loop. Instead, all three closures print 20, because they all captured the same variable i, not a snapshot of its value at the time each function was created.

Why this happens

Closures look up their free variables when they're actually called, not when they're defined. This is called late binding. By the time any of the three multiplier() functions in the example above actually get called, the loop has already finished entirely, and i holds its final value — 2 — for all three closures simultaneously, since they're all referencing the exact same underlying variable, not three separate captured copies.

The fix: forcing early binding with a default argument

The standard fix is to force the current value to be captured immediately, at definition time, using a default argument — since default argument values, as covered in the earlier function arguments article, are evaluated once, at definition time, not at call time:

functi []
for i in range(3):
    def multiplier(i=i):   # the default 'i=i' captures the CURRENT value of i, right now
        return i * 10
    functions.append(multiplier)

for f in functions:
    print(f())
# 0
# 10
# 20

That i=i looks a little odd at first glance, but it does exactly what's needed: it creates a genuinely new, independent parameter called i, whose default value is fixed to whatever the loop variable i happened to be at the exact moment that particular function was defined — rather than staying tied to the loop variable itself for the rest of the loop's execution.

Real-World Uses and When to Reach for a Class Instead

Where closures show up constantly

Once you know what to look for, closures turn up everywhere in real Python code:

  • Decorators — the timer decorator shown in the earlier *args/**kwargs article is a closure: the inner wrapper() function captures func from the enclosing scope.

  • Callbacks and event handlers — a function generated to respond to a specific event, carrying whatever context it needs from where it was created.

  • Encapsulating state without global variables — the counter example above is a genuine, practical way to maintain state across calls without resorting to a module-level global variable, which (as covered in the earlier scope article) tends to make code harder to reason about.

The data-hiding benefit

Closures offer something conceptually similar to private attributes in object-oriented programming: the captured variable (count, in the counter example) genuinely isn't accessible from outside the closure through any normal means — there's no counter.count you can read or modify directly. The only way to interact with it at all is through the function's own defined behavior, which is a legitimate, lightweight form of encapsulation.

When a class might be clearer instead

That said, closures aren't always the right tool. Once the captured state grows more complex, or you need multiple related functions all sharing and operating on that same state, a class — particularly one implementing __call__ to make instances directly callable — often reads more clearly than a deeply nested function structure:

# As a closure — fine for one simple behavior
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

# As a class — clearer once you need more than one operation on the shared state
class Counter:
    def __init__(self):
        self.count = 0

    def increment(self):
        self.count += 1
        return self.count

    def reset(self):
        self.count = 0

counter = Counter()
print(counter.increment())   # 1
print(counter.increment())   # 2
counter.reset()
print(counter.increment())   # 1

The class version scales more gracefully the moment you need a second operation (like reset()) — trying to bolt a second nested function onto the closure pattern, sharing the same captured state, quickly becomes awkward. As a rule of thumb: reach for a closure when you need one specific, self-contained behavior with some captured context; reach for a class once you need multiple related behaviors operating on shared state together.