Back to blog

Sunday, August 16, 2026

Why Did My Closure Capture the Wrong Loop Variable?

Title card for 'Why Did My Closure Capture the Wrong Loop Variable?' with a short code example

Why Did My Closure Capture the Wrong Loop Variable? (Go 1.22 Changed This)

Because the closure holds a reference to the loop variable, not a copy of the number printed at that iteration. Before Go 1.22, for reused one variable. After the loop, every stored func or goroutine read the last value. Go 1.22 creates a fresh variable per iteration, but only if the module's go line is 1.22 or later.

The failure

This prints 3 3 3 on Go 1.21 (and on 1.22+ if go.mod still says go 1.21).

package main

import "fmt"

func main() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, fn := range prints {
        fn()
    }
}

Goroutines do the same:

values := []string{"a", "b", "c"}
for _, v := range values {
    go func() { fmt.Println(v) }()
}

You usually get c c c. t.Parallel() inside a range over cases used to hide odd failures: the loop finished first, then every subtest saw the last v.

Taking &v or passing &v into a helper is the same trap: one address, last contents.

Why this happens

A closure does not snapshot. It names the same storage the loop writes. Pre-1.22, that storage was declared once. Each iteration assigned a new value into it. Anything that ran after the iteration, a goroutine, a defer, a slice of funcs, saw whatever sat there at call time.

Go 1.22 changed the spec: each iteration gets its own i / v. The old examples print 1 2 3 and a b c. The switch is gated by go 1.22 in go.mod, not merely by the toolchain version. A 1.23 compiler still uses the old rule for a module that says go 1.21.

Variables you declare inside the body were always per-iteration. The change is only for names the for statement itself declares.

The fix

On go 1.22+, write the obvious loop. The compiler does the per-iteration copy.

On older modules, or when you must be obvious, shadow or pass by value:

for _, v := range values {
    v := v
    go func() { fmt.Println(v) }()
}

for _, v := range values {
    go func(val string) { fmt.Println(val) }(v)
}

Before bumping go 1.22, run GOEXPERIMENT=loopvar go test ./... (Go 1.21). Tests that used t.Parallel() with a captured loop var often start failing, they were never testing the cases you thought.

Keep v := v if a reviewer will read the code on mixed versions. It is harmless under the new rule.