Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 062 / 100

Go 1.22 loop variable change: what developers should know

A classic bug, fixed.

Expona Admin7 min read

The loop variable change that stopped a thousand bugs

For decades, Go developers have worked around a subtle gotcha in loop semantics. If you've ever debugged a goroutine-in-loop issue or been bitten by closure capture in a for loop, you know the frustration. Go 1.22 fixes this at the language level, and understanding what changed—and why it matters—is essential for writing reliable production code.

Each iteration gets a new variable

Before Go 1.22, every iteration of a loop reused the same loop variable. This meant that if you took the address of that variable or captured it in a closure, you'd get references to the same memory location, updated with each iteration's value. In Go 1.22, the language semantics changed: each iteration now gets its own fresh copy of the loop variable.

Let's see this in action with a concrete example:

go
// Go 1.21 and earlier
package main

import "fmt"

func main() {
    values := []int{1, 2, 3}
    var pointers []*int

    for _, v := range values {
        pointers = append(pointers, &v)
    }

    for _, p := range pointers {
        fmt.Println(*p) // Prints: 3, 3, 3
    }
}

In Go 1.21, this prints 3, 3, 3 because all three pointers reference the same v variable, which ends up holding the value 3 after the loop completes. With Go 1.22, the same code prints 1, 2, 3 because each iteration receives its own v.

This change applies to all loop constructs:

  • for range loops
  • Traditional for i := 0; i < n; i++ loops (though the loop counter itself remains unchanged)
  • Labeled break and continue statements

The key insight is that this is a semantic change, not a performance hit. The Go compiler is smart enough to optimize away unnecessary allocations in practice. You're getting correctness without cost.

Closures in loops behave as expected

Closures that capture loop variables now work intuitively. In production systems, we frequently use loops with closure callbacks—think middleware chains, event handlers, or functional transformations. The old behavior was a constant source of bugs.

Consider a typical scenario: processing a batch of user IDs and registering callbacks.

go
// Go 1.21 and earlier
func registerUserHandlers(userIDs []int) []func() int {
    var handlers []func() int

    for _, id := range userIDs {
        handlers = append(handlers, func() int {
            return id
        })
    }

    return handlers
}

func main() {
    handlers := registerUserHandlers([]int{10, 20, 30})
    
    for _, h := range handlers {
        fmt.Println(h()) // Prints: 30, 30, 30
    }
}

All closures captured the same id variable, which ended up as 30. The workaround was cumbersome:

go
// Go 1.21 workaround
for _, id := range userIDs {
    id := id // Create a new variable in each iteration
    handlers = append(handlers, func() int {
        return id
    })
}

With Go 1.22, the loop variable shadowing is automatic. Each closure captures its own iteration's variable:

go
// Go 1.22+
func registerUserHandlers(userIDs []int) []func() int {
    var handlers []func() int

    for _, id := range userIDs {
        handlers = append(handlers, func() int {
            return id
        })
    }

    return handlers
}

func main() {
    handlers := registerUserHandlers([]int{10, 20, 30})
    
    for _, h := range handlers {
        fmt.Println(h()) // Prints: 10, 20, 30
    }
}

This is particularly valuable in frameworks and libraries. If you're building an HTTP router, event emitter, or task scheduler, you no longer need to document the workaround or worry that users will hit this classic bug.

Goroutines in loops are safer

Spawning goroutines inside loops has always been a minefield. Before Go 1.22, you'd write something like this and immediately regret it:

go
// Go 1.21 and earlier – WRONG
func processItems(items []string) {
    for _, item := range items {
        go func() {
            // Process item
            fmt.Println(item) // Probably not the item you expect
        }()
    }
}

The goroutine captures the item variable, but by the time it runs, the loop has likely finished and item holds the last value. Production bugs from this pattern have caused countless race conditions and data processing errors in real-world systems.

The old fix was to pass the variable as a parameter or shadow it:

go
// Go 1.21 workaround
for _, item := range items {
    item := item // Shadow the variable
    go func() {
        fmt.Println(item)
    }()
}

// Or pass as parameter
for _, item := range items {
    go func(i string) {
        fmt.Println(i)
    }(item)
}

With Go 1.22, the loop variable is automatically new for each iteration, so goroutines naturally capture the right value:

go
// Go 1.22+ – Now safe
func processItems(items []string) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(item) // Correct value
        }()
    }
    wg.Wait()
}

This is a game-changer for concurrent code. Worker pools, batch processors, and parallel data pipelines become less error-prone by default.

Requires go 1.22 in go.mod

The behavior change is gated by the Go version specified in your go.mod file. If your module declares go 1.21, you get the old semantics. If you declare go 1.22 or later, you get the new behavior.

To adopt the new loop semantics, update your go.mod:

go
module github.com/yourorg/yourapp

go 1.22

Then run go mod tidy to update any dependencies. The Go toolchain respects the version declaration strictly, so you have control over when to adopt this change.

In practice, we recommend upgrading at a natural module boundary. If you're working on a new service or a major version bump, it's a good time to move to Go 1.22. For existing services, plan a migration as part of regular maintenance. The risk of behavioral changes is low because the new semantics are almost always what you intended in the first place.

Common mistakes and how to avoid them

Mistake 1: Assuming old workarounds are still needed. If you migrate to Go 1.22 but leave in the old shadow-variable workarounds, your code still works correctly but becomes unnecessarily verbose. After upgrading, remove the id := id patterns where they're no longer needed.

Mistake 2: Mixing Go versions in a monorepo. If you have multiple modules at different Go versions, the semantics vary per module. Make sure your CI and documentation are clear about which version applies where. A common practice is to upgrade all modules in lockstep.

Mistake 3: Forgetting to test loop-based concurrency after upgrading. Although the new semantics are safer, test your goroutine-in-loop code thoroughly. Integration tests are especially valuable to confirm that parallel workers are processing the right data.

Mistake 4: Relying on the change without reading the release notes. Go 1.22 also includes for range int syntax and other improvements. Understanding the full scope of changes helps you use the language more effectively.

Key takeaways

  • Go 1.22 gives each loop iteration its own loop variable, eliminating a classic source of bugs in closures and goroutines.
  • Update go.mod to go 1.22 to opt into the new semantics; older code using workarounds will continue to work but can be simplified.
  • Closures and goroutines now capture loop variables correctly without extra shadowing or parameter passing.
  • The change is backward compatible at the module level; plan upgrades as part of regular maintenance cycles.
  • Test thoroughly after upgrading, especially code that spawns concurrent tasks in loops, to confirm correctness in your specific use cases.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles