Skip to content
gohacks.dev
Basics

Closures in Go and What They Really Capture From Scope

A Go closure captures variables, not values, so every closure from one scope shares them. See counters, the loop variable change, heap escape and memoization.

8 min read
Summarize in AI
Flat illustration of an editor window full of code.

A closure is a function that remembers where it was written. In Go that memory is not a snapshot of values. It is a live connection to the variables themselves, and that one detail is behind every closure bug and every closure trick. Counters, middleware, memoization and callbacks all use it deliberately, and the classic loop-variable bug used it accidentally for a decade. This article demonstrates what is captured, shows how two closures share state, runs the loop case under both language versions, and ends with the cost, because captured variables have to live somewhere. It is a companion to functions with multiple returns, follows pointers in Go, and is part of the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

A function that remembers

The standard demonstration is a counter. The outer function declares a variable and returns a function that changes it:

main.goGo
package main

import "fmt"

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	a := counter()
	b := counter()

	fmt.Println(a(), a(), a())
	fmt.Println(b())

	x := 1
	show := func() { fmt.Println("x is", x) }
	x = 2
	show()
}
Text
1 2 3
1
x is 2

Each call to counter creates a fresh n, so a and b have separate state: three increments on a and a 1 for b. The n outlives counter because the returned function still refers to it. The last three lines make the capture rule visible. show was created when x was 1, and it prints x is 2, because it holds the variable and not the value. If you wanted a snapshot, copy the value into a new variable first: snapshot := x, and refer to that.

Closures from one scope share variables

Because they capture the variable, two closures that mention the same variable share it. That is how you build a small object without declaring a type:

main.goGo
package main

import "fmt"

func newAccount(opening int) (deposit func(int), balance func() int) {
	total := opening
	deposit = func(n int) { total += n }
	balance = func() int { return total }
	return deposit, balance
}

func main() {
	deposit, balance := newAccount(100)
	deposit(50)
	deposit(25)
	fmt.Println(balance())

	deposit2, balance2 := newAccount(0)
	deposit2(5)
	fmt.Println(balance2(), balance())
}
Text
175
5 175

The two returned functions both close over total. A deposit made through one is visible through the other, and a second account has its own total. This is a legitimate pattern for a small amount of private state. The limit shows quickly: there is no way to add a third operation without changing the signature, and no way to print or inspect the state. When the state has more than one field, or the operations are more than two, a struct with methods is the clearer choice. I think of closures as the right tool for one function's worth of memory, and a struct as the right tool for an object.

The loop variable, before and after Go 1.22

The most famous closure bug is the loop. For years, a for loop declared its variable once, and every closure created in the body captured that single variable. By the time they ran, the loop had finished and the variable held its final value. Go 1.22 changed the language so that each iteration has its own copy, and the behavior depends on the go line in the module's go.mod. Here is the same source run under both:

main.goGo
package main

import "fmt"

func main() {
	var funcs []func() int
	for i := 0; i < 3; i++ {
		funcs = append(funcs, func() int { return i })
	}
	for _, f := range funcs {
		fmt.Print(f(), " ")
	}
	fmt.Println()
}
Text
3 3 3
main.goGo
package main

import "fmt"

func main() {
	var funcs []func() int
	for i := 0; i < 3; i++ {
		funcs = append(funcs, func() int { return i })
	}
	for _, f := range funcs {
		fmt.Print(f(), " ")
	}
	fmt.Println()
}
Text
0 1 2

The first module declares go 1.21, and the closures all see the variable after its final increment. The second declares go 1.25, and each closure sees the value from its own iteration. The toolchain version does not matter, only the module's declared language version, which is why upgrading the go line is the entire migration. If you maintain an older module, the pre-1.22 fix was to shadow the variable inside the loop, i := i, and you will still see that line in older code. It is harmless in new code and unnecessary. The full story of the change, including go vet's loopclosure check, is in the loop variable change in Go 1.22.

Closures and goroutines

The same capture rule applies when the closure runs on another goroutine, with the added problem that the capturing variable may be written while the goroutine reads it, which is a data race. Starting goroutines in a loop is correct under Go 1.22 semantics when each one needs its own loop variable:

main.goGo
package main

import (
	"fmt"
	"sort"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	var results []int

	for i := range 5 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			results = append(results, i*i)
			mu.Unlock()
		}()
	}
	wg.Wait()

	sort.Ints(results)
	fmt.Println(results)
}
Text
[0 1 4 9 16]

