Closure Variable Capture
A Go closure — a function literal that refers to variables from its surrounding function — captures those variables, not their values. The spec is explicit: “Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible” (go.dev/ref/spec). The gotcha follows directly: every closure that captures the same variable sees the same storage, so they all observe whatever value that variable holds when each closure runs — not when it was created. The most infamous instance was the loop-variable trap, fixed by Go 1.22’s per-iteration scoping; but that fix narrowed the trap, it did not remove the underlying capture-by-reference mechanism.
This is a focused gotcha note on capture semantics in general. The loop-variable-specific history and the Go 1.22 scoping change have their own note — Loop Variable Capture — which this note cross-links rather than duplicates. The runtime representation of a closure (the function pointer plus the heap-allocated capture record) is Function Values and Closures Internals.
Mental Model
A closure is a pair: a code pointer and a set of captured variables. The single fact that explains every closure surprise: the closure captures the variable’s identity — its storage location — not a snapshot of its current value. Two closures that mention the same surrounding variable share one box. Read from one, write from the other, and they communicate.
A second fact follows: a captured variable outlives its lexical scope. If a function creates a closure that captures a local variable and returns that closure, the local variable cannot live on the stack — it would be destroyed when the function returns. So the compiler moves it to the heap (escape analysis decides this; see Escape Analysis). The closure and the variable live together on the heap for as long as the closure is reachable.
The contrast that resolves the gotcha: capturing a variable (closure refers to x by name) versus passing a value (you call f(x), copying x’s current value into a parameter). The first shares; the second snapshots. Choosing between them is choosing whether the function observes later mutations.
flowchart TD subgraph Shared["Capture: closures share the variable"] V["variable counter (one heap box)"] F1["closure inc: counter++"] F2["closure get: return counter"] F1 -.captures.-> V F2 -.captures.-> V N["inc() then get() -> 1<br/>they share the SAME box"] end subgraph Snapshot["Pass-by-value: each call copies"] X["x = 5"] C["call f(x): parameter p := copy of 5"] X -->|"copy at call time"| C M["later x = 99 ; f's p is still 5"] end style N fill:#1e3a5f,color:#fff style M fill:#1b5e20,color:#fff
Diagram: capture (top) versus pass-by-value (bottom). Captured counter is one shared box that both closures mutate and observe. A passed argument x is copied into the callee’s parameter, so later mutation of x is invisible to the callee. The insight: a closure capturing x is not equivalent to a function taking x as a parameter — one shares storage, the other copies.
Mechanical Walk-through
What “shared” means, per the spec
The spec’s “Function literals” paragraph is the whole contract: variables referred to by a function literal are shared between the surrounding function and the literal, and they survive as long as they are accessible (go.dev/ref/spec). “Shared” means one storage location, multiple readers/writers. “Survive as long as accessible” means lifetime is governed by reachability, not lexical scope — the variable lives until no closure (and no surrounding-function frame) can reach it.
How the compiler implements it
When the compiler sees a function literal that references a surrounding variable, it does two things. First, Escape Analysis determines whether the variable can stay on the stack. If the closure does not escape the surrounding function’s lifetime (e.g. it is called immediately, or only within the function), the variable may stay on the stack and the closure simply references the stack slot. If the closure escapes — it is returned, stored, sent to a goroutine — the captured variable is heap-allocated, because it must outlive the surrounding stack frame. Second, the closure value is built: a small object holding the code pointer and pointers to (or inline copies of) the captured variables. A closure that captures nothing is just a plain function pointer; a closure that captures variables is a function pointer plus a capture record. The capture is by reference to the variable’s box — which is exactly why mutations are mutually visible.
This is the mechanical reason capture-by-reference is not a choice the language could “fix” without breaking closures: a closure that could not observe a mutation of a captured variable would not be a closure in the lexically-scoped sense. (See Function Values and Closures Internals for the exact object layout.)
The loop-variable case, and what Go 1.22 changed
The most-reported manifestation: a loop creates closures that capture the loop variable. Before Go 1.22, a for loop’s variable had per-loop scope — one variable, reused across iterations. Every closure created in the loop body captured that one variable. After the loop, the variable held its final value, so every closure observed the final value (go.dev/blog/loopvar-preview).
Go 1.22 changed for loops so each iteration gets its own variable — per-iteration scope (go.dev/doc/go1.22). The spec’s For statements section now reads (verbatim, from the current master spec source): “Each iteration has its own separate declared variable (or variables) [Go 1.22]. The variable used by the first iteration is declared by the init statement. The variable used by each subsequent iteration is declared implicitly before executing the post statement and initialized to the value of the previous iteration’s variable at that moment.” The same rule appears in the range-clause subsection: “In this case their scope is the block of the ‘for’ statement and each iteration has its own new variables [Go 1.22].” So the change applies symmetrically to both the ForClause form (for i := 0; i < n; i++) and the RangeClause short-variable form (for k, v := range xs).
The scope rule for variables declared inside the loop body is separate and has not changed: per the spec’s Declarations and scope section, each if, for, and switch statement is in its own implicit block, and a short variable declaration inside that block is scoped to it. Each execution of the body block re-runs its declarations, producing a fresh binding — this has always been true and is unrelated to the Go 1.22 change. Concretely: for i := 0; i < 3; i++ { x := i*2; ... } produces a fresh x per iteration in Go 1.0 and a fresh i per iteration only in Go 1.22+. The Go 1.22 change manufactures more iteration variables; body-block locals were already per-execution.
But — and this is the point of having a separate note from Loop Variable Capture — the Go 1.22 change did not alter capture semantics at all. Closures still capture variables by reference. The change created more variables (one per iteration) so that each closure captures a different one. Capture is still capture-by-reference; the loop now simply gives each closure a fresh box to capture. Every non-loop manifestation of the gotcha — closures capturing a function-level variable, an accumulator mutated across closures, a variable mutated after the loop body — is unchanged by Go 1.22.
Code Examples with Line-by-Line Commentary
Example 1 — the intended use: shared state
func newCounter() (inc func(), get func() int) {
count := 0 // one variable; will escape to the heap
inc = func() { count++ } // captures the variable count
get = func() int { return count } // captures the SAME variable count
return
}
func main() {
inc, get := newCounter()
inc(); inc(); inc()
fmt.Println(get()) // 3 -- both closures share one box
}count on line 2 is captured by both closures. Because capture is by-reference-to-the-variable, inc’s writes are visible to get’s reads. After newCounter returns, count is unreachable from any stack frame but is reachable from inc and get, so it survives on the heap. This is closures working as designed — shared mutable state is the feature.
Example 2 — the gotcha: capture after a mutation
func makeGreeters() []func() string {
var fns []func() string
name := "world"
fns = append(fns, func() string { return "hello " + name })
name = "gopher" // mutate AFTER creating the closure
fns = append(fns, func() string { return "hi " + name })
return fns
}
func main() {
for _, f := range makeGreeters() {
fmt.Println(f())
}
}
// prints:
// hello gopher <-- NOT "hello world"
// hi gopherBoth closures capture the single variable name. The first closure was created when name == "world" — but it captured the variable, not the string "world". By the time either closure runs (in main), name holds "gopher", so both read "gopher". This trap has nothing to do with loops and Go 1.22 does not touch it: there is one name, mutated, shared.
Example 3 — non-loop, goroutine-style
func register(events []string) {
var v string
for _, v = range events { // NOTE: v is declared OUTSIDE the loop
go func() { fmt.Println(v) }() // captures the outer v
}
}Subtle and important. The Go 1.22 fix gives the loop’s own iteration variable per-iteration scope. But here v is declared outside the loop (var v string on line 2) and the range assigns to it (for _, v = range — note =, not :=). So v is not a loop variable; it is a single function-level variable. Every goroutine closure captures that one v, and Go 1.22’s change does not apply. This still prints the last event repeatedly. The fix is the same as it always was — make a fresh variable or pass an argument (Example 5). The lesson: the Go 1.22 fix is specifically about the loop-declared variable; capture-by-reference of any other shared variable is unchanged.
Example 4 — loop variable, post-1.22
func makePrinters() []func() {
var fns []func()
for i := 0; i < 3; i++ { // i is the loop's variable -> per-iteration scope (Go 1.22+)
fns = append(fns, func() { fmt.Println(i) })
}
return fns
}
func main() {
for _, f := range makePrinters() {
f()
}
}
// Go 1.22+ : prints 0, 1, 2 (each closure captured a distinct per-iteration i)
// Go < 1.22 : prints 3, 3, 3 (all captured one shared i, left at 3)Under Go 1.22 and later, each iteration declares its own i, so the three closures capture three different variables holding 0, 1, 2. Under Go 1.21 and earlier (or a module declaring go 1.21 or below) there is one i, and after the loop it is 3. The capture mechanism is identical in both eras; what changed is how many variables exist to capture. See Loop Variable Capture for the full version history.
Example 5 — the universal fix: pass a value as an argument
for _, v := range events {
go func(v string) { // v here is a PARAMETER -- a fresh copy per call
fmt.Println(v)
}(v) // argument evaluated NOW, copied into the parameter
}This fix works in every Go version and for every shared-variable case, loop or not. The closure no longer captures v; it takes v as a parameter. The argument v is evaluated at the go/call statement and copied into the parameter — pass-by-value. Each goroutine gets its own independent copy. The same trick fixes Example 3 and defer cases (see defer Argument Evaluation Timing). The pre-1.22 shadowing fix v := v is the same idea — it creates a fresh variable per iteration for the closure to capture.
Example 6 — IIFE: capture is fine when the closure runs immediately
for i := 0; i < 3; i++ {
func() {
fmt.Println(i) // captures i, but runs NOW, before i changes
}() // immediately invoked
}
// prints 0, 1, 2 in EVERY Go versionWhen a closure is immediately invoked, it runs before the captured variable can be mutated, so capture-by-reference is harmless — it reads the variable’s current value. The Go Wiki notes this: “Non-goroutine closures execute immediately, so they work correctly even without capturing by value” (go.dev/wiki/CommonMistakes). The gotcha needs a time gap between closure creation and closure execution; an IIFE has none.
Failure Modes and How to Diagnose Them
Symptom: every closure in a collection prints/uses the same final value. They all captured one shared variable that was mutated after their creation. Classic in pre-1.22 loops, but also in any loop assigning to an outer variable (Example 3), or any sequence that creates closures then mutates a shared local (Example 2). Diagnose: find the captured variable; check whether it is mutated between closure creation and closure execution, and whether all closures name the same variable.
Symptom: a goroutine sees a value from “the future.” The goroutine’s closure captured a variable that the main flow kept mutating. The goroutine ran late and read the latest value. go vet’s loopclosure analyzer catches the loop-variable form in some cases; the non-loop form is generally not flagged.
Symptom: a memory “leak” — a closure pins a large object. A captured variable lives as long as the closure. If the closure is long-lived (registered as a callback, stored in a global) and captures something large, the large thing cannot be collected. Diagnose with a heap profile (see pprof and Profiling); the fix is to not capture the large object, or to null out the captured reference when done.
Symptom: a data race on a captured variable. Two goroutines whose closures capture the same variable, with at least one writing, race. The race detector flags it. The fix is per-goroutine copies (Example 5) or synchronization.
Tooling. go vet’s loopclosure analyzer detects the loop-variable shape of the gotcha — closures created in a for-loop body that capture the loop variable, particularly inside go statements, defer statements, and certain testing helpers. With modules declaring go 1.22 or later the situation it warned about can no longer occur for the loop variable itself, so the analyzer is most useful today for modules still on go 1.21 or below. The non-loop manifestations of capture-by-reference (Examples 2 and 3 below) are outside loopclosure’s scope; treat them as a code-review concern. Go 1.26 (the current stable release as of 2026-05) did not add a new analyzer for general closure capture (go.dev/doc/go1.26).
Common Misunderstandings
“Closures capture values.” They capture variables — storage locations. A later mutation of the variable is visible to the closure. (Examples 1, 2.)
“Go 1.22 fixed closure capture.” No. Go 1.22 changed for-loop variable scoping — each iteration gets its own loop variable. Capture semantics (by-reference-to-the-variable) are unchanged. Every non-loop capture trap still exists. (Examples 2, 3.)
“Capturing x is the same as passing x as a parameter.” Capturing shares the variable; passing copies the value. The difference is the entire gotcha. (Diagram; Example 5.)
“The v := v trick is obsolete after Go 1.22.” For the loop variable specifically, yes — it is now redundant. But for any other shared variable a closure captures (an outer var, an accumulator), the make-a-fresh-variable / pass-as-argument fix is still required.
“Immediately-invoked closures need the copy trick.” They do not — running immediately, they see the current value before any mutation. (Example 6.)
Alternatives and When to Choose Them
Capture deliberately — when shared mutable state is the goal. Counters, memoization caches, event accumulators, the sync.Once-style “do this once” closures. Capture-by-reference is the feature; lean on it. (Example 1.)
Pass a parameter — when each invocation needs its own snapshot. Goroutines launched in a loop, callbacks registered with per-instance data, deferred calls that should freeze the current value. The argument is copied at the call site. This works in every Go version. (Example 5.)
Shadow with a fresh variable — x := x inside the loop body. Functionally identical to passing a parameter for the loop case; creates a new variable per iteration for the closure to capture. Post-Go-1.22 it is redundant for the loop variable but still the tool for other outer variables. Prefer the explicit parameter for clarity.
Synchronize — when goroutines must genuinely share a captured variable. If sharing is intended across goroutines, capture is fine but you must protect access with a mutex or atomics, or the race detector will (correctly) complain. See Atomic Operations in Go and sync.Mutex Internals.
Production Notes
The capture-by-reference rule is one line in the spec and the source of a disproportionate share of Go bugs, because the intuition “the closure I created when x was 5 remembers 5” is so natural and so wrong. The loop-variable form was common enough that the Go team took the rare step of a language change in Go 1.22 — gated on the module’s go directive for backward compatibility — and Google reported running it internally for four months across its entire codebase with zero production issues before shipping (go.dev/blog/loopvar-preview). A real-world incident the blog cites is a Let’s Encrypt bug where loop-variable capture spread across multiple functions silently certified the wrong data.
The enduring lesson — the reason this note is separate from Loop Variable Capture — is that Go 1.22 fixed one common shape of the trap by manufacturing more variables, but the trap’s cause is permanent: closures share variables. The discipline that survives every Go version: when a closure will run later than it was created, decide explicitly whether it should see the variable’s later value (capture) or its creation-time value (pass it as a parameter). Get that decision right and the gotcha never bites; the language will not make it for you outside the narrow loop-variable case.
See Also
- Function Values and Closures Internals — the runtime layout of a closure: code pointer plus capture record
- Loop Variable Capture — the loop-specific manifestation and the full Go 1.22 version history
- Range Loop Semantics —
rangevariables, scoping, and the value-vs-reference distinction - defer Argument Evaluation Timing —
defer f(x)snapshotsx;defer func(){use(x)}()captures it - Escape Analysis — why a captured variable is moved to the heap
- Data Races and the Race Detector — captured variables shared across goroutines
- Comparing Structs and Interfaces — a sibling gotcha where storage-versus-value semantics also bite (interface comparison panics)
- Go Internals MOC — parent map (§13 Common Gotchas)