Loop Variable Semantics

Understand how Go handles variables declared in for loops before and after the Go 1.22 change, the infamous loop variable capture pitfall, and how to write correct concurrent code.

A for loop in Go always introduces at least one variable—the index, the element, or a value derived from the loop clauses. How long that variable lives, and whether each iteration sees the same one or a fresh copy, has changed in Go 1.22. This document explains both the old shared-variable behavior, the bugs it created, the workarounds developers used for a decade, and the new per-iteration scoping that eliminates the whole class of mistakes.

The Pre-Go 1.22 Shared Variable Behavior

Before Go 1.22, all for loop forms reused the same variable instance across every iteration. The declaration happened once before the first iteration, and each subsequent iteration simply assigned a new value to that same variable. This is not a bug—it was a deliberate, documented part of the language specification.

In a for range loop, both the index and value variables are declared once. In a three‑component for i := 0; i < n; i++ loop, i is declared once and updated in place. The same holds for the for condition {} form: any variable used in the condition lives across all iterations unless explicitly re‑declared inside the loop body.

This design is efficient—it avoids allocating a new variable per iteration—but it creates a subtle and dangerous trap when the loop variable escapes the iteration via a closure, a goroutine, or a pointer.

Production Impact:

The shared loop variable has caused real production bugs in major systems. For example, a bug at Let’s Encrypt captured a pointer to the loop value across multiple function calls, silently reusing the same final value for all entries. The issue was traced back to exactly this semantic.

Capturing the Loop Variable in a Closure

Consider a loop that stores functions for later execution:

package main
import "fmt"
func main() {
    var prints []func()
    for i := 1; i <= 3; i++ {
        prints = append(prints, func() { fmt.Println(i) })
    }
    for _, fn := range prints {
        fn()
    }
}

When you run this with Go 1.21 or earlier (without the experiment), the output is:

3
3
3

Every closure captures the same variable i. By the time any closure actually runs, the loop has finished and i holds the final value 3. The closures never saw the intermediate values because they all reference the same location in memory.

Goroutines and the Loop Variable

The same problem appears with goroutines, even without a closure explicitly storing a function:

package main
import (
    "fmt"
    "time"
)
func main() {
    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
        }()
    }
    time.Sleep(time.Second)
}

The output is almost always c printed three times (the order may vary). The goroutine’s anonymous function captures the loop variable v. When the goroutines eventually execute, the loop has already assigned v its final value.

A common variant appears in tests that use t.Run with t.Parallel:

func TestAllEvenBuggy(t *testing.T) {
    testCases := []int{1, 2, 4, 6}
    for _, v := range testCases {
        t.Run("sub", func(t *testing.T) {
            t.Parallel()
            if v%2 != 0 {
                t.Fatal("odd v", v)
            }
        })
    }
}

Before Go 1.22, this test passes—incorrectly—because t.Parallel blocks until the loop finishes. Then every sub‑test checks v against the final value 6, so 1 (which is odd) is never caught. The test silently gives a false sense of safety.

Workarounds Before Go 1.22

For years, Go programmers learned to sidestep the shared‑variable trap with a small set of idioms. The most widespread was to create a new variable that shadows the loop variable inside each iteration:

for _, v := range values {
    v := v            // declares a new v scoped to this iteration
    go func() {
        fmt.Println(v)
    }()
}

This declaration creates a fresh v on every iteration. The closure captures that new variable, which is never overwritten. The cost is a single extra assignment, but the mental overhead is real—especially for newcomers who expect the language to just work.

Another approach passes the value explicitly as a parameter:

for _, v := range values {
    go func(val string) {
        fmt.Println(val)
    }(v)
}

Both patterns work, and both require the developer to recognize the problem in the first place. That’s a high bar for a language that prides itself on simplicity.

False Positives from Linters:

Automated checkers, including early versions of the loopclosure analyzer, flag any closure that captures a loop variable, even when the closure runs synchronously inside the same iteration. Many developers added unnecessary x := x lines to silence the warnings, creating noise without benefit.

The Go 1.22 Change: Per-Iteration Scope

Starting with Go 1.22, the compiler creates a new instance of each loop variable for every iteration. The loop variable is no longer a single mutable slot updated in place; it is a fresh variable whose lifetime is exactly one iteration.

