Range Variable & Goroutine Closure Bugs
You launch ten goroutines in a loop and every one prints 10 instead of 0 through 9.
Search across all documentation pages
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.
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.
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:
errgroup or worker pools over a collectiont.Parallel() inside rangepackage 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:
id by reference to the loop variable addressid := id creates a per-iteration binding safe for the goroutinego func(id int) { ... }(id) documents intent in code reviewWait() or use errgroup so main does not exit before goroutines runfor and range under go 1.22 language versiondefer in a loop still stacks defers until the function returns, not per iteration| 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 |
// 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
}()
}defer in loops - Defers accumulate; files stay open until function end. Fix: nested function per iteration.t.Run(tc.name, func(t *testing.T) { t.Parallel(); use(tc) }) needs tc := tc. Fix: copy before t.Run.| 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 |
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.
They share one variable's address.
The loop updates that variable before goroutines run.
Both work.
Parameters make the capture explicit in the go statement line for reviewers.
Yes - g.Go(func() error { return work(item) }) needs per-iteration item with safe semantics.
govet copyloopvar reports loop variables referenced by closures without copying.
Enable in golangci-lint for older language targets.
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.
Run a tight loop spawning goroutines that print IDs.
Use -count=100 stress; wrong capture shows uniform output.
Each receive gets a new value.
The classic bug is range over slice/map with goroutines inside the body.
WaitGroup usage is fine.
The bug is which variable value the goroutine closure observes.
That experiment preceded the 1.22 language change.
Use go 1.22 in go.mod instead of experiments in production modules.
No - same loop variable semantics apply.
Mechanical find: go func inside for with free variables.
Add v := v or parameter pass; run tests with race detector enabled.
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).
Reviewed by Chris St. John·Last updated Jul 19, 2026