Skip to main content

Closures

What Is a Closure?​

A closure is a function value that references variables from its surrounding scope. When a function literal is created inside another function, it "closes over" the variables it uses β€” it captures them by reference, not by value. The closure and those variables share the same memory location, so mutations made through the closure are visible from the enclosing scope, and vice versa.

The mechanics are simple: every function literal in Go that refers to a variable defined outside its own body forms a closure over that variable. The captured variable lives as long as any closure referencing it is reachable β€” the garbage collector extends the lifetime automatically.

package main

import "fmt"

func makeCounter() func() int {
count := 0 // captured variable
return func() int { // closure β€” captures count by reference
count++
return count
}
}

func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3

// Each call to makeCounter creates an independent count variable.
other := makeCounter()
fmt.Println(other()) // 1 β€” independent from counter
fmt.Println(counter()) // 4 β€” continues its own sequence
}

Output:

1
2
3
1
4

count is allocated on the heap (the compiler detects the escape), so it outlives the makeCounter call frame. The two closures (counter and other) each have their own count β€” they are entirely independent.


Closure Creation Patterns​

Encapsulating State​

Closures provide lightweight stateful objects without defining a struct or interface:

package main

import (
"fmt"
"strings"
)

// accumulator returns a function that appends words and reports the running total.
func accumulator(sep string) (add func(string), report func() string) {
var words []string

add = func(w string) {
words = append(words, w)
}
report = func() string {
return fmt.Sprintf("(%d words) %s", len(words), strings.Join(words, sep))
}
return
}

func main() {
add, report := accumulator(", ")
add("Go")
add("is")
add("simple")
fmt.Println(report()) // (3 words) Go, is, simple

add("and")
add("fun")
fmt.Println(report()) // (5 words) Go, is, simple, and, fun
}

Parameterised Behaviour via Factories​

Factories capture parameters and bake them into the returned function:

package main

import (
"fmt"
"strings"
)

// hasPrefix returns a predicate that checks string prefix.
func hasPrefix(prefix string) func(string) bool {
return func(s string) bool {
return strings.HasPrefix(s, prefix)
}
}

// multiplier returns a function fixed to a specific factor.
func multiplier(factor float64) func(float64) float64 {
return func(x float64) float64 { return x * factor }
}

// rateLimiter returns a function that allows at most n calls.
func rateLimiter(n int) func() bool {
remaining := n
return func() bool {
if remaining <= 0 {
return false
}
remaining--
return true
}
}

func main() {
isGo := hasPrefix("Go")
fmt.Println(isGo("Golang")) // true
fmt.Println(isGo("Python")) // false

celsius := multiplier(1.0)
toFahrenheit := func(c float64) float64 { return celsius(c)*9/5 + 32 }
fmt.Printf("0Β°C = %.1fΒ°F\n", toFahrenheit(0))
fmt.Printf("100Β°C = %.1fΒ°F\n", toFahrenheit(100))

try := rateLimiter(3)
for i := 0; i < 5; i++ {
fmt.Printf("attempt %d: allowed=%v\n", i+1, try())
}
}

Output:

true
false
0Β°C = 32.0Β°F
100Β°C = 212.0Β°F
attempt 1: allowed=true
attempt 2: allowed=true
attempt 3: allowed=true
attempt 4: allowed=false
attempt 5: allowed=false

The Loop Closure Trap​

This is one of the most common Go pitfalls. When you create closures inside a loop and capture the loop variable, all closures share the same variable β€” and by the time they execute, the loop has already finished.

package main

import "fmt"

func main() {
// WRONG: all closures capture the same variable i.
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
funcs[i] = func() {
fmt.Println(i) // all print 5
}
}
for _, f := range funcs {
f()
}
// Output: 5 5 5 5 5 (not 0 1 2 3 4)
}

Fix 1 β€” capture with a new variable inside the loop body:

package main

import "fmt"

func main() {
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
i := i // shadow i with a new variable scoped to this iteration
funcs[i] = func() {
fmt.Println(i)
}
}
for _, f := range funcs {
f()
}
// Output: 0 1 2 3 4
}

Fix 2 β€” pass the value as a function argument:

package main

import "fmt"

