Closures
Functions that capture and retain their lexical environment - the factory pattern and the goroutine-loop closure pitfall.
A closure is a function that references variables from the scope in which it was defined. The function and the captured variables form a bundle - the closure retains a live reference to the variable, not a copy of its value at the moment of creation.
A factory function returns a closure that captures a variable from the enclosing scope. Each call to the factory produces a new, independent closure with its own captured state.
package main
import "fmt"
func makeAdder(x int) func(int) int {
return func(y int) int {
return x + y
}
}
func main() {
add5 := makeAdder(5)
add10 := makeAdder(10)
fmt.Println(add5(3)) // 8
fmt.Println(add10(3)) // 13
fmt.Println(add5(7)) // 12 - add5 and add10 are independent
}A closure can capture and mutate a variable in the enclosing scope. Each call to the returned function updates the shared counter.
package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3
other := makeCounter() // independent counter
fmt.Println(other()) // 1
}The goroutine-loop closure bug: on Go versions before 1.22, a goroutine launched inside a loop captures the loop variable itself - a single shared variable - not a snapshot of its value at that iteration. By the time the goroutines run, the loop has finished and every goroutine sees the last value. Go 1.22 changed loop-variable scoping so each iteration gets its own variable, fixing the bug automatically. The safe fix for all versions: pass the loop variable as an argument to the goroutine function literal.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// BUG (Go < 1.22): every goroutine captures the same `i` variable.
// All likely print 5 (or whatever i is when they run).
// On Go 1.22+ each iteration gets its own variable, so this is no longer buggy.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("buggy:", i) // captures variable, not value (pre-1.22)
}()
}
wg.Wait()
fmt.Println("---")
// FIX: pass i as an argument - a copy is made at call time.
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println("fixed:", n) // each goroutine gets its own n
}(i)
}
wg.Wait()
}In production
The goroutine-loop closure bug is one of the most common sources of data races in real Go services. Before Go 1.22, the loop variable was shared across all iterations - capturing it in a goroutine produced a race between the goroutine read and the loop increment. Go 1.22 changed loop-variable scoping so each iteration gets its own variable, fixing the bug automatically - but services on older toolchain versions (or using -gcflags=-lang=go1.21 for compatibility) still hit it. The defensive fix - passing the loop variable as an argument - works on all Go versions and is worth making a team habit until you can guarantee Go 1.22+ everywhere.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free