Closures in Go
How closures capture variables, how they work under the hood, and common patterns like counters and memoization in Go
A closure is not a separate kind of function — it is simply a function value that references variables from its enclosing scope. Because the function carries those references, it can read and modify the variables even after the outer function has returned. In Go, any function literal can form a closure when it uses names declared outside its own body.
func makeGreeter(greeting string) func(string) string {
return func(name string) string {
return greeting + ", " + name
}
}
The inner anonymous function returned by makeGreeter uses greeting, a parameter of the outer function. That function literal is a closure: it closes over greeting so the variable remains accessible long after makeGreeter finishes.
How Closures Capture State
When a function literal references a variable from an outer scope, the Go compiler must keep that variable alive for as long as the closure might need it. Ordinarily, local variables live on the goroutine stack and are discarded when the function returns. A captured variable breaks that rule.
Through escape analysis, the compiler detects that the variable’s lifetime extends beyond the function’s stack frame. It moves the variable — and sometimes the closure function itself — to the heap. The closure then holds a reference to that heap‑allocated variable, which persists until nothing references it anymore. The garbage collector reclaims the memory later, automatically.
Closures hold references, not snapshots:
A closure captures the variable itself, not the value the variable had at creation time. If the variable changes later, the closure sees the latest value. This is why loop‑variable captures often surprise newcomers.
Understanding the reference‑based capture is essential. The closure does not copy the variable when the function literal is defined; it binds to the same storage location. Any change made by the closure is visible everywhere that variable is accessible, and vice‑versa.
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
c1 := counter()
c2 := counter()
fmt.Println(c1()) // 1
fmt.Println(c1()) // 2
fmt.Println(c2()) // 1
Here c1 and c2 each reference a distinct count variable because counter is called twice, creating a fresh count on each invocation. The closures are independent. Both survive after the outer function returns because they keep their count alive on the heap.
Why Use Closures Instead of Other Approaches
Closures let you build functions that remember private state without exposing it through a struct or global variable. They are especially useful when you need:
- Stateful functions that maintain context between calls — like counters, accumulators, or connection pools.
- Encapsulation where the only way to modify the state is through the returned function.
- Function factories that generate specialised behaviour by fixing some parameters now and leaving others for later.
- Callbacks and handlers that need access to request‑scoped data without passing it explicitly through every layer.
A closure is often simpler than defining a struct with methods when the state is small and the behaviour is a single function. You get the same encapsulation with less ceremony. However, when multiple operations need to share the same state, a struct with methods tends to be clearer because the state fields are explicit and can be inspected during debugging.
Common Pitfall – Loop Variable Capture
Go reuses the same loop variable across iterations. If a closure inside the loop body captures that variable, every closure will reference the same memory location. The value they see is the one the loop variable held when the closure eventually executes, not when it was created.
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // all goroutines likely print 5
}()
}
Loop variable capture causes data races and wrong results:
The behaviour above is a bug. The loop variable i is captured by reference, and its value changes before the goroutines run. This may also trigger the race detector because multiple goroutines read a variable that is still being written by the loop.
The fix is to give each closure its own copy of the loop variable. In modern Go (1.22+) the loop variable semantics changed so that each iteration gets a fresh variable. When working with older code or for absolute clarity, you can shadow the variable inside the loop body:
for i := 0; i < 5; i++ {
i := i // shadow: new variable per iteration
go func() {
fmt.Println(i) // 0,1,2,3,4 in some order
}()
}
Passing i as a parameter to the anonymous function has the same effect, because function arguments are copied by value.
Common Closure Patterns
Closures shine in a few recurring scenarios. Each of the following patterns uses a closure to carry state across calls without exposing that state to the rest of the program.
Counter
The simplest closure pattern: return a function that increments and returns a private integer. Every call to the factory creates an independent counter.
func newCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
next := newCounter()
fmt.Println(next()) // 1
fmt.Println(next()) // 2
The state count is invisible outside the returned function. You cannot reset it accidentally from another part of the code.
Adder (Stateful Accumulator)
A slightly more flexible version lets the caller supply a value to add. The closure remembers the running total.
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
pos := adder()
fmt.Println(pos(3)) // 3
fmt.Println(pos(5)) // 8
Each closure returned by adder gets its own sum variable. Calling adder again creates a separate accumulator starting from zero.
Memoization
Memoization caches the results of expensive function calls. A closure can hold a cache map that is invisible to the caller, so the function appears purely computational even though it uses mutable state internally.
func memoizedFib() func(int) int {
cache := make(map[int]int)
var fib func(int) int
fib = func(n int) int {
if n <= 1 {
return n
}
if val, ok := cache[n]; ok {
return val
}
cache[n] = fib(n-1) + fib(n-2)
return cache[n]
}
return fib
}
fib := memoizedFib()
fmt.Println(fib(40)) // 102334155, computed quickly
The outer function creates the cache and returns the recursive closure fib. Because fib references itself, the assignment requires the variable to be declared first. The cache persists across calls and makes repeated lookups nearly instantaneous. Without the closure, you would need a global cache or pass a cache parameter through every call.
Memoization in action:
If you run fib(40) once, the first call takes some time. A second call to fib(40) returns instantly because the result is already in the cache. The state lives inside the closure, not in any global variable.
Function Factories
A closure can pre‑configure behaviour by fixing some arguments now and accepting the rest later. This is sometimes called partial application.
func multiplier(factor int) func(int) int {
return func(n int) int {
return n * factor
}
}
double := multiplier(2)
triple := multiplier(3)
fmt.Println(double(5)) // 10
fmt.Println(triple(5)) // 15
double and triple each close over a different factor variable. The outer function is a factory that stamps out specialised multiplication functions. This pattern avoids repeating the same configuration logic everywhere.
Encapsulating Private State
Because the closed‑over variable is only accessible through the returned function, closures enforce a form of data hiding. You can expose a limited, controlled interface without exporting any fields.
func newBankAccount(initial int) (func(int) int, func() int) {
balance := initial
deposit := func(amount int) int {
if amount <= 0 {
return balance
}
balance += amount
return balance
}
current := func() int {
return balance
}
return deposit, current
}
deposit, balance := newBankAccount(100)
deposit(50)
fmt.Println(balance()) // 150
The variable balance exists only inside the closures. There is no way to change it except through deposit, and no way to read it except through balance. You cannot accidentally write balance = 0 elsewhere in the package. This pattern is a lightweight alternative to a struct with unexported fields and exported methods.
Don't forget the variable lives on the heap:
Because balance escapes to the heap, it will not be freed until the returned closures are no longer reachable. If you create many such accounts and keep them alive, memory usage grows accordingly. This is usually irrelevant for small programs, but it matters in long‑running servers that generate many short‑lived closures.
How Closures Behave with Multiple Variables
A closure can capture any number of variables from its outer scope. All of them are held by reference. If the outer function creates several closures that share the same variable, they all see the same value and can interfere with one another.
func multiCounter() (func() int, func() int) {
count := 0
inc := func() int {
count++
return count
}
dec := func() int {
count--
return count
}
return inc, dec
}
inc, dec := multiCounter()
fmt.Println(inc()) // 1
fmt.Println(inc()) // 2
fmt.Println(dec()) // 1
Both inc and dec close over the same count. Calling one affects the other’s view of the variable. This is intentional here but can cause subtle bugs when the sharing is accidental — for instance, when a closure inside a loop captures a variable that is also used by another closure.
Closures and the Go Runtime
When a variable escapes to the heap, the closure and the captured variables form an allocation unit. The garbage collector tracks these references. The compiler decides at build time which variables escape; you can inspect its decisions with go build -gcflags="-m".
Understanding this mechanism helps you avoid unnecessary allocations. A closure that captures a large struct by reference keeps the entire struct alive even if you only need one field. If performance matters, capture only what you need, or pass the relevant data as a parameter instead of closing over the whole object.
Closures versus Structs with Methods
A struct with methods and a closure that returns multiple functions can both hold state. The choice is often a matter of clarity. A struct makes the state fields explicit and allows you to inspect them with a debugger or fmt verbs. A closure hides the fields, which can be cleaner when you want true privacy. There is no universal performance rule — the compiler can inline and optimise both forms — so pick the one that makes the code easier to reason about.
Summary
Closures are ordinary functions that carry their own private variables. They appear wherever you need a function with memory: stateful iterators, caches, middleware that captures request‑scoped data, or configuration factories. The Go compiler automatically moves captured variables to the heap so they survive the outer function's stack frame.
The single most important thing to remember is that closures capture variables by reference. That is why they can share state, and it is also why loop‑variable bugs happen. When you see a closure inside a loop, check whether each closure truly needs its own copy of the iteration variable.