func main() {
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
funcs[i] = func(n int) func() { // factory closes over n, not i
return func() { fmt.Println(n) }
}(i)
}
for _, f := range funcs {
f()
}
// Output: 0 1 2 3 4
}

Fix 3 β€” Go 1.22+ range variable semantics (each iteration gets its own variable):

Starting with Go 1.22, the loop variable i in a for loop is scoped per iteration, so the trap no longer exists in modern Go code. The first broken example above produces 0 1 2 3 4 under Go 1.22 with GOEXPERIMENT=loopvar or when the module's go directive is go 1.22 or later.

//go:build go1.22

package main

import "fmt"

func main() {
// Under go 1.22+: each iteration binds a fresh i.
funcs := make([]func(), 5)
for i := range 5 { // range integer syntax (Go 1.22)
funcs[i] = func() { fmt.Println(i) }
}
for _, f := range funcs {
f()
}
// Output: 0 1 2 3 4
}

Understanding the older semantics is still important when reading pre-1.22 code or working on modules that have not updated their go directive.


State Encapsulation with Closures​

Closures can replace small structs when state and behaviour are tightly coupled and the type does not need to be named:

package main

import (
"fmt"
"math"
)

// newStack returns push, pop, and peek functions backed by a shared slice.
func newStack[T any]() (push func(T), pop func() (T, bool), peek func() (T, bool)) {
var data []T

push = func(v T) {
data = append(data, v)
}

pop = func() (T, bool) {
if len(data) == 0 {
var zero T
return zero, false
}
top := data[len(data)-1]
data = data[:len(data)-1]
return top, true
}

peek = func() (T, bool) {
if len(data) == 0 {
var zero T
return zero, false
}
return data[len(data)-1], true
}

return
}

// newRollingAverage tracks the mean of the last n values.
func newRollingAverage(n int) func(float64) float64 {
window := make([]float64, 0, n)
return func(v float64) float64 {
if len(window) == n {
window = window[1:]
}
window = append(window, v)
sum := 0.0
for _, x := range window {
sum += x
}
return sum / float64(len(window))
}
}

func main() {
push, pop, peek := newStack[int]()
push(10)
push(20)
push(30)

if v, ok := peek(); ok {
fmt.Println("peek:", v) // 30
}
for {
v, ok := pop()
if !ok {
break
}
fmt.Println("pop:", v)
}

avg := newRollingAverage(3)
readings := []float64{10, 20, 30, 40, 50}
for _, r := range readings {
fmt.Printf("reading=%.0f rolling avg=%.2f\n", r, avg(r))
}
_ = math.Pi
}

Output:

peek: 30
pop: 30
pop: 20
pop: 10
reading=10 rolling avg=10.00
reading=20 rolling avg=15.00
reading=30 rolling avg=20.00
reading=40 rolling avg=30.00
reading=50 rolling avg=40.00

Practical Example: Counter Generator​

package main

import (
"fmt"
"sync"
)

// SafeCounter returns thread-safe increment and value functions.
func SafeCounter(start int) (inc func(), dec func(), val func() int, reset func()) {
var mu sync.Mutex
n := start

inc = func() {
mu.Lock()
n++
mu.Unlock()
}
dec = func() {
mu.Lock()
n--
mu.Unlock()
}
val = func() int {
mu.Lock()
defer mu.Unlock()
return n
}
reset = func() {
mu.Lock()
n = start
mu.Unlock()
}
return
}

func main() {
inc, dec, val, reset := SafeCounter(0)

var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
inc()
}()
}
wg.Wait()
fmt.Println("After 100 increments:", val()) // 100

for i := 0; i < 30; i++ {
dec()
}
fmt.Println("After 30 decrements:", val()) // 70

reset()
fmt.Println("After reset:", val()) // 0
}

Output:

After 100 increments: 100
After 30 decrements: 70
After reset: 0

Practical Example: Memoization​

Memoization caches expensive function results. A closure over a map provides the cache without any global state:

package main

import (
"fmt"
"time"
)

// memoize wraps a func(int) int with a result cache.
func memoize(fn func(int) int) func(int) int {
cache := make(map[int]int)
return func(n int) int {
if v, ok := cache[n]; ok {
return v
}
v := fn(n)
cache[n] = v
return v
}
}

