If you have written a few Go programs, you have probably passed a function to another function or returned one from a helper. Sooner or later you meet a closure, often without realizing it. Closures sound academic, but the idea is simple, and once it clicks you will use it daily: for counters, middleware, retries, lazy initialization, and more.

This guide builds the concept from the ground up, with runnable examples and the pitfalls that trip up most newcomers.

What you need to know first

Two Go features make closures possible:

  1. Functions are first-class values. You can assign them to variables, pass them as arguments, and return them from other functions.
  2. Anonymous functions. You can define a function without a name, right where you need it.
package main import "fmt" func main() { greet := func(name string) { fmt.Println("Hello,", name) } greet("Gopher") // Hello, Gopher } 

Here greet is a variable holding a function. Nothing special yet.

What is a closure?

A closure is a function value that remembers the variables from the scope where it was created, even after that scope has finished executing.

A useful analogy: imagine a function leaving home for a trip and packing a backpack with the variables it will need. Wherever it travels, the backpack goes with it. That backpack is the closure.

Your first closure: a counter

package main import "fmt" func newCounter() func() int { count := 0 // lives in newCounter's scope return func() int { count++ // the inner function "captures" count return count } } func main() { next := newCounter() fmt.Println(next()) // 1 fmt.Println(next()) // 2 fmt.Println(next()) // 3 other := newCounter() // a brand-new, independent counter fmt.Println(other()) // 1 } 

Walk through what happens:

  • newCounter creates a local variable count and returns an anonymous function.
  • Normally, count would disappear when newCounter returns. But the returned function still uses it, so Go keeps it alive.
  • Every call to next() updates the same count.
  • Each call to newCounter() creates a fresh count, so next and other do not interfere with each other.

You don't need to manage memory manually here. Go's compiler performs escape analysis and moves count to the heap when needed, and the garbage collector cleans it up once nothing references it.

Closures capture variables, not values

This is the single most important detail. A closure holds a reference to the variable, not a snapshot of its value at creation time.

func main() { x := 10 show := func() { fmt.Println("x is", x) } show() // x is 10 x = 99 show() // x is 99 } 

The closure sees the updated value because it shares the variable with the surrounding code. The same sharing works in the other direction: a closure can modify the outer variable.

func main() { total := 0 add := func(n int) { total += n } add(5) add(7) fmt.Println(total) // 12 } 

Practical use cases

1. Function factories

Generate specialized functions from a general recipe.

func multiplier(factor int) func(int) int { return func(n int) int { return n * factor } } func main() { double := multiplier(2) triple := multiplier(3) fmt.Println(double(10)) // 20 fmt.Println(triple(10)) // 30 } 

factor is the configuration baked into each returned function.

2. Middleware in web servers

This is probably the most common production use of closures in Go. Middleware wraps a handler and adds behavior such as logging, authentication, or rate limiting.

func logging(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { start := time.Now() next(w, r) // call the original handler log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start)) } } func main() { hello := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello!") } http.HandleFunc("/", logging(hello)) log.Fatal(http.ListenAndServe(":8080", nil)) } 

The returned function remembers next, which is exactly the closure behavior we have been discussing.

3. Custom sorting

The standard library's sort.Slice takes a closure that decides ordering.

people := []struct { Name string Age int }{{"Ana", 31}, {"Ben", 24}, {"Cara", 28}} sort.Slice(people, func(i, j int) bool { return people[i].Age < people[j].Age }) 

The closure reaches into people without it being passed in as a parameter.

4. Hiding state (encapsulation)

Because the captured variable is unreachable from outside, closures give you private state without defining a struct.

func newAccount(initial int) (deposit func(int), balance func() int) { bal := initial deposit = func(n int) { bal += n } balance = func() int { return bal } return } func main() { deposit, balance := newAccount(100) deposit(50) fmt.Println(balance()) // 150 // There is no way to touch `bal` directly. } 

5. Deferred cleanup

defer pairs naturally with closures, especially when you need to react to the final state of a variable.

func process() (err error) { defer func() { if err != nil { log.Println("process failed:", err) } }() // ... work that may set err ... return nil } 

Common pitfalls

Pitfall 1: the loop variable trap (and the Go 1.22 change)

Before Go 1.22, every iteration of a for loop reused the same loop variable. Closures created inside the loop all captured that single variable, which led to a famous bug:

// Go 1.21 and earlier var funcs []func() for i := 0; i < 3; i++ { funcs = append(funcs, func() { fmt.Println(i) }) } for _, f := range funcs { f() // printed 3, 3, 3 (not 0, 1, 2) } 

By the time the closures ran, the loop had finished and i was 3.

Since Go 1.22, each iteration gets its own copy of the loop variable, so the same code prints 0, 1, 2. This applies to modules whose go.mod declares go 1.22 or newer.

If you maintain older code, the classic fix is to shadow the variable:

for i := 0; i < 3; i++ { i := i // new variable per iteration funcs = append(funcs, func() { fmt.Println(i) }) } 

The same issue shows up with goroutines, so check your go.mod version:

var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func() { defer wg.Done() fmt.Println(i) // safe on Go 1.22+, buggy before }() } wg.Wait() 

Passing the value as an argument works on every version:

go func(n int) { defer wg.Done() fmt.Println(n) }(i) 

Pitfall 2: data races

Since closures share variables, two goroutines modifying the same captured variable is a race condition.

counter := 0 var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ // RACE: unsynchronized access }() } wg.Wait() 

Protect shared state with a sync.Mutex, or use sync/atomic:

var mu sync.Mutex // inside the goroutine: mu.Lock() counter++ mu.Unlock() 

Run your tests with go test -race to catch these early.

Pitfall 3: accidental memory retention

A closure keeps everything it references alive. If a long-lived closure captures a large slice or struct, that memory cannot be garbage-collected until the closure itself is unreachable. If you only need a small piece of data, copy it into a local variable first and capture that instead.

Closures vs. structs with methods

A closure is not always the right tool. A rough guide:

Situation Prefer
Small, single-purpose behavior (a counter, a sort comparator, a handler wrapper) Closure
Several related operations sharing state Struct with methods
State that must be tested, inspected, or serialized Struct with methods
One-off callback passed to a function Closure

Both approaches are valid. Choose the one that makes the code easier to read.

Quick recap

  • A closure is a function that remembers variables from the scope where it was defined.
  • It captures variables by reference, so changes are visible on both sides.
  • Each call to a factory function creates an independent set of captured variables.
  • Closures power middleware, function factories, custom sorting, and private state.
  • Watch out for loop variables (fixed in Go 1.22+), data races, and memory retention.

Final thoughts

Closures are one of those features that feel like magic at first and then become second nature. The key mental model is simple: a function plus the variables it carries with it. Start by writing a counter and a middleware, then experiment with the pitfalls above. Once you can predict the output without running the code, you have understood closures.

Happy coding!