Go Loop Variables and Goroutines: The Closure Capture Bug and the Go 1.22 Fix

3 min readProgramming

Goroutines in for loops have a classic capture bug: all goroutines share one loop variable and race to read its final value. Go 1.22 changed the semantics. Here is what that means for existing and new code.

programminggogoroutinesconcurrencyclosures

The bug

func processRequests(queue chan *Request) {
    for req := range queue {
        go func() {
            process(req) // bug: all goroutines capture the same `req`
        }()
    }
}

Every goroutine launched here references the same req variable — the loop variable itself, not its current value. By the time a goroutine runs, the loop has likely advanced to the next iteration and req now points to a different request. In a tight loop, most goroutines will see the last value req held when the channel was drained.

The result: ten requests enqueued, ten goroutines launched, all process the final request. The first nine are silently dropped.

This is not a data race in the Go race detector sense — there is no concurrent write. It is a logical error: the closure captures a reference to the variable, and the variable's value has changed.

Why it happens

In Go (before 1.22), for loop variables are declared once and reused across iterations. The variable req has a single memory address throughout the loop. Each iteration overwrites the value at that address.

A goroutine closure captures a reference — the address. If the goroutine does not execute before the next iteration, it reads whatever value is at that address when it eventually runs.

for req := range queue {
    fmt.Printf("loop addr: %p\n", &req) // same address every iteration
    go func() {
        fmt.Printf("goroutine addr: %p, value: %v\n", &req, req)
    }()
}

All goroutines print the same address. They are sharing one variable.

The fixes (pre-1.22)

Pass the variable as a function argument. This creates a new binding for each goroutine at the call site:

for req := range queue {
    go func(r *Request) {
        process(r) // r is a distinct copy per goroutine
    }(req)
}

Shadow with a local variable. Idiomatic and explicit:

for req := range queue {
    req := req // new variable scoped to this iteration's block
    go func() {
        process(req)
    }()
}

The second req := creates a new variable that happens to share the name. The goroutine captures the inner req, which is not reused by the loop.

Both approaches work. The shadowing pattern is more common in idiomatic Go code because it avoids changing the function signature of the closure.

Go 1.22: the language changed

Go 1.22 (released February 2024) changed for loop variable semantics: each iteration now creates a new variable. The original bug no longer exists for code compiled with Go 1.22 or later with go 1.22 in the module's go.mod.

// In Go 1.22+, this is safe — each iteration's `req` is a distinct variable
for req := range queue {
    go func() {
        process(req)
    }()
}

The loop now behaves as if you wrote req := req at the top of each iteration body.

What this means for existing code

The Go 1.22 change is controlled by the go directive in go.mod. Code compiled with go 1.22 gets the new semantics. Code with go 1.21 or earlier keeps the old semantics even if compiled with a newer toolchain.

This is deliberate — Go preserves backward compatibility. Upgrading your go.mod to go 1.22 opts you into the new loop variable behavior. Do not upgrade blindly: if any code in your repo relied on the old behavior (unlikely but possible), the semantics change silently.

For code that must run on both old and new versions, keep the explicit shadowing. It is unambiguous regardless of Go version.

The bug is not limited to goroutines

Any closure that captures a loop variable and executes after the loop iteration ends has the same problem. Goroutines are the most common source because they are almost always deferred, but the same issue applies to callbacks, deferred functions, and stored closures:

// Same bug with stored callbacks
handlers := make([]func(), 0, 5)
for i := 0; i < 5; i++ {
    handlers = append(handlers, func() {
        fmt.Println(i) // all print 5 (the final value)
    })
}
for _, h := range handlers {
    h()
}

Fix: shadow i inside the loop, or pass it as an argument.

When to use goroutines vs channels for this pattern

The goroutine-per-item fan-out pattern is common but not always the right tool. A channel-based worker pool gives you bounded concurrency:

func processRequests(queue chan *Request, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for req := range queue { // each goroutine reads its own req
                process(req)
            }
        }()
    }
    wg.Wait()
}

Here there is no capture bug: each goroutine's req is a range variable local to that goroutine's loop. Because multiple goroutines read from the same channel, each read yields a distinct value — no sharing.

This pattern also bounds the number of goroutines to workers. Unbounded goroutine fan-out (one per queue item) can exhaust memory or overwhelm a downstream service under high load.

Closure capture semantics in Go

GotchaGo Language

A Go closure captures variables by reference, not by value. The closure holds a pointer to the variable's storage location. If that location is shared across loop iterations (as loop variables were before Go 1.22), all closures that captured it see the same value.

Prerequisites

  • Go closures
  • goroutines and the scheduler
  • sync.WaitGroup

Key Points

  • Closures capture references (addresses), not values. The value at the address may change.
  • Loop variables before Go 1.22 have one address for all iterations. After 1.22, each iteration has its own.
  • The `go` directive in go.mod controls which semantics your code uses.
  • Passing as a function argument or shadowing with a new variable both work in all Go versions.
  • Worker pool pattern eliminates the capture problem entirely for queue-processing workloads.

You are running Go 1.21 with go 1.21 in go.mod. Which of the following correctly ensures each goroutine processes its own request?

easy

A for-range loop over a slice of *Request launches one goroutine per element.

  • Ago func() { process(req) }()
    Incorrect.This captures req by reference. All goroutines share the same loop variable and may process the wrong request.
  • Breq := req; go func() { process(req) }()
    Correct!Shadowing req with a new variable scoped to the iteration block gives each goroutine its own copy. This works in all Go versions.
  • Cgo func(r *Request) { process(r) }(req)
    Correct!Passing req as an argument creates a distinct parameter for each goroutine at call time. Also correct — both this and shadowing solve the problem.
  • DNo fix is needed since the Go scheduler runs goroutines immediately
    Incorrect.The Go scheduler does not guarantee when goroutines run relative to loop iteration. Even if a goroutine ran immediately, the next iteration could overwrite req before it returns.

Hint:Two of these are correct. Both create a new binding for each goroutine rather than sharing the loop variable.