Closures
A function value that refers to a variable from an enclosing scope keeps that variable alive after the enclosing frame is gone. The language question is what the closure captures; the compiler question is where the captured variable now lives.
If a function returns a lambda that uses a local variable, where does that variable live after the function returns?
A function value that is no longer just a code pointer. At the source level it is a lambda plus the set of *free variables* it mentions — names that are neither parameters nor locals of the lambda, computed by the resolver as a by-product of name resolution. At the implementation level it is a pair: code, plus somewhere to find those free variables. This lesson is about why the pair is forced; [[closure-conversion]] is about how the pair is built.
Any representation is legal so long as every read of a captured variable through the closure observes the same value the language's scoping rules say it should, for the whole time the closure remains reachable. That forbids leaving a captured variable in a stack frame that can be destroyed while the closure is still alive, and — in a language that captures by reference — forbids copying it, since a copy would stop observing later writes. A compiler may keep a captured variable on the stack only if it can prove the closure cannot outlive the frame, which is [[escape-analysis]].
Key points
- A closure is a function value plus access to the free variables it mentions from enclosing scopes.
- The free-variable set is a by-product of name resolution: identifiers inside the lambda that bind to a declaration outside it.
- Globals are not captured; they have an address, not a frame slot. Unmentioned locals are not captured either.
- A captured variable cannot stay in a stack frame that the closure can outlive, so it goes into the closure or into a heap environment.
- Capturing the value and capturing the binding are different features; the difference is invisible until someone writes through one of them.
- Escape analysis is what makes a non-escaping closure free, and whether it runs decides most of the real-world cost.
- Closures are the first place a scoping fact forces a storage decision — the same pattern recurs for coroutines and async.
The problem, in six lines
A local variable normally lives in its function's stack frame, and the frame is destroyed when the function returns. That is the deal: it is why locals are cheap. A closure breaks the deal by producing a value that still refers to the local after the return.
Nothing about the source suggests a difficulty. outer declares x, returns a function that reads x, and the caller calls that function. But by the time the returned function runs, outer's frame has been popped and the stack space has almost certainly been reused by whatever ran next. If x were still in that frame, the closure would read whatever now occupies those bytes.
So the compiler must do something. It has exactly three options and every language picks one of them: refuse to allow it, copy the value into the closure, or move the variable somewhere with a longer life. C picks the first — a function pointer captures nothing, which is why C callbacks all take a void* you fill in yourself. C++ makes you say which of the second and third you want. Most other languages pick the third and hide it.
1function outer() {2 let x = 10;3 return () => x; // the returned function still mentions x4}5 6const f = outer(); // outer's frame is now gone7f(); // must still produce 10The variable x outlives the function that declared it. Nothing in the syntax announces this, and the entire implementation cost of closures follows from it.
Free variables are what the resolver already computed
The set of variables a lambda captures is not a new analysis. Name resolution has to bind every identifier to a declaration anyway; a lambda's free variables are precisely the identifiers inside it that bound to a declaration *outside* it. Compilers compute the set as the resolver unwinds, and store it on the lambda node.
Two details make the set smaller than people expect. Globals are not captured, because they are not in any frame — a reference to a global is a reference to a fixed address, and no closure has to carry it. And a variable mentioned only in a nested lambda is still free in the outer lambda, transitively, which is what makes nested closures interesting: an inner lambda's captures propagate outward through every enclosing lambda that does not itself declare the name.
The set matters because it is the size of the thing the closure must carry. A lambda that captures one integer can be a pair of words. A lambda that captures fifteen locals is a fifteen-field record, allocated somewhere, on every evaluation of the lambda expression — and if that expression is inside a loop, once per iteration.
Read it asThe capture set is exactly the identifiers whose resolved symbol lives in an enclosing *function* scope. n is in scope and not captured because it is not mentioned. g is mentioned and not captured because a global has an address rather than a frame slot. Getting this set right is a [[name-resolution]] question that has an allocation attached to the answer.
Three places the variable can live
[&] capture leaves the variable in the enclosing frame, so a lambda that captures by reference and outlives that frame has a dangling reference and undefined behavior — the language permits you to write it and the compiler is not required to diagnose it. Every garbage-collected language in the table makes this case impossible by construction, which is why the same code is a crash in one language and correct in another.Once you know what is captured, the question is where it goes. The frame is out unless it can be proved safe, so the realistic answers are: inside the closure object itself, in a separate heap-allocated environment shared with the enclosing scope, or — the special case that matters for performance — still on the stack, because the compiler proved the closure never escapes.
Putting the value inside the closure object is capture *by value*, and it is a copy. Reads through the closure see the value at capture time and never again. That is simple, allocation-friendly, and wrong for any language where the enclosing scope and the closure are supposed to share a mutable variable.
Putting the variable in a shared heap cell is capture *by reference*, or more precisely capture of the binding. The variable is moved out of the frame into a heap object at the point the compiler realises it is captured, both the enclosing code and the closure access it through that object, and it lives as long as either of them does. This is what a garbage-collected language typically does, and it is why a closure in such a language is an allocation.
The third case is the one that turns closures from a cost into a non-cost. If the closure is only called and never stored — passed to a sort comparator, say — nothing outlives the frame and the environment can stay exactly where it was. That proof is [[escape-analysis]], and whether a given compiler performs it is the single biggest determinant of what closures cost in practice.
| Language | What is captured | Where it lives | Who decides |
|---|---|---|---|
| JavaScriptspec | The binding, always by reference | A heap environment record shared with the enclosing scope | The language; the programmer has no syntax for the choice |
| Pythonspec | The binding, by reference (a cell object) | A heap cell; nonlocal is required to rebind it | The language, with nonlocal/global controlling assignment only |
| C++ lambdasspec | Whatever the capture list says: [x] by value, [&x] by reference | By value: inside the closure object. By reference: nowhere — it stays in the frame | The programmer, explicitly, per variable |
| Rustspec | By reference, by mutable reference or by move, inferred from use; move forces the last | Borrowed: the original location, with a lifetime the checker enforces. Moved: inside the closure | Inferred, and checked — a closure outliving a borrow does not compile |
| Goimplementation | The variable, by reference | Stack if escape analysis permits, heap otherwise | The compiler, per variable, reported by -gcflags=-m |
| Java lambdasspec | The value; captured locals must be effectively final | Inside the closure instance | The language, by forbidding the case where the distinction would show |
Why this is a compiler lesson and not a language trivia lesson
The reason closures get their own module entry is that they are the first feature in most languages where a *lexical* fact — a name resolving to an enclosing scope — forces a *storage* decision. Nothing else in the frontend does that. Types decide sizes, but scoping deciding lifetimes is new, and it is the thing that makes closures more than a syntax for anonymous functions.
It also introduces the pattern the rest of this module repeats. Coroutines force locals off the frame because a suspension outlives the activation. Async functions do the same. Generators do the same. In every case the mechanism is: some construct lets a piece of the function outlive the frame the function was given, so the compiler must relocate whatever that piece touches. Closures are the smallest instance and the right place to learn it.
The practical corollary is that closure cost is not uniform and is not knowable from the source. Two identical-looking lambdas can have completely different costs depending on whether the compiler could prove non-escape, how many variables were free, and whether the language captures values or bindings. That is why the inspection flags in this lesson matter more than usual.
How it works
The steps, in the order the compiler takes them.
- Name resolution binds every identifier; for each lambda, the identifiers that resolved to a declaration in an enclosing function scope form its free-variable set, stored on the lambda node.
- Inner lambdas propagate their free variables outward: a name free in a nested lambda and not declared by the enclosing lambda is free in the enclosing lambda too.
- The compiler decides, per captured variable, whether the closure copies it or shares it, following the language rule or an explicit capture list.
- Variables that must be shared are moved out of the frame into a heap-allocated environment; the enclosing function is rewritten so its own accesses go through that environment too.
- The lambda expression evaluates to a value pairing a code pointer with the environment, allocated at the point of evaluation — inside a loop, that is once per iteration.
- Escape analysis may prove the closure does not outlive the frame, in which case the environment is placed on the stack and the allocation disappears.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A returned closure reads garbage or crashes: the captured variable was left in a frame that had already been popped, and the stack slot has since been reused by another call.
- A closure created in a loop sees the loop variable's final value in every iteration, because what was captured was the shared binding rather than the per-iteration value — see
[[closure-conversion]]. - A mutation made through a closure is invisible to the enclosing scope, because a language or a capture list copied the value at capture time and the two are now separate variables.
- Memory grows steadily in a long-running process: a closure stored in a callback registry keeps a whole environment alive, including large objects the lambda never mentions but that share the environment.
- A tight loop allocates once per iteration because a lambda inside it captures a variable and escape analysis could not prove it stays put; the profile shows allocation with no
newanywhere in the source. - A C++ lambda captured by reference and stored in a container compiles cleanly, works in tests where the frame is still live, and produces undefined behavior in production.
When it helps
- Callbacks and event handlers, where the code to run and the data it needs must travel together to somewhere that knows about neither.
- Higher-order functions over collections, where the operation needs context from the calling scope.
- Encapsulating state without declaring a type — a counter, a memo table, a configured function — where a class would be ceremony.
- Deferred and lazy work: a closure is a computation packaged with everything it needs to run later.
When it hurts
- In hot loops in languages where the environment is heap-allocated and escape analysis cannot see through the call, where the per-iteration allocation dominates the work.
- When the captured environment keeps far more alive than the closure uses, which is a common shape of memory leak in long-lived registries.
- When the capture semantics are implicit and the code relies on sharing, so a refactor that moves the lambda changes what it sees.
- When reasoning about lifetimes matters and the language hides them, so the cost and the liveness are both invisible at the call site.
What it costs
Every one of these is paid by something.
- Capturing the binding buys a shared mutable variable that behaves exactly as lexical scoping suggests, and pays an allocation plus an indirection on every access to that variable — including from the enclosing function, which now also goes through the environment.
- Capturing by value buys a flat, allocation-free closure object and pays the loss of sharing: writes on either side are invisible to the other, and the language must either forbid the confusing cases or accept them.
- Leaving captured variables in the frame — the C++
[&]option — buys zero cost and pays with a lifetime obligation the compiler does not check, converting a scoping mistake into undefined behavior. - Escape analysis buys back most of the cost and pays in compile time and in unpredictability: the same source compiles to an allocation or to nothing depending on whether an interprocedural proof succeeded, which makes performance non-local.
What else you could do
What a different compiler or language does instead, and when that is better.
- Function pointer plus an explicit context argument, which is what C does. No capture, no allocation, no hidden lifetime — and the caller must manage the context's lifetime by hand, which is the same problem moved rather than solved.
- An object with a call method and explicit fields — the pre-lambda Java and C++ approach. Identical machinery with the capture written out by the programmer, which is more code and no hidden costs.
- Defunctionalization: replace every closure with a tagged data value and a single dispatch function that interprets the tag. Used in compilers targeting environments without indirect calls, and in whole-program compilers that want no function pointers at all.
- Lambda lifting: turn free variables into extra parameters and make the function top-level, which avoids the environment entirely when the closure does not escape — see
[[lambda-lifting]].
See it for yourself
The flag, dump or tool that shows you this directly.
- Go:
go build -gcflags=-mprints escape decisions per variable, including lines likemoved to heap: xandfunc literal escapes to heap. This is the most direct view of the whole lesson available in any mainstream toolchain. - Python:
f.__code__.co_freevarslists a function's free variables andf.__closure__shows the cells holding them;cell_contentsreads one.dis.disshowsLOAD_DEREFfor a captured variable versusLOAD_FASTfor a local. - JavaScript: Chrome DevTools shows a
Closurescope in the scope chain when a closure is paused; a heap snapshot shows the retained environment, which is how closure-shaped memory leaks are actually found. - C++: compile a lambda on Compiler Explorer and look at the generated closure type. A
[x]capture becomes a struct member; a[&x]capture becomes a pointer, which makes the dangling case visible. - Rust:
rustc -Z unpretty=hirshows the inferred capture mode per variable, and the 2021 edition's disjoint closure captures mean a closure may captures.fieldrather thans.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A closure captures the enclosing scope." It captures the variables it mentions. Capturing the whole scope is an implementation some early engines used, and it is the reason closure memory leaks were once much worse than they are now.
- "Closures are just anonymous functions." An anonymous function that mentions nothing outside itself needs no environment and is a plain function pointer. Capture is the feature; anonymity is the syntax.
- "A closure copies the variables it uses." In most garbage-collected languages it shares them, which is exactly why a loop-created closure sees the final value.
- "Closures always allocate." They allocate when the environment must outlive the frame. A comparator passed to a sort in a language with escape analysis frequently allocates nothing.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When a function creates another function that uses one of its local variables, that variable has to survive the outer function returning. Normally locals die with the frame, so the compiler moves the variable somewhere longer-lived and gives the inner function a way to reach it. The pair of "code to run" and "variables it can reach" is what the word closure names.
practical
Three things to know for your own language. First, what does it capture — the value or the variable? That decides whether a write on one side is visible on the other, and it is the source of the loop-variable bug in [[closure-conversion]]. Second, does capture allocate, and does your compiler tell you? Go will tell you with -gcflags=-m; most others will not without a profiler. Third, what does the closure keep alive? A closure stored in a registry retains its whole environment, so a lambda that mentions one field of a large object may keep the object alive — which is a leak that never appears in the code as a reference.
advanced
The design space is best understood as a choice about *where the binding lives*, and every other property follows. If bindings live in a heap environment, closures are uniform, mutation is shared, and the cost is an allocation you then try to remove with escape analysis. If bindings live in the closure object, closures are flat and cheap and the language must either forbid mutation of captured variables (Java) or accept that sharing is lost (C++ by-value). If bindings stay in the frame, the language must prove the closure does not outlive it — which is exactly what Rust's borrow checker does, and it is why Rust can offer the cheap representation without the C++ hazard. The three answers are not better and worse; they are the same trade paid in allocation, in expressiveness, and in checker complexity respectively.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
-gcflags=-m and they change between releases; the JVM relies on escape analysis in C2 that runs only after a method is hot, so the same lambda allocates during warmup and may not afterwards. Never treat a measured allocation count as a property of the source.If you were asked this in an interview
- A function returns a lambda that reads one of its locals. Walk me through what the compiler has to do.
- What is the difference between capturing a value and capturing a variable, and when can a user tell?
- How would you decide whether a closure needs a heap allocation?
Connections
- Programming Languages & Runtime Internals — How the heap environment a closure needs is allocated, traced and collected at run timeThis lesson stops at the compiler deciding a variable must leave the frame. What the allocator does with that request, how the collector finds the environment through the closure, and what the object header costs are the runtime's half of the same mechanism.