Skip to content
gohacks.dev
Basics

The Loop Variable Change in Go 1.22 and What It Fixed

Since Go 1.22 every loop iteration has its own variable, which fixes the closure and goroutine bug. See old against new output and how go.mod decides.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

For a decade, one of the most reliable ways to get a wrong answer from Go was to start goroutines in a loop. The loop variable was a single variable, the goroutines all captured it, and by the time they ran it held the last value. Every Go developer learned the workaround, a line that copied the variable, and every code review had to check for it. In Go 1.22 the language changed so the bug cannot happen. This article shows the old and new output from the same source, explains the one rule that decides which you get, covers the case where code could break, and ends with how to check your own module. It follows closures in Go and is part of the Go basics path. Goroutines are covered properly in the concurrency patterns hub.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64); the old behavior was run with go 1.21 in the module file

The bug, run under both rules

The same program, byte for byte, under two go.mod files. It starts three goroutines that each report the loop variable, and collects pointers to the variable:

main.goGo
package main

import (
	"fmt"
	"sort"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var seen []int
	var ptrs []*int

	for i := 0; i < 3; i++ {
		ptrs = append(ptrs, &i)
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			seen = append(seen, i)
			mu.Unlock()
		}()
	}
	wg.Wait()

	sort.Ints(seen)
	fmt.Println("goroutines saw:", seen)
	for _, p := range ptrs {
		fmt.Print(*p, " ")
	}
	fmt.Println()
}
Text
goroutines saw: [3 3 3]
3 3 3
main.goGo
package main

import (
	"fmt"
	"sort"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var seen []int
	var ptrs []*int

	for i := 0; i < 3; i++ {
		ptrs = append(ptrs, &i)
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			seen = append(seen, i)
			mu.Unlock()
		}()
	}
	wg.Wait()

	sort.Ints(seen)
	fmt.Println("goroutines saw:", seen)
	for _, p := range ptrs {
		fmt.Print(*p, " ")
	}
	fmt.Println()
}
Text
goroutines saw: [0 1 2]
0 1 2

The first block ran with go 1.21 in the module file, and the second with the default go 1.25. Under the old rules every pointer pointed at the one shared variable, which ended the loop at 3, so the pointer list printed 3 3 3. In this run the goroutines all started after the loop had finished and saw [3 3 3]. Another run could show an earlier value for one of them, because they read a variable the loop is still changing, which is a data race, and that unpredictability is part of the symptom. Under the new rules each iteration has its own i, the goroutines see 0 1 2, and the pointers print 0 1 2.

The mutex and sort are there so the new-rules output is deterministic: goroutines finish in any order, and an unsorted slice would vary between runs for reasons that have nothing to do with the loop variable.

What exactly changed

The specification on for statements now says that each iteration has its own separate declared variable. The variable used by the first iteration is the one declared in the init statement. The variable for each later iteration is declared implicitly before the post statement runs, and is initialized to the value the previous iteration's variable has at that moment. Two consequences follow.

The change applies to both loop forms. A three-clause loop for i := 0; i < n; i++ and a for k, v := range m loop each declare fresh variables per iteration.

Mutating the loop variable inside the body still works as before. If the body changes i, the next iteration's i starts from that changed value and then increments:

main.goGo
package main

import "fmt"

func main() {
	for i := 0; i < 8; i++ {
		if i%3 == 0 {
			i++
		}
		fmt.Print(i, " ")
	}
	fmt.Println()
}
Text
1 2 4 5 7

The loop skips values whenever the body bumps i, exactly as it did before. The per-iteration copy is initialized from the previous one, so loops that adjust their own counter are unaffected. That was a deliberate design choice, to keep the common idioms working.

It is the go line, not the toolchain

The new semantics apply only to packages whose module declares Go 1.22 or later. A module with go 1.21 in go.mod, built with a Go 1.25 toolchain, behaves the old way, as the first run above shows. That gating is how the Go team made a change that alters program behavior without breaking anyone: nothing changes until you raise the line. You can inspect and set it with:

Shell
go mod edit -go=1.22

A file can opt in or out on its own with a //go:build go1.22 constraint, because the language version is set per file. That makes a gradual migration possible in a big repository. If you have a monorepo with a mix, the module's own line decides for each module.

Raising the version is also the migration, and it is almost always the only step. The i := i and v := v copies that are all over older code are harmless and now redundant. There is no hurry to delete them, and a lint tool can strip them when you do.

Can it break working code

In principle yes, and in practice rarely. The change breaks a program only if the program depended on the shared variable. The Go team's own preview of the change describes an analysis of open-source commits that added x := x lines, expecting to find bug fixes, and finding many unnecessary lines instead: developers added the copy to keep a checker quiet, whether or not the code was buggy. That is a fair summary of the old situation. The same two-line diff was a real fix in one program and noise in another, and you could not tell which without reading the types involved.

Two cases you could write on purpose, and should not, change meaning. Code that appends &i to a slice and expects every entry to see the final value, which is almost never intended. And code that starts a goroutine to watch the loop's counter, expecting to see it advance. For both, the old behavior was a bug waiting for a reader. If your tests pass under both versions, you are fine.

There is a small performance point as well. A per-iteration variable that a closure or pointer captures and that outlives the iteration is allocated on the heap, which costs one allocation per iteration. A loop whose variables are not captured compiles as before, and the compiler avoids the extra copy when it can show nothing escapes. I did not measure this one, and I would not expect it to matter outside a very hot loop.

Checking your own module

Three checks are worth doing before and after raising the go line.

Run go vet ./... on the old version. The loopclosure analyzer reports the textbook form, a closure or go statement at the end of a loop body that refers to the loop variable, and each hit is a bug that the upgrade fixes:

Shell
go vet ./...

Run the tests with -race before and after. A data race on a captured loop variable disappears when the loop variable stops being shared, so the race detector reporting fewer races after the upgrade is a good sign.

Preview and bisect. A Go 1.21 toolchain can apply the new semantics to every loop with GOEXPERIMENT=loopvar, which is how the team asked people to test ahead of the release. If a test fails only after the change, the change's FAQ, linked from the blog post, describes a tool that finds the single loop responsible.

Where the old habit lingers

You will see the copying line in code written before 2024, and in tutorials that predate the change. If you maintain a library whose go.mod must stay at an older version for compatibility, keep the copy, since your users' toolchains and language versions follow your module's line, not theirs. For anything new, set the language version to 1.22 or later and delete the habit. The same logic about the go line applies to other language changes, such as ranging over integers and functions in range over integers and functions, which also needs a recent enough line. And when you read a goroutine example from an older source, be skeptical of any loop that captures its variable without copying it: it was a bug in that version of the language, and it is a correct program in this one. The release notes for the change are in the Go 1.22 release notes.

Taggedgo 1 22
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.