The Goroutine Closure Variable Capture Gotcha

Fix Go goroutine closure variable capture by declaring a new variable inside the loop to snapshot the current iteration value.

The whiteboard problem

You write a loop to process a list of URLs. You spawn a goroutine for each one. You expect to see the index or the URL printed in order. Instead, every goroutine prints the last item in the list. The program does not crash. It just quietly does the wrong thing. This is the classic closure capture trap.

How closures actually capture state

Go closures capture variables by reference. When a function literal mentions a variable from its surrounding scope, it does not take a snapshot of the value. It keeps a pointer to the memory address where that variable lives. If the surrounding code changes that variable later, the closure sees the change.

In a for loop, the loop variable is declared once before the loop starts. Every iteration reassigns the same memory slot. All the goroutines you spawn inside the loop end up pointing to that single slot. By the time the scheduler actually runs them, the loop has finished and the variable holds its final value.

Think of the loop variable as a single whiteboard in a room. Each iteration writes a new number on it. The goroutines are interns told to go look at the whiteboard and do something with the number. If the interns are slow, they all arrive after the loop finishes and read the final number. Giving each intern a sticky note with the current number solves the problem.

Closures capture addresses, not snapshots.

The minimal trap

Here is the simplest version of the bug. It spawns three goroutines inside a loop and waits for them to finish.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	// i is declared once before the loop starts
	for i := 0; i < 3; i++ {
		wg.Add(1)
		// The closure captures the address of i, not its current value
		go func() {
			defer wg.Done()
			// All three goroutines read from the same memory slot
			fmt.Println(i)
		}()
	}
	// Wait blocks until all goroutines call Done
	wg.Wait()
}

The main thread runs the loop rapidly. It sets i to zero, spawns a goroutine, sets i to one, spawns another, sets i to two, spawns the last one, then increments i to three and exits the loop. The goroutines sit in the scheduler queue. When they finally wake up, they dereference the captured pointer and read three. You get 3 3 3 on standard output.

This is a data race. The main thread writes to i while the goroutines read from it. The Go memory model considers this undefined behavior. The program might print 0 1 2 on one run, 3 3 3 on another, or panic if the scheduler interleaves differently.

Why Go 1.22 changed the rules

The Go team recognized that this pattern caused more confusion than educational value. Starting with Go 1.22, the compiler implicitly creates a new loop variable for each iteration. The language spec now guarantees that each iteration gets its own memory slot. You no longer need to manually shadow the variable to fix the bug.

The compiler still watches your code. If you write a closure that captures the loop variable in a way that triggers the old behavior, the compiler rejects the program with loop variable i captured by func literal. This warning forces you to acknowledge the capture. In Go 1.22+, the warning is informational because the runtime already handles it correctly, but it keeps your mental model aligned with the spec.

The race detector is your co-pilot. Run it before you ship.

Real-world pattern: processing a slice

You rarely write bare for i := 0; i < n; i++ loops in production code. You usually iterate over slices with range. The same capture rules apply to range variables.

package main

import (
	"fmt"
	"sync"
)

// ProcessTasks runs a batch of string jobs concurrently
func ProcessTasks(jobs []string) {
	var wg sync.WaitGroup
	// task is a loop variable reused across iterations
	for _, task := range jobs {
		wg.Add(1)
		// Closure captures the address of task
		go func() {
			defer wg.Done()
			// Prints the final value of task after the loop ends
			fmt.Println("working on:", task)
		}()
	}
	wg.Wait()
}

If you run this on Go 1.21 or earlier, every goroutine prints the last job in the slice. On Go 1.22+, each goroutine prints the correct job because the compiler shadows task automatically.

Explicit shadowing remains useful when you want to document intent or support older toolchains. You create a new variable inside the loop body that shadows the outer one. The inner variable is initialized with the current iteration value. The closure captures the inner variable, which lives in a fresh memory slot for that iteration.

package main

import (
	"fmt"
	"sync"
)

// ProcessTasksExplicit shadows the range variable to guarantee per-iteration capture
func ProcessTasksExplicit(jobs []string) {
	var wg sync.WaitGroup
	for _, task := range jobs {
		wg.Add(1)
		// task := task creates a new variable scoped to this iteration
		task := task
		go func() {
			defer wg.Done()
			// Closure captures the inner task, which never changes
			fmt.Println("working on:", task)
		}()
	}
	wg.Wait()
}

Shadowing is explicit. Explicit is better than implicit.

Pitfalls and runtime behavior

The capture trap rarely causes panics. It causes silent logical errors. A background worker processes the wrong database ID. An HTTP handler returns the wrong cache key. The bug hides until load testing exposes the race condition.

Run your code with the race detector to catch it early. Execute go run -race main.go or go test -race ./.... The detector instruments memory accesses and prints a stack trace when concurrent reads and writes collide. It does not fix the bug. It only tells you where to look.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If you are processing long-running tasks, pass a context.Context as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The context is plumbing. Run it through every long-lived call site.

When you discard a return value, use _ intentionally. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible.

Trust the race detector. Argue logic, not formatting.

When to shadow, when to pass, when to let the compiler handle it

Use explicit shadowing when you need to support Go versions before 1.22 or when you want to document capture intent for future maintainers. Use function parameters when passing loop values to a named helper function instead of an inline closure. Use a channel when you need to feed work into a bounded worker pool rather than spawning unbounded goroutines. Use the default loop variable behavior in Go 1.22+ when you want concise code without manual shadowing. Use a closure only when you actually need to capture surrounding state.

Goroutines are cheap. Data races are not.

Where to go next