~ $ cat closures-why-your-functions-remember-things.md

Closures: why your functions remember things

June 28, 2026 8 min read languages · fundamentals · python

Write a little function that makes counters:

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

a = make_counter()
b = make_counter()

a()  # 1
a()  # 2
b()  # 1

a and b count independently — each one remembers its own count. But here’s the part that should bother you: by the time you call a(), make_counter has already returned. Its count variable should be gone; locals die when a function returns. Yet there it is, still incrementing, three calls later.

That’s a closure. And once you see what it’s actually holding onto, a surprising amount of everyday code stops looking like magic.

What a closure actually is

A closure is a function bundled together with the variables it uses from the scope around it.

That’s the whole definition. When you write a function that refers to a variable declared outside itself — count, above — the language can’t hand you the function on its own. The function is useless without that variable. So it captures it: the function and the outside variables it depends on travel together, as one unit. That unit is the closure.

(One Python wrinkle you can see in the code: reading a captured variable just works, but reassigning one — like count += 1 — needs the nonlocal keyword. Without it, Python sees an assignment and assumes you’re making a brand-new local. nonlocal says “no, I mean the captured one.” Languages differ on this detail; the capture itself is the universal part.)

The key word is captures, and we’ll come back to how literally it means it. First, the question that makes closures click: where does count actually live?

Where the captured variable lives

Normally, a local variable lives on the stack. You call a function, it gets a stack frame holding its locals; the function returns, the frame is popped, and the locals are gone. That’s the simple, fast rule — and it’s exactly the rule the counter seems to break.

Closures are the exception, and they pay for it. When the language sees that an inner function refers to count, it knows count has to outlive make_counter. So it doesn’t put count on the stack frame that’s about to vanish. It puts it on the heap — the memory that lives until nothing references it anymore — and the returned function keeps a reference to it.

So a and b each came from their own run of make_counter, which means each got its own count on the heap. They share nothing. That’s why they count independently: two separate captured variables, two separate functions holding onto them.

This is also why closures aren’t free. A captured variable can’t be cleaned up when its function returns, because something still points at it. It stays alive as long as the closure does. Usually that’s exactly what you want. Occasionally it’s a leak you didn’t mean to make — a giant object kept alive by a tiny callback that happened to mention it.

Why they exist at all

Closures aren’t a feature someone bolted on for fun. They fall out, almost inevitably, of one decision: making functions first-class values — things you can return from other functions, pass as arguments, and store in variables.

The moment functions can be passed around, they start outliving the scope that created them. You return a function from make_counter and call it later, somewhere else entirely. But a function that referenced count still needs count to mean something when it finally runs. There are only two ways out. Either you ban functions from referring to anything outside themselves — which makes them far less useful — or you let the referenced variables come along for the ride. Closures are the second choice. They’re the price, and the reward, of treating functions as values.

That’s why you find closures in language after language — Python, JavaScript, Swift, Go, Rust, Lisp — and not by coincidence. Any language with first-class functions hits the same problem and reaches for the same answer.

What closures let you build

Closures look like a curiosity until you notice how much they quietly hold up.

Private state. The count in make_counter can’t be read or changed from outside. There’s no syntax to reach it — the only door in is the function you returned. That’s real encapsulation, built from nothing but scope. In Python it’s actually more private than a class attribute: a leading underscore, or even a name-mangled __count, is private only by convention, but a closed-over variable has no public name at all.

Callbacks that remember context. Schedule Timer(5, lambda: save(doc)) and the saved-off function runs five seconds later — long after the surrounding function returned — and it still knows which doc you meant. That’s a closure capturing doc. The same thing happens with a button wired to command=lambda: save(doc), a sort key like sorted(rows, key=lambda r: r.score), or any callback you hand to a framework. A callback that couldn’t remember the variables around it would be nearly useless.

Function factories. make_counter is one. So is a function that takes a multiplier and returns a function that multiplies by it:

def multiply_by(n):
    return lambda x: x * n

double = multiply_by(2)
triple = multiply_by(3)

double(10)  # 20
triple(10)  # 30

double and triple are the same function body with a different captured n. You build specialized functions out of general ones, with the specialization stored in the closure. The same idea shows up as memoization (a cache captured in a closure), partial application, and decorators that wrap a function while remembering it — all of them this one mechanism wearing different hats.

The bug everyone hits once

Closures capture variables, not values — and the difference bites in a loop.

funcs = []
for i in range(3):
    funcs.append(lambda: i)

[f() for f in funcs]  # [2, 2, 2] — not [0, 1, 2]

You expected [0, 1, 2]. You got [2, 2, 2]. The reason is exactly the definition: each lambda captured the variable i, not the value i had at that instant. The loop reuses one i the whole way through — and in Python it even outlives the loop. By the time you call the functions, the loop is long done and that single shared i is stuck at its final value, 2. All three closures look at the same variable and read the same number.

Python has no block-scoped let to reach for here, so the usual fix is to bind the value as a default argument:

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)

[f() for f in funcs]  # [0, 1, 2]

lambda i=i: i looks strange, but it’s doing something precise: a default argument is evaluated now, at definition time, so each lambda freezes its own copy of the current i instead of sharing the one loop variable. (A factory function — a small def that takes i as a parameter and returns the closure — does the same job if you find the default-argument trick too cute.) Whenever a closure seems to “see the wrong value,” this is almost always why: it’s holding a variable that kept changing after it was captured.

The whole idea, in one line

Strip away the syntax and a closure is a small, sturdy thing: a function plus the part of its birthplace it still needs. The language keeps that piece of the past alive for exactly as long as the function might use it, and not a moment longer.

You were almost certainly using closures before you had a name for them — every callback that remembered a variable, every handler that knew which thing you clicked. The name just lets you reason about it on purpose: what is this function holding onto, where does that live, and how long will it stay? Once you can answer those three, closures stop being magic and start being a tool.

← all writing