Range Variable & Goroutine Closure Bugs
You launch ten goroutines in a loop and every one prints 10 instead of 0 through 9.
This page explains loop variable capture, what Go 1.22 changed, and the defensive patterns that still matter for defer, nested closures, and libraries targeting older Go versions.
Summary
A closure captures variables by reference, not by loop-iteration snapshot.
Before Go 1.22, the for loop's single i or range value variable was reused each iteration, so goroutines started in the loop often observed the final value.
Go 1.22+ creates a new variable per iteration for for and range, fixing the common case, but defer inside loops, manual outer-variable closures, and code compiled with older go directives still require explicit copies.
Recipe
Quick-reference recipe card - copy-paste ready.
// Defensive (all versions): copy before goroutine
for _, item := range items {
item := item
go func() {
process(item)
}()
}
// Go 1.22+ per-iteration vars (default with go 1.22 in go.mod)
for i := range n {
go func() {
process(i) // each goroutine gets its own i
}()
}
// Pass as parameter (clearest)
for _, item := range items {
go func(it Item) {
process(it)
}(item)
}When to reach for this:
- Spawning goroutines inside any loop
errgroupor worker pools over a collection- Table tests with
t.Parallel()inside range - Scheduling callbacks that close over loop vars
- Maintaining libraries that must compile on Go 1.21 or older
Working Example
package main
import (
"fmt"
"sync"
)
func buggy(ids []int) {
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("buggy", id) // may print last id only on pre-1.22 semantics
}()
}
wg.Wait()
}
func fixed(ids []int) {
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
id := id
go func() {
defer wg.Done()
fmt.Println("fixed", id)
}()
}
wg.Wait()
}
func main() {
ids := []int{1, 2, 3}
buggy(ids)
fixed(ids)
}What this demonstrates:
- Closure captures
idby reference to the loop variable address id := idcreates a per-iteration binding safe for the goroutine- Parameter passing
go func(id int) { ... }(id)documents intent in code review - Always
Wait()or useerrgroupso main does not exit before goroutines run
Deep Dive
How It Works
- Loop variables live for the whole loop statement, not per iteration (pre-1.22)
- Goroutine scheduling means the loop may finish before children run, leaving only the final value
- Go 1.22 release notes: per-iteration loop variables for
forandrangeundergo 1.22language version deferin a loop still stacks defers until the function returns, not per iteration
Version and Tooling
| Situation | Risk | Mitigation |
|---|---|---|
go 1.22 in go.mod, default loop | Lower for direct capture | Still copy for public APIs |
go 1.21 or //go:build older | High | Always v := v or param |
defer inside loop | High (resource leak) | Inline function or collect defers |
t.Parallel() in subtests | Medium | Copy loop var before t.Run body |
Go Notes
// defer in loop: BAD - all defers run at function exit
for _, f := range files {
defer f.Close()
}
// GOOD - scoped function per iteration
for _, f := range files {
func() {
defer f.Close()
// work with f
}()
}Gotchas
- Assuming Go 1.22 fixed every closure bug - Outer variables from enclosing scopes still alias. Fix: copy anything mutable the closure reads.
deferin loops - Defers accumulate; files stay open until function end. Fix: nested function per iteration.- Table tests with parallel subtests -
t.Run(tc.name, func(t *testing.T) { t.Parallel(); use(tc) })needstc := tc. Fix: copy beforet.Run. - Worker pools reading loop index - Pool goroutines may run after loop completes. Fix: pass index as task field.
- Libraries without go.mod version bump - Consumers on 1.22 still see old behavior if library sets older language. Fix: defensive copy in library code.
- Closures in HTTP handlers registering callbacks - Registration closes over loop var from setup. Fix: pass parameters at registration time.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Parameter to goroutine go f(x)(item) | Clearest intent | Many parameters clutter call |
id := id shadow copy | Quick fix in loops | Shadowing confuses readers who do not know rule |
| Send on channel per item | Pipeline designs | Simple fan-out does not need channel |
| Sequential processing | Small N or ordering required | Need parallelism for throughput |
FAQs
Did Go 1.22 eliminate loop variable bugs entirely?
It fixed per-iteration for and range variables when the module uses Go 1.22 language version.
defer in loops, outer scope captures, and explicit go version directives still need care.
Why do all goroutines print the same value?
They share one variable's address.
The loop updates that variable before goroutines run.
Is passing the variable as a function argument better than shadow copy?
Both work.
Parameters make the capture explicit in the go statement line for reviewers.
Does this apply to errgroup?
Yes - g.Go(func() error { return work(item) }) needs per-iteration item with safe semantics.
What does copyloopvar linter do?
govet copyloopvar reports loop variables referenced by closures without copying.
Enable in golangci-lint for older language targets.
Are for loops with index affected?
Yes - for i := 0; i < n; i++ had the same single-variable reuse pre-1.22.
Go 1.22 creates per-iteration i when language version allows.
How do I test this defect?
Run a tight loop spawning goroutines that print IDs.
Use -count=100 stress; wrong capture shows uniform output.
Does range over channel have this bug?
Each receive gets a new value.
The classic bug is range over slice/map with goroutines inside the body.
What about sync.WaitGroup inside the loop?
WaitGroup usage is fine.
The bug is which variable value the goroutine closure observes.
Can I rely on GOEXPERIMENT loopvar?
That experiment preceded the 1.22 language change.
Use go 1.22 in go.mod instead of experiments in production modules.
Do generics loops differ?
No - same loop variable semantics apply.
How do I fix existing production code quickly?
Mechanical find: go func inside for with free variables.
Add v := v or parameter pass; run tests with race detector enabled.
Related
- Debugging Go: Observable Failures and Sharp Edges - closure symptom family
- Goroutine Leaks & Missing Context Cancellation - defer-in-loop leaks
- Data Race Scenarios & race Detector Fixes - races with shared loop state
- for Loop Variants & range Semantics - range forms
- Control Flow Best Practices - loop and goroutine rules
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).