// slowFibonacci simulates a slow computation.
func slowFibonacci(n int) int {
if n <= 1 {
return n
}
return slowFibonacci(n-1) + slowFibonacci(n-2)
}

func main() {
// Memoize the recursive fibonacci so repeated subproblems are cached.
// Note: memoize wraps the top-level call; internal recursive calls
// still recompute unless you use a self-referencing closure.
fastFib := memoize(slowFibonacci)

inputs := []int{10, 20, 30, 10, 20, 30} // second set should be instant
for _, n := range inputs {
start := time.Now()
result := fastFib(n)
elapsed := time.Since(start)
fmt.Printf("fib(%2d) = %8d (%v)\n", n, result, elapsed)
}

// Self-memoizing fibonacci using a closure that references itself
var fibMemo func(int) int
cache := make(map[int]int)
fibMemo = func(n int) int {
if n <= 1 {
return n
}
if v, ok := cache[n]; ok {
return v
}
v := fibMemo(n-1) + fibMemo(n-2)
cache[n] = v
return v
}

start := time.Now()
fmt.Printf("\nfib(40) = %d (%v)\n", fibMemo(40), time.Since(start))
start = time.Now()
fmt.Printf("fib(40) = %d (%v, cached)\n", fibMemo(40), time.Since(start))
}

Output:

fib(10) =       55  (elapsed varies)
fib(20) = 6765 (elapsed varies)
fib(30) = 832040 (elapsed varies)
fib(10) = 55 (0s)
fib(20) = 6765 (0s)
fib(30) = 832040 (0s)

fib(40) = 102334155 (0s)
fib(40) = 102334155 (0s, cached)

Practical Example: Middleware Chain with Closures​

Closures compose naturally into middleware chains where each layer wraps the next:

package main

import (
"fmt"
"strings"
"time"
)

// Handler processes a request string and returns a response string.
type Handler func(req string) string

// withTiming wraps a Handler and prints the execution duration.
func withTiming(label string, next Handler) Handler {
return func(req string) string {
start := time.Now()
resp := next(req)
fmt.Printf("[TIMING] %s: %v\n", label, time.Since(start))
return resp
}
}

// withUppercase converts the request to uppercase before passing it on.
func withUppercase(next Handler) Handler {
return func(req string) string {
return next(strings.ToUpper(req))
}
}

// withPrefix prepends a fixed string to every response.
func withPrefix(prefix string, next Handler) Handler {
return func(req string) string {
return prefix + next(req)
}
}

// coreHandler is the innermost business logic.
func coreHandler(req string) string {
return fmt.Sprintf("processed: %q", req)
}

func main() {
// Compose the chain: timing β†’ uppercase β†’ prefix β†’ core
handler := withTiming("total",
withUppercase(
withPrefix("RESULT: ",
coreHandler,
),
),
)

responses := []string{"hello world", "go closures", "middleware"}
for _, req := range responses {
resp := handler(req)
fmt.Printf("req=%q resp=%q\n\n", req, resp)
}
}

Output:

[TIMING] total: ...
req="hello world" resp="RESULT: processed: \"HELLO WORLD\""

[TIMING] total: ...
req="go closures" resp="RESULT: processed: \"GO CLOSURES\""

[TIMING] total: ...
req="middleware" resp="RESULT: processed: \"MIDDLEWARE\""

Expert Tips​

Closures capture variables, not values. If you need to snapshot a value at the time the closure is created, copy it into a new variable inside the closure scope, or pass it as a function argument.

Avoid closures over loop variables in pre-1.22 Go. The most reliable fix is i := i inside the loop body. It is idiomatic and understood by every Go developer. After upgrading to Go 1.22 (updating the go directive in go.mod), this shadowing is no longer necessary.

Closures hold references β€” think about their lifetimes. A closure that captures a large slice or a *sql.DB prevents the garbage collector from reclaiming those resources while the closure is reachable. If closures are stored in long-lived data structures (caches, event buses), ensure you have a way to remove them.

Use closures for lightweight state; use structs for richer state. When you need more than two or three state variables, or when the type needs to be named and exported, a struct with methods is more readable and easier to test.

Named returns + deferred closure = clean error wrapping. A deferred function can inspect and modify a named return value, which is a clean pattern for wrapping errors or cleaning up resources conditionally on success/failure.