This applies to all for loop forms:

  • for i, v := range collection { ... } — both i and v are per‑iteration.
  • for i := 0; i < n; i++ { ... } — the variable i is per‑iteration.
  • for condition { ... } — any new variables introduced in the init statement (if using for init; condition; post) are per‑iteration; variables declared outside the loop are not affected.

With the new semantics, the original closure and goroutine examples work as a developer would first expect. The code that stored closures now prints 1, 2, 3, and the goroutine example prints a, b, c in some order.

No More Shadow Workarounds:

In modules that opt into the new semantics, the v := v idiom becomes unnecessary for correctness. The compiler guarantees that each iteration gets its own variable, so a captured reference always points to the current iteration's value.

What the Change Does Not Do

The per‑iteration scope applies only to variables that are declared by the for statement itself. Variables declared inside the loop body, or outside the loop and mutated inside the body, are not affected.

For example:

var results []int
for _, v := range items {
    doubled := v * 2
    results = append(results, doubled)
}

Here doubled is declared inside the loop body. It already has per‑iteration scope because its declaration is inside the block. The change does not alter this; it only modifies the semantics of v in the for range.

Opt‑In via go.mod

The new semantics take effect only in packages that belong to a module declaring go 1.22 or later in its go.mod file. Existing code compiled with older Go versions or with a go.mod line below 1.22 retains the classic shared‑variable behavior. This per‑module gate allows a gradual, controlled migration.

You can preview the new semantics with Go 1.21 by setting the environment variable GOEXPERIMENT=loopvar:

GOEXPERIMENT=loopvar go test ./...

This applies the per‑iteration scoping to all loops, regardless of the go.mod version. It is the recommended way to test a codebase before upgrading the go directive.

Impact on Existing Code and Testing

The loop variable change is not a silent replacement—it can surface hidden bugs in tests and, rarely, in production code that accidentally depended on the old behavior.

The test example with t.Parallel earlier is the most common pattern that breaks. A test that passed incorrectly before 1.22 will start failing, which is actually the correct outcome: it was never testing what it claimed to test. The Go team intentionally accepted this breakage because it exposes real test gaps.

Tests May Fail After Upgrading:

Tests that use t.Parallel inside a loop and reference the loop variable are the primary failure mode. Run your test suite with GOEXPERIMENT=loopvar before updating the go directive to see which tests rely on the old semantics.

The go vet tool’s loopclosure analyzer was improved in Go 1.21 to detect when a loop variable is captured by a closure that runs after the iteration—for instance, inside a goroutine or a deferred call. It reports these cases so you can fix them before the semantic change makes the bug visible.

Migrating Existing Code

If you maintain a large codebase with a go.mod line below 1.22, these steps will help you adopt the new semantics safely:

  1. Run the experiment.
    Execute GOEXPERIMENT=loopvar go test ./... on your entire module. Any failures indicate code that was accidentally relying on the old behavior.

  2. Inspect failures.
    The vast majority will be tests like the t.Parallel example. Rewrite them to capture the per-iteration value explicitly or, if the test logic is correct under the new semantics, update the test expectations.

  3. Update go.mod.
    Once all tests pass with the experiment, change the go directive to 1.22 (or higher). From that point on, the compiler enforces per‑iteration scope for all packages in the module.

  4. Remove redundant workarounds.
    If your code has v := v lines whose only purpose was to work around the old semantics, they become optional. You may keep them for clarity, but they are no longer required for correctness.

Older Go Versions Unaffected:

Code compiled with a Go version older than 1.22, or with a go.mod line below 1.22, continues to exhibit the classic shared‑variable behavior. The fix is entirely opt‑in and does not change the meaning of existing, already‑compiled programs.

Summary

The loop variable semantics change in Go 1.22 removes the single most common, persistent source of subtle concurrency bugs in Go. Before 1.22, a for loop reused one variable across all iterations, causing closures and goroutines to capture the final value instead of the current one. The fix gives each iteration its own variable, making the intuitive code behave correctly without workarounds.

If you are starting a new module, set go 1.22 in your go.mod and write loops without fear of capture. If you maintain older code, use GOEXPERIMENT=loopvar to identify tests that depend on the old behavior, then upgrade the module’s go line when ready.