Each goroutine captured its own i. They also all captured results and mu, which are shared on purpose, and the mutex serializes the appends. The sort at the end is there because the goroutines finish in any order, and printing without it would give a different arrangement from run to run. If you ever see a shared captured variable written by two goroutines with no lock, the race detector (go test -race) is the tool that finds it. WaitGroups and the mistakes everyone makes covers the Add and Done placement in this snippet.

Where the captured variables live

A local variable normally lives on the stack and disappears when the function returns. A variable captured by a closure that outlives the function cannot. The compiler detects that and moves the variable to the heap, which means an allocation. The escape analysis output says so:

main.goGo
package main

import "fmt"

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func sum(xs []int) int {
	total := 0
	add := func(v int) { total += v }
	for _, x := range xs {
		add(x)
	}
	return total
}

func main() {
	c := counter()
	fmt.Println(c(), c(), sum([]int{1, 2, 3}))
}
Text
# example.test/lab
example.test/lab/main.go:5:6: can inline counter
example.test/lab/main.go:7:9: can inline counter.func1
example.test/lab/main.go:15:9: can inline sum.func1
example.test/lab/main.go:13:6: can inline sum
example.test/lab/main.go:17:6: inlining call to sum.func1
example.test/lab/main.go:23:14: inlining call to counter
example.test/lab/main.go:7:9: can inline main.counter.func1
example.test/lab/main.go:24:15: inlining call to main.counter.func1
example.test/lab/main.go:24:20: inlining call to main.counter.func1
example.test/lab/main.go:24:27: inlining call to sum
example.test/lab/main.go:24:13: inlining call to fmt.Println
example.test/lab/main.go:15:9: can inline main.sum.func2
example.test/lab/main.go:24:27: inlining call to main.sum.func2
example.test/lab/main.go:6:2: moved to heap: n
example.test/lab/main.go:7:9: func literal escapes to heap
example.test/lab/main.go:13:10: xs does not escape
example.test/lab/main.go:23:14: func literal does not escape
example.test/lab/main.go:24:13: ... argument does not escape
example.test/lab/main.go:24:15: ~r0 escapes to heap
example.test/lab/main.go:24:20: ~r0 escapes to heap
example.test/lab/main.go:24:27: ~r0 escapes to heap
example.test/lab/main.go:24:33: []int{...} does not escape

The output marks n as moved to the heap and the returned function literal as escaping, because both outlive counter. In sum the closure add never leaves the function, so total stays on the stack and the closure is inlined. That difference is the practical cost model. A closure used locally, in a sort callback or a helper inside a function, is essentially free. A closure that is returned, stored or passed to a goroutine costs an allocation for its captured variables, which is small and not free. You rarely need to avoid closures for performance, and when a profile shows allocations in a hot path, look for a closure created per call and consider making it a method on a struct that you reuse.

Memoization, a closure that holds a cache

A closure can wrap a function with state of its own, such as a cache. This memoizer stores results by argument:

main.goGo
package main

import "fmt"

func memoize(f func(int) int) func(int) int {
	cache := map[int]int{}
	return func(n int) int {
		if v, ok := cache[n]; ok {
			return v
		}
		v := f(n)
		cache[n] = v
		return v
	}
}

func main() {
	calls := 0
	slowSquare := func(n int) int {
		calls++
		return n * n
	}

	fast := memoize(slowSquare)
	fmt.Println(fast(9), fast(9), fast(9), fast(4))
	fmt.Println("underlying calls:", calls)
}
Text
81 81 81 16
underlying calls: 2

Four calls to fast made two real computations. The cache lives as long as the returned function does. Two cautions come with this pattern. The map is not safe for concurrent use, so a memoizer used from several goroutines needs a lock, and the cache has no size limit, so memoizing a function whose arguments are unbounded is a slow memory leak. For anything beyond a small function with a handful of distinct inputs, use a real cache with eviction.

When a closure is the wrong shape

If the captured state grows past a couple of variables, give it a type, because a struct can be printed, tested, copied deliberately and documented. If a function is passed through many layers and you cannot tell which variables it holds, the closure is hiding dependencies, and a method on a visible type is easier to read. If a long-lived closure captures a large object, such as a request or a buffer, it keeps that object alive until the closure is garbage collected, a leak that is easy to cause with callbacks stored in a global registry. And in a deferred function, the capture rule is what makes a defer see the final value of a variable, which the defer article explains. The Go specification on function literals states that closures "may refer to variables defined in a surrounding function" and that those variables are shared, and the Go blog's Fixing For Loops in Go 1.22 gives the history of the loop change.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.