Go by Example

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