The loop variable capture trap
You write a loop to process a list of URLs. You spawn a goroutine for each one to fetch the content. You run the code. Every single goroutine prints the last URL in the list. You stare at the screen. The logic looks perfect. The loop runs. The goroutines start. But they all see the same variable. This is the loop variable capture gotcha. It haunted Go developers for over a decade until version 1.22 changed the rules.
How closures and loops interact
A closure is a function value that remembers variables from the surrounding scope. In Go, closures capture variables by reference, not by value. The closure holds a pointer to the variable's memory address. If the variable changes later, the closure sees the new value.
Before Go 1.22, the compiler optimized for loops by reusing the same memory address for the loop variable across all iterations. The variable wasn't created fresh every time. It was updated in place. When a goroutine captured the loop variable, it captured the address. By the time the goroutine ran, the loop had often finished, and the variable held the final value.
Think of a single sticky note. You write a number on it. You pass the note to a friend. You erase the number, write a new one, and pass the note to another friend. All friends look at the note at the end. They all see the last number. The note is the variable. The friends are the goroutines.
Closures capture the variable address. The value changes, but the address stays the same.
The minimal trap
Here's the trap. A loop spawns goroutines that reference the loop variable. In pre-1.22 code, this produces incorrect output.
package main
import (
"fmt"
"sync"
)
func main() {
// Slice of items to process concurrently.
items := []string{"alpha", "beta", "gamma"}
var wg sync.WaitGroup
for i, item := range items {
// Increment wait group before starting work.
wg.Add(1)
// Goroutine captures the loop variable 'item'.
// Pre-1.22: 'item' is the same variable across all iterations.
go func() {
defer wg.Done()
// Reads 'item' when the goroutine actually runs.
// By then, the loop has likely finished.
fmt.Println(item)
}()
}
// Block until all goroutines complete.
wg.Wait()
}
# output:
gamma
gamma
gamma
The code compiles without errors. The goroutines run. But they all read the final value of item. The loop variable lives in one spot. The range clause updates it. The closure captures the address. When the goroutine executes, it dereferences that address. The address holds "gamma".
The classic for loop has the same issue
The problem isn't unique to range. Any loop variable captured in a closure suffers from the same behavior. The classic for loop declares the variable once before the loop body.
package main
import "fmt"
func main() {
// Classic for loop.
// The variable 'i' is declared once before the loop starts.
for i := 0; i < 3; i++ {
// Goroutine captures 'i' by reference.
// 'i' is updated in place across iterations.
go func() {
// Prints the value of 'i' when this runs.
// The loop has likely finished by then.
fmt.Println(i)
}()
}
// Sleep to let goroutines finish.
// In real code, use a WaitGroup.
time.Sleep(100 * time.Millisecond)
}
The variable i is declared outside the loop body. It persists across iterations. The closure captures i. The result is the same: all goroutines see the final value. The range loop just hides the declaration, making the trap harder to spot.
Why the compiler did this
The original design prioritized performance and consistency. Allocating a new variable every iteration added overhead. The Go team believed variable reuse was consistent with the rest of the language. Variables are addresses. Closures capture addresses. The issue was that range semantics felt different to developers. The implicit variable declaration made it easy to assume a new variable was created each time.
The confusion outweighed the micro-optimization. Modern compilers can optimize allocations efficiently. Goroutine scheduling dominates the cost of a loop. The Go team changed the spec in version 1.22. Now range creates a new variable for each iteration. The behavior matches developer expectations. The performance impact is negligible.
Realistic code and the fix
Production code avoids the trap by passing the loop variable as an argument. This creates a fresh copy for each call.
package main
import (
"fmt"
"net/http"
"sync"
)
// fetchStatus checks the status of a URL.
// It takes the URL as a parameter to avoid capture issues.
func fetchStatus(url string) {
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
// Close body to free resources.
defer resp.Body.Close()
fmt.Printf("%s returned %d\n", url, resp.StatusCode)
}
func main() {
urls := []string{
"https://golang.org",
"https://google.com",
"https://github.com",
}
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
// Pass 'url' as an argument to the function.
// This creates a copy of the value for this call.
go func() {
defer wg.Done()
fetchStatus(url)
}()
}
wg.Wait()
}
The helper function receives url by value. Each call gets its own copy. The goroutine captures the argument, which is stable. This pattern works in all Go versions. It also improves readability. The function signature documents what data the goroutine needs.
Run gofmt on your code. The community expects consistent formatting. Most editors apply it automatically when you save. Trust the tool. Argue logic, not indentation.
The manual fix: shadowing the variable
If you cannot extract a helper function, you can create a local variable inside the loop. This shadows the loop variable. The closure captures the local copy.
package main
import (
"fmt"
"sync"
)
func main() {
items := []string{"alpha", "beta", "gamma"}
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
// Create a new variable scoped to this iteration.
// The closure captures this local copy, not the loop variable.
item := item
go func() {
defer wg.Done()
fmt.Println(item)
}()
}
wg.Wait()
}
The assignment item := item declares a new variable named item that shadows the loop variable. The right side reads the loop variable. The left side creates a fresh variable for this iteration. The closure captures the fresh variable. This works in pre-1.22 code. It's verbose, but it's safe.
Pitfalls and debugging
The compiler doesn't stop you from capturing loop variables. The code compiles. The bug is silent. The output is wrong. Finding the cause requires careful inspection.
Add print statements inside the loop and inside the goroutine. You'll see the loop variable changes while the goroutine waits. The race detector might help if there's a data race, but capture isn't always a race. It's a logic error. The race detector checks for unsynchronized access. If all goroutines read the variable after the loop finishes, there's no race. The data is consistent, just wrong.
go vet is your friend. It analyzes code for common mistakes. It warns when a loop variable is captured by a closure.
# go vet output:
loop variable i captured by func literal
The warning tells you exactly where the problem is. Run go vet before every commit. It catches capture bugs that the compiler ignores.
When a closure captures a variable, the compiler must ensure the variable lives as long as the closure. If the closure runs in a goroutine, the variable escapes to the heap. This allocation happens at runtime. It's a small cost, but it exists. The escape analysis determines where variables live. Captured variables often move from the stack to the heap. This is a performance detail to keep in mind when optimizing hot loops.
Trust go vet. It catches the silent bugs before they hit production.
Decision: when to use what
Use a function argument when you spawn a goroutine inside a loop. Passing the variable as a parameter creates a fresh copy for each call. This is the idiomatic Go pattern. It works in all versions and improves readability.
Use a local variable assignment when you cannot extract a helper function. Assign the loop variable to a new variable inside the loop body before the closure. This shadows the loop variable and captures a stable copy.
Use Go 1.22 or later when you want the language to fix this automatically. The compiler now creates a new variable for each iteration in range loops. The code works as expected without manual fixes.
Use a channel when you have a producer-consumer pattern. Send values through the channel instead of capturing loop variables. Channels provide synchronization and data flow. They eliminate capture issues entirely.
Upgrade to Go 1.22. The language now handles loop variables the way you expect.