Go 1.22 changed the scoping rules for for and range loop variables. Before 1.22, the loop variable was a single variable reused across every iteration, which broke code that spawned goroutines or registered callbacks inside the loop. Since 1.22, each iteration gets its own copy, and a whole class of subtle bugs disappears. This lesson explains the old behavior, the classic bug that motivated the change, the new semantics, and how to migrate safely.
Before Go 1.22, a for loop declared its iteration variable once. Every iteration reused the same memory location, just updating its value. That sounds harmless when you read the value during the iteration, but it gets ugly the moment you capture the variable inside a closure that runs later.
Here's a stripped-down example using the old semantics. To force the old behavior in a recent Go install, set the go directive in go.mod to 1.21 or earlier.
Every goroutine printed mug. None printed notebook or pen. The reason: all three closures captured the same variable p, and by the time they ran, the loop had already finished and p held its last value.
The fix that Go developers learned to write reflexively was the shadow trick: re-declare the variable inside the loop body so each iteration got a fresh one.
That works, but it relies on the developer remembering the rule. Many developers didn't, and even experienced Go programmers got bitten by it.
The shared loop variable wasn't an accident. The Go team picked it early on for simplicity: one declaration, one variable, easy mental model. The problem was that the rule clashed with how programmers actually use loops.
Three patterns hit the bug constantly.
Spawning goroutines per item. This is the classic case. You iterate over products, kick off a goroutine for each, and every goroutine sees the same product.
Deferring per iteration. A defer captures by reference, like a closure. If you defer a cleanup function inside a loop and reference the loop variable, the cleanup sees the last value.
In pre-1.22 code, every deferred function prints the same order.ID, the last one in the slice.
Registering callbacks. Event handlers, HTTP handler factories, button click registrations. Anything that stashes a closure for later execution.
Pre-1.22, every button's OnClick would print the same label, the last one.
The pattern is the same across all three: a closure created inside the loop body outlived the loop, and by the time it ran, the captured variable had changed underneath it.
Here's the button example in full, with old-style output. Consider an e-commerce admin panel that builds a row of action buttons for each product in a list. Each button needs to remember which product it belongs to.
Every button "remembers" the last label. The buttons themselves have correct Label fields (because that field was copied at struct-literal time), but the OnClick closure captures label by reference, and all three closures share the same label variable.
The source code is unchanged. The only difference is which loop semantics are in effect, decided by the go directive in go.mod. This is exactly the kind of bug that the new behavior eliminates.
The diagram shows the root cause: one variable, many closures, all pointing at it. By the time the closures run, the loop has finished and the variable holds its final value.
go vet had a loopclosure checker that warned about the goroutine case, but it couldn't catch every shape of the bug, and many developers ignored its warnings or turned them off. The change in 1.22 made the warnings mostly moot by removing the bug at its source.
Since Go 1.22, the compiler treats the loop variable in for and range loops as a new variable on every iteration. Each closure captures its own copy. The classic three-goroutine example now does what almost everyone expected it to do.
(The order may vary since goroutines run concurrently, but each product appears once.)
The change covers two loop forms.
Range loops:
Three-clause for loops:
A three-clause loop without a declaration in the init clause, like for ; cond; or for i = 0; i < n; i++ where i was declared outside the loop, does not get per-iteration scoping. The variable belongs to the enclosing scope, so there's nothing for the compiler to scope per iteration.
The i declared in i := 0 gets per-iteration scoping under the new rules. Without it, you'd see three copies of the last element.
Per-iteration scoping allocates a new variable on every iteration. For loops that capture in a closure, the compiler usually heap-allocates that variable so the closure can outlive the iteration. For loops that don't capture, the variable stays on the stack and there's no measurable overhead. The compiler optimizes the common case.
The Go team had a backward compatibility problem. A change to loop semantics could change the behavior of existing code in either direction (fixing latent bugs in some places, breaking working code that depended on the old behavior in others). Their solution was to tie the new behavior to the go directive in go.mod.
A module with go 1.22 or later uses the new per-iteration semantics. A module with go 1.21 or earlier keeps the old shared-variable semantics. The Go toolchain reads your go.mod and switches modes accordingly.
Here's what a go.mod opting into the new behavior looks like:
The directive is per-module, not per-file. Every package in a module compiles under the same rules. If you import a third-party package, that package's behavior depends on its own go.mod, not yours. So your go 1.22 code can safely call into a dependency that's still on go 1.18, and that dependency will keep using the old loop semantics internally.
That isolation matters because the change is observable from outside the function only through what closures capture. Two loops written identically can behave differently depending on which module they live in. That sounds scary on paper, but in practice it just means each module gets to upgrade on its own schedule.
To bump a module to the new behavior, edit go.mod:
Or run go mod edit -go=1.22.
After bumping, run your tests. Most projects find that bugs they didn't know they had get fixed. A small minority find that some code was depending on the shared-variable behavior, usually accidentally, and needs adjusting.
Even after you bump to go 1.22, you may have code that looks like it relied on the old behavior. The compiler doesn't warn about that automatically because the new behavior is now correct by definition. But go vet still helps you spot related issues.
The loopclosure analyzer used to be the main early-warning system for this bug. In Go 1.22 and later, it's been refined to focus on patterns that are still suspicious, like ranging over function-typed values where the closure runs after the loop ends.
In a Go 1.21 module, go vet -vettool=... flagged the original goroutine bug. In a Go 1.22 module, that specific pattern is silent because it's now safe.
If you want to test what your code does under the new semantics before flipping your module, the older toolchains supported GOEXPERIMENT=loopvar as a knob. Build with that env set and the compiler used the new rules even with an older go directive.
That experiment is no longer needed in 1.22 and later. If you're on 1.22+, just update the directive. The experiment exists in the history mostly as a debugging tool for the migration period.
There's also a build-mode option for finding code that depends on the old behavior. Compile with -d=loopvarhash to log loops where the new semantics differ from the old. That's mainly useful when bumping a large codebase and you want a checklist of loops to review.
In practice, the typical workflow looks like this. You inherit a codebase on go 1.20. You want to update it to go 1.22 to clean up the shadow tricks and pick up the safer semantics. You run the tests on the unchanged code, confirm they pass, then bump the directive and re-run. If the tests still pass, you're done. If a test fails, the failure points at code that depended on the old behavior. Look at it, decide whether the test or the production code was wrong, and adjust.
For a large monorepo with many modules, bumping them one at a time is an option. The per-module nature of the directive means you don't have to coordinate a flag day across the whole codebase. Library A can be on go 1.22 while library B is still on go 1.20, and code that uses both gets each library's chosen semantics.
The diagram shows the decision path. go.mod decides which semantics apply, and go vet checks for patterns that are still problematic under either rule.
The new rule is narrower than it sounds. It only changes the scoping of variables declared in the loop's init clause or in the range clause. Other variables captured inside the loop body keep their normal scoping rules, which means closures over them still share state.
Here's a case that surprises people. The loop variable p is per-iteration, but counter is shared.
p is correct for each goroutine because of the new per-iteration rule. But counter is declared outside the loop, so all three goroutines see the same counter variable, and by the time they print, it's been incremented to 3.
If you want each goroutine to see its own counter value, you have to capture it explicitly.
Here i is also a loop variable, so it gets per-iteration scoping. The output prints the correct index for each product.
Another subtlety: range over an integer (added in Go 1.22 in the same release) also gets per-iteration scoping for its variable.
The i in for i := range 5 is per-iteration, so each goroutine sees its own i. That's covered by the new rule because i is declared in the range clause.
But this isn't:
Here i belongs to the enclosing scope, not the loop. The loop just assigns to it. There's no per-iteration variable to scope, so closures capture the outer i and see its final value. This pattern is rare but about.
The scoping change has no runtime cost when nothing captures the loop variable. The compiler analyzes the loop body and only generates per-iteration storage when a closure or defer actually escapes a reference. For the common case (a tight numeric loop with no captures), the generated code is identical to pre-1.22.
Another subtle case: pointers to the loop variable behave differently too. Pre-1.22, taking &p inside a range loop and stashing the pointer somewhere outside meant every stored pointer aimed at the same memory cell.
Every stored pointer aimed at the same o, which held the final order by the time the second loop ran.
Each iteration's o is now distinct, so each pointer references its own copy. This same fix applies to any code that builds a map keyed by pointers, registers handler structs that hold a *Order, or hands references off to background work.
Bumping the go directive is one line of edit, but the migration is worth doing with a little care. A change in loop semantics can fix bugs (good) or break code that depended on the old behavior (rare, but worth catching).
Step by step:
1. Update `go.mod`.
2. Re-run all tests.
If any tests fail, the failure points to code that depended on the old shared-variable behavior. Most of the time that code was buggy and the new semantics revealed it, but occasionally the test itself is asserting the old behavior.
3. Run `go vet`.
go vet won't complain about the patterns the new rule fixed (those are now correct). It will still flag genuinely suspicious patterns, like capturing variables that aren't loop variables.
4. Audit goroutine spawns and deferred functions.
These are the two places where the old behavior caused the most pain. After bumping, go through any code that spawns goroutines inside a loop or defers cleanup inside a loop, and confirm that the new behavior is what you want. In almost every case, it will be.
5. Look for accidental shadowing.
The old p := p trick still works under the new semantics, it's just redundant. You don't have to remove it. But if you do, double-check that the shadow wasn't doing something more interesting (like converting a pointer to a value).
A before-and-after for a typical e-commerce migration.
Before (Go 1.20 module, with the shadow workaround):
(Order may vary.)
After (Go 1.22 module, shadow removed):
(Order may vary.)
The code is one line shorter and behaves the same. Multiply that by every loop in a large codebase and the cleanup is meaningful.
One caveat for library authors. If your module bumps to go 1.22 and you publish a release, callers on older Go toolchains may have trouble building it. Go's "forward compatibility" feature in 1.21+ handles most of that automatically (the toolchain downloads a matching version on demand), but it's still worth flagging in your release notes.
Even after bumping to go 1.22, a few patterns can still bite you. The new rule is narrow, and a careless read of the spec leads to wrong assumptions.
Pitfall 1: Assuming all captures are now safe.
The new rule fixes the loop variable, not every variable in the loop body. If you use a counter, an accumulator, or an outer slice that the loop mutates, closures still see the shared variable.
The item is safe per-iteration, but processed is shared. The goroutines may all see the full final slice, depending on timing. The fix is the same as before: capture explicitly with a local copy, pass as an argument, or use a synchronization primitive.
Pitfall 2: Assuming pointer aliasing changed.
If your code does &items[i] inside a loop, you're taking the address of a slice element, not the loop variable. That behavior hasn't changed. The pointer aliases the slice's backing array, which can be reallocated by future append calls.
The output happens to be correct here because notebook was copied into a new backing array when append grew the slice. But first no longer points at the live slice's element. Loop variable scoping doesn't fix this; it's a separate issue about slice internals.
Pitfall 3: Tests that depended on the old behavior.
Some test suites accidentally relied on the shared-variable bug. The classic case is a test that spawns several workers in a loop and asserts they all process the same final value. Under the new semantics, those workers process distinct values and the assertion fails.
When that happens, the test is usually the thing that needs fixing. The production code was probably correct under both semantics; only the assertion was confused. But occasionally the production code itself was depending on the bug, and the assertion was right. There's no automated way to tell; you have to read each case.
Pitfall 4: Mixing old and new modules.
A module on go 1.22 calling into a dependency on go 1.20 is fine. The dependency keeps its old semantics, your code gets the new ones. The cross-module boundary is clean because the loop happens inside one module or the other, never across the boundary.
What sometimes confuses people is when they read the dependency's source and see a pattern that looks buggy. It probably is, but the dependency's go.mod decides whether it acts buggy. If the dependency was written defensively (with shadow tricks), it works under both semantics.
The diagram shows the boundary. Your module's go directive only affects loops inside your module. Each dependency keeps the semantics its own go.mod declared.
Pitfall 5: Treating the change as a feature flag.
Some teams try to use the go directive as a way to enable or disable new behavior temporarily, like a feature flag. That's not what it's for. The directive should reflect the minimum Go version your code requires. Bumping it back down after a problem doesn't roll back the underlying issue; it just hides it. Better to find the offending loop, fix it, and keep moving forward.
10 quizzes