Before Go 1.22, the language specification allocated loop variables once per loop statement rather than per iteration. This single memory location was reused for every iteration, with only its value changing sequentially. When a closure captured this variable by reference—common in goroutines launched inside the loop—all closures shared the identical memory address. Consequently, every closure observed the final value assigned to that address once the loop completed.
Go 1.22 introduced per-iteration scoping, meaning each iteration instantiates a fresh variable with a distinct memory address. This ensures that closures capture the specific value for that iteration rather than a shared mutable location. This change eliminated one of the most common concurrency pitfalls while maintaining backward compatibility for code that did not depend on the address identity of loop variables.
A data processing service needed to fan out sensor readings to worker goroutines for parallel validation before storage.
The team initially implemented the fan-out using idiomatic closure syntax:
readings := []SensorReading{{ID: 1}, {ID: 2}, {ID: 3}} for _, r := range readings { go func() { validate(r.ID) // Critical bug: All goroutines validate ID 3 }() }
Upon deployment, logs revealed that every worker processed the same last record, while earlier records were completely ignored, causing data loss.
Solution 1: Variable shadowing. This approach introduces a new variable inside the loop body to shadow the iteration variable, forcing a distinct stack allocation for each iteration. Pros: It immediately fixes the capture issue without requiring changes to function signatures. Cons: It relies on a subtle lexical trick that appears syntactically redundant to reviewers and provides no compiler protection if accidentally removed during refactoring.
Solution 2: Parameter passing. This method explicitly passes the value as an argument to the closure, ensuring evaluation occurs at each iteration rather than at call time. Pros: It is unambiguous, portable across all Go versions, and makes data dependencies explicit and self-documenting. Cons: It requires restructuring the closure to accept parameters, which adds minimal but non-zero syntactic overhead.
Solution 3: Infrastructure upgrade. Migrating the entire fleet to Go 1.22+ to leverage the new per-iteration variable semantics. Pros: It eliminates the root cause at the language level, allowing for cleaner idiomatic code. Cons: It requires coordinated infrastructure changes and offers no relief for legacy codebases that must remain on older toolchains.
The team selected Solution 2 for immediate deployment. This decision ensured the code behaved correctly across all compiler versions and did not rely on subtle shadowing tricks that could be accidentally removed.
After implementation, each goroutine received its distinct sensor ID, the pipeline processed all records correctly, and the system remained stable during the subsequent upgrade to Go 1.22.
Why does taking the address of a for-range iteration variable in Go 1.22+ still not allow direct modification of the original slice elements?
Even with per-iteration variables, the iteration variable holds a copy of the slice element, not the element itself. Taking its address yields a pointer to this ephemeral copy rather than the entry in the underlying array. Since each iteration's variable is a distinct location but contains a copy of the value, modifying *(&v) affects only the temporary copy, which is discarded when the iteration ends. To modify the source slice, you must use index syntax: for i := range slice { slice[i].Field = NewValue }.
Does the per-iteration scoping change in Go 1.22 introduce performance overhead or additional heap allocations compared to the pre-1.22 variable reuse model?
No. The Go compiler optimizes per-iteration variables to reside on the stack or in registers when closures do not escape to the heap. The semantic change affects lexical scoping and pointer identity, not the allocation strategy or runtime performance of the loop itself. Loops without closures exhibit identical performance characteristics before and after the change.
How did the variable reuse behavior in pre-1.22 Go affect traditional three-clause for loops compared to for-range loops?
The behavior was identical across all for loop variants. Both for i := 0; i < n; i++ and for _, v := range m reused the same memory address for their iteration variables across all iterations. Candidates often incorrectly assume the stale closure bug was unique to range loops, but closures capturing the index i in a three-clause loop suffered the same issue, printing the final value of i rather than the expected iteration value. Go 1.22 resolved this uniformly for all loop types.