skip to content

Why can a Go loop that creates one closure per iteration allocate on every iteration?

level: middleimportance: nice to knowfreq 30%

answer

  1. a func literal is more than code
  2. code pointer plus captured variables
  3. does the callee keep the function value
  4. since 1.22 each iteration owns its variable

basics

~20 s

A function literal that captures variables is an object holding a code pointer plus those captured variables. If that function value escapes the frame, each iteration allocates one closure, and each captured variable it refers to is moved to the heap too.

solid answer

~50 s

A Go function literal that captures variables is not just code: it is a closure object holding a code pointer and references to the captured variables. If the compiler can prove the function value does not outlive the frame — the callee neither stores it nor lets it escape — the closure lives in the frame and costs nothing, and escape analysis prints `func literal does not escape`. If instead the closure is stored in a slice, returned, sent on a channel, or handed to something the compiler cannot see through, it escapes, and a loop that creates one per iteration pays one allocation per iteration. Captured variables escape with it, so each captured local is `moved to heap` as well. Since Go 1.22 each iteration has its own loop variable, so an escaping capture allocates a fresh variable per iteration instead of sharing one for the whole loop.

code

go · 9 lines
go
var handlers []func() string

func register(f func() string) { handlers = append(handlers, f) }

func setup(names []string) {
	for _, name := range names {
		register(func() string { return name })
	}
}

go deeper

for a junior

Know that a function literal capturing a variable is an object with storage, not just code, and that creating one in a loop can therefore cost memory.

for a middle

Explain when the closure stays in the frame and when it escapes, why the captured variables follow it to the heap, and what the per-iteration loop variable since Go 1.22 means for the allocation count.

for a senior

Show how you localise this in real code: find the callee that retains the function value, decide whether to hoist the literal, pass data as arguments or change the helper, and prove the allocation went away with a benchmark.

for a principal

Weigh readability against the cost. Callback-heavy APIs are pleasant to use and force allocations at every call site; decide where in the codebase that trade is acceptable and where a plain loop or a concrete helper is the standard.

## What a closure actually is A function literal that references nothing outside itself compiles to a single static function value; creating it allocates nothing, however many times you write it. A literal that *captures* variables is different. It becomes a closure object: a code pointer plus the captured variables (or pointers to them, when the variable is also used or assigned outside the literal). That object needs storage, and where it goes is an escape-analysis decision like any other. ## The two outcomes **The closure does not escape.** The compiler sees the whole story: the function value is called locally, or passed to a callee that provably does not retain it. Then the closure object sits in the frame and neither it nor the variables it captures cost an allocation. The escape output for this case reads `func literal does not escape`. **The closure escapes.** It is stored in a package-level slice or a struct field, returned to the caller, sent on a channel, started as a goroutine, or passed to a function the compiler cannot analyse at the call site — for example an indirect call through an interface or a function-typed field. Now the closure object must be heap-allocated, and everything it captures by reference must be heap-allocated with it. Put that second case inside a loop and the arithmetic is unavoidable: one closure created per iteration, one allocation per iteration, plus one allocation per captured variable that the compiler has to move to the heap. ## The loop variable makes it per-iteration Since Go 1.22 each iteration of a `for` loop has its own copy of the loop variable. That change fixed the long-standing capture bug — every closure now sees the value from its own iteration rather than whatever the shared variable ended up holding — but it also changes the allocation picture. Before 1.22 a captured, escaping loop variable was one variable, moved to the heap once for the whole loop. Since 1.22 there are N distinct variables, so an escaping capture heap-allocates one per iteration. This is not a regression to route around; it is the price of the correct semantics, and it only shows up when the closure genuinely escapes. A non-escaping closure over a per-iteration variable still costs nothing. ## Reading it in the compiler output Build the package with `go build -gcflags='-m -m'` and look for three things around the loop: - `func literal escapes to heap` versus `func literal does not escape` — the closure object itself. - `moved to heap: name` for each captured local the compiler had to promote. - The `leaking param` line on whatever the closure is passed to. That line is usually the root cause: the closure escapes because the callee keeps it. The second `-m` matters here, because it prints the chain from the literal to the thing that made it escape, which is what tells you whether the fix belongs at the literal or at the callee. ## Ways the allocation goes away The fix is nearly always to stop the function value from escaping, not to stop writing closures: - If the callee only needs the function during the call, make that visible — a small, direct, non-retaining helper lets the compiler prove `does not escape`. - Hoist the closure out of the loop when it does not depend on the iteration. One closure, one allocation. - Pass the per-iteration data as an argument rather than capturing it, so the literal captures nothing and needs no per-instance storage. - Where a callback is only ever used for one shape of work, an ordinary method value or a plain loop body avoids the question entirely. Each of these changes what the code says, so each needs its tests to still pass — and each needs a benchmark to show the allocation count actually fell. The compiler's opinion is not the measurement. ## Why this is worth knowing A loop that looks purely computational — no `new`, no `make`, no obvious allocation — can allocate twice per iteration once a callback is involved, and the profile will point at the loop while the cause is a callee three lines away that keeps the function value. Recognising the shape saves the hunt.

  • Do Go closures capture variables by value or by reference?
    By reference to the variable, not by copying its value: the literal and the surrounding code see the same variable, so a write through one is visible to the other. That is exactly why a captured variable has to be moved to the heap when the closure escapes — the storage must outlive the frame that declared it.
  • Does a function literal that captures nothing allocate?
    No. With no captured variables there is no per-instance state to store, so the compiler can use a single static function value no matter how often the literal is evaluated. Creating it in a loop a million times allocates nothing, which is why passing the iteration data as a parameter instead of capturing it removes the cost.
  • How would you confirm the allocation is really per iteration?
    Two signals. `go build -gcflags='-m -m'` should show the literal escaping and the captured variables moved to the heap; a benchmark run with -benchmem should show allocs/op rising with the number of iterations rather than staying flat. Fix the escape, rerun both, and check the count actually dropped.

saying these in an interview costs you the question

  • Says closures are free because they are just functions
  • Claims a captured variable is copied into the closure
  • Blames the garbage collector rather than the escape
  • Thinks Go 1.22 removed closure allocations entirely
  • Assumes every callback passed to a helper escapes