Go 1.22 changed what a for loop variable is. The Go 1.22 release notes explain that loops used to create their variables once and update them on each iteration. From Go 1.22, each iteration gets new variables.
The sharing bug
Under the old rule, closures that captured a variable declared by the loop shared it across iterations. The Go blog post on the change shows goroutines that all print one shared v:
for _, v := range []string{"a", "b", "c"} {
go func() {
fmt.Println(v)
}()
}
The usual workaround was a copy, v := v, at the top of the body. The loopvar wiki page says the copy is unnecessary once a module targets Go 1.22 or later.
Which variables the rule covers
In the Go specification, a range clause that declares its variables with := gets new variables on each iteration. A range clause can instead assign to existing variables with =, and such a loop keeps using those variables.
The go line sets the default
On a current toolchain, the rule follows the go line in go.mod. The blog says the new semantics apply only in modules that declare Go 1.22 or later, and that //go:build lines can decide file by file.
There are two exceptions. The blog notes that very old, unsupported Go versions could still compile such code with a shared variable. The Go 1.21 preview, GOEXPERIMENT=loopvar, applied the new semantics to every loop and ignored the go line.
A module whose go.mod says go 1.21 therefore shares the variable by default, unless a file’s build constraint selects Go 1.22 or later. Code copied in from a newer module can then change behavior while its text stays identical.
What changes when you raise the line
Raising the go line switches every affected loop in the module at once. Per the wiki, the loops that compile differently are the ones whose variable is captured by a closure or has its address taken. Building with -gcflags=all=-d=loopvar=2 prints a line for each such loop.
The compiler source shows that this setting also turns on the new semantics in files below Go 1.22. The wiki notes that all= makes it report on every package in the build, so older dependencies get the new semantics too, including ones your own go line does not govern. Treat it as a broader check than a raise of that line.
If a test fails after the switch, the wiki’s bisect tool enables the change on subsets of the program to find the loop that causes the failure:
go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test
It needs a failing test command.
What to do
- Read the
goline in eachgo.mod, and look for per-file build constraints that select Go 1.22 or later. - List the affected loops with
-gcflags=all=-d=loopvar=2, and run the full test suite with the same flag. The flag applies the new semantics to older dependencies too, so that run is a broader check than the raise. - Run
bisect -compile=loopvar go teston any test that fails. - Copies that only guard a variable declared with
:=can go. Review copies in loops that assign with=before removing them. Our advice: make the removal a separate commit, so reviewers see the semantic switch and the cleanup apart.

The Campfire
CommentsThanks. Your comment is waiting for the desk. Once it is approved it appears here within about a minute.
Add a comment