Go ·
Go closures capture variables, not values (1.22 or not)
Every closure built in the loop returns the same final value, because they all share one captured variable. Why Go 1.22 didn't fix this shape.
Test yourself before reading - the write-up below gives it away.
package main
import "fmt"
func makeGreeters(names []string) []func() string {msg := ""
fs := make([]func() string, 0, len(names))
for _, n := range names {msg = "hi " + n
fs = append(fs, func() string { return msg })}
return fs
}
func main() { fs := makeGreeters([]string{"ana", "bo"})fmt.Println(fs[0](), fs[1]()) // expected: hi ana hi bo, actual: hi bo hi bo (!)
}
Both greeters greet the last guest
Go 1.22 was supposed to end this class of bug. The snippet below compiles clean on 1.26 and still greets the wrong person.
package main
import "fmt"
func makeGreeters(names []string) []func() string {
msg := ""
fs := make([]func() string, 0, len(names))
for _, n := range names {
msg = "hi " + n
fs = append(fs, func() string { return msg })
}
return fs
}
func main() {
fs := makeGreeters([]string{"ana", "bo"})
fmt.Println(fs[0](), fs[1]()) // expected: hi ana hi bo, actual: hi bo hi bo (!)
}
Why do all my Go closures return the same value?
A closure captures the variable itself - a reference to one memory location, never
a snapshot of its value at creation time. msg is declared once, before the loop,
so both closures share that single variable; by the time anyone calls them, it
holds the last assignment, "hi bo". The fix: declare msg inside the loop body,
so each iteration - and each closure - gets its own.
What Go 1.22 fixed, and what it deliberately didn't
The language spec is short about it:
function literals "may refer to variables defined in a surrounding function" and
those variables "are shared between the surrounding function and the function
literal". Shared. The closure stores the address of msg, all three of them the
same address, and reads it lazily at call time.
Go 1.22 changed one narrow thing: loop variables. for _, n := range names now
declares a fresh n per iteration, so capturing n in a closure became safe
(Go 1.22 release notes). That is why line 10, the
closure appended in the loop, looks fully modern and innocent - and is. The bug is
line 6: msg is an ordinary variable declared before the loop, and no version
of Go gives those per-iteration copies. A decade of "the loop variable gotcha is
fixed now" articles has quietly taught people the wrong scope for the fix.
The bug survives testing whenever the closures get called eagerly - call each one inside the loop and the shared variable happens to hold the right value at that instant. It surfaces when creation and invocation drift apart: callbacks registered now and fired later, handlers stored in a slice, goroutines scheduled after the loop finishes.
Three million certificates, one shared variable
This mechanism has a famous production run. In 2020, Let's Encrypt's CA software rechecked domain authorizations in a loop that referenced a loop iterator variable; every check ended up pointing at the same authorization, so with N domains in a request, one domain got checked N times and the other N-1 not at all. The result was the revocation of just over three million certificates (incident report, Bugzilla 1619047). Theirs was the loop-variable flavor that Go 1.22 later fixed - the incident is cited in the Go team's own motivation for the change. The flavor in our snippet is its sibling, and it is still fully alive.
The fix
Move the declaration into the loop, so "one variable per iteration" is true because you wrote it that way:
for _, n := range names {
msg := "hi " + n
fs = append(fs, func() string { return msg })
}
"Upgrade to Go 1.22" is the fix people reach for first, and for this shape it does
nothing - the per-iteration rule covers variables declared by the loop statement,
never ones it merely assigns to. The other honest fix is passing the value through
a parameter (func(m string) func() string { return func() string { return m } }),
which makes the copy explicit at the cost of ceremony.
How to find shared captures in your codebase
go vet's loopclosure check targeted the pre-1.22 loop-variable case; it does not flag captured outer variables likemsg. Treat anyfunc() {...}inside a loop that reads a variable declared above the loop as a review flag.- Grep for the shape:
grep -rnE 'for .*(range|;).*\{' -A 4 --include='*.go' . | grep 'func('surfaces closures built in loops; the judgment call is yours. - The runtime tell: N callbacks that all report identical data, where the data matches the final iteration.
Related bugs
Deferred effects meeting shared state is also the plot of the goroutine counter that loses updates - same shared variable, plus concurrency. And the mirror image, where Go copies when you wanted sharing, is the range loop over structs. Hub: bug write-ups.
One line lower. That is the entire distance between this bug and correct code.