Skip to content

Closures & Anonymous Functions Intermediate

Introduction

In Go, functions are first-class values — they can be assigned to variables, passed as arguments, and returned from other functions. An anonymous function (function literal) is a function defined without a name. A closure is a function literal that captures variables from its enclosing scope. Closures are the engine behind many idiomatic Go patterns: middleware chains, functional options, iterators, and goroutine-based concurrency. Understanding how closures capture variables — by reference, not by value — is critical for avoiding subtle bugs.


Syntax & Usage

Function Literals (Anonymous Functions)

// Assign to a variable
greet := func(name string) string {
    return "Hello, " + name
}
fmt.Println(greet("Alice")) // "Hello, Alice"

// Pass as an argument
strings.Map(func(r rune) rune {
    return r + 1
}, "HAL") // "IBM"

// Return from a function
func makeGreeter(greeting string) func(string) string {
    return func(name string) string {
        return greeting + ", " + name
    }
}
hello := makeGreeter("Hello")
fmt.Println(hello("Bob")) // "Hello, Bob"

Closures Capture Variables (By Reference!)

A closure captures the variable itself, not the value at the time of creation. The closure and the enclosing function share the same variable.

func counter() func() int {
    count := 0
    return func() int {
        count++ // modifies the outer variable
        return count
    }
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3

// Each call to counter() creates a NEW count variable
c2 := counter()
fmt.Println(c2()) // 1 (independent)

Closures Share State

func counterPair() (increment func(), get func() int) {
    count := 0
    increment = func() { count++ }
    get = func() int { return count }
    return
}

inc, get := counterPair()
inc()
inc()
inc()
fmt.Println(get()) // 3 -- both closures share the same count

The Loop Variable Closure Bug (Pre-Go 1.22)

This was one of Go's most notorious gotchas. Go 1.22 fixed this by changing loop variable scoping.

// PRE-GO 1.22: BUG -- all closures capture the SAME variable
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
    funcs[i] = func() {
        fmt.Println(i) // captures variable i, not its current value
    }
}
for _, f := range funcs {
    f() // Pre-1.22: prints 5, 5, 5, 5, 5 (i is 5 after loop ends)
}       // Go 1.22+: prints 0, 1, 2, 3, 4 (each iteration has its own i)
// PRE-1.22 FIX: create a new variable in each iteration
for i := 0; i < 5; i++ {
    i := i // shadow with a new variable (classic Go idiom)
    funcs[i] = func() {
        fmt.Println(i) // now captures a per-iteration copy
    }
}

// ALTERNATIVE FIX: pass as parameter
for i := 0; i < 5; i++ {
    funcs[i] = func(n int) func() {
        return func() { fmt.Println(n) }
    }(i) // i is copied into parameter n
}

Loop Variable Capture (Pre-1.22)

Before Go 1.22, loop variables were scoped to the entire loop, so closures captured a single shared variable. This caused bugs in goroutines, deferred calls, and function slices. Go 1.22+ creates a new variable per iteration. If your project uses Go < 1.22, always use i := i inside the loop.


Immediately Invoked Function Expressions (IIFE)

// Execute a function literal immediately
result := func(a, b int) int {
    return a + b
}(3, 4)
fmt.Println(result) // 7

// Common use: isolate scope in init or setup
func main() {
    config := func() Config {
        data, err := os.ReadFile("config.json")
        if err != nil {
            log.Fatal(err)
        }
        var cfg Config
        json.Unmarshal(data, &cfg)
        return cfg
    }()
    // config is available, intermediate variables are gone
}

Closures in Goroutines

// CORRECT: pass the variable as a parameter
for _, url := range urls {
    go func(u string) { // u is a copy of url
        resp, err := http.Get(u)
        // ...
    }(url)
}

// Go 1.22+: this is also correct (each iteration has its own url)
for _, url := range urls {
    go func() {
        resp, err := http.Get(url) // safe in Go 1.22+
        // ...
    }()
}

Goroutine + Closure + Shared Variable

count := 0
for i := 0; i < 1000; i++ {
    go func() {
        count++ // DATA RACE: multiple goroutines modify shared variable
    }()
}
Closures in goroutines share the captured variable. Use sync.Mutex, sync/atomic, or channels to synchronize access.


Middleware Pattern Using Closures

Closures are the foundation of Go's middleware pattern.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now() // captured by closure
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func rateLimiter(maxRequests int, window time.Duration) func(http.Handler) http.Handler {
    mu := &sync.Mutex{} // shared state captured by closure
    counts := map[string]int{}

    go func() { // cleanup goroutine, captures mu and counts
        for range time.Tick(window) {
            mu.Lock()
            counts = map[string]int{}
            mu.Unlock()
        }
    }()

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := r.RemoteAddr
            mu.Lock()
            counts[ip]++
            current := counts[ip]
            mu.Unlock()

            if current > maxRequests {
                http.Error(w, "rate limited", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Functional Options Pattern

Uses closures to create a clean, extensible configuration API.

type Server struct {
    host    string
    port    int
    timeout time.Duration
    maxConn int
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func WithMaxConn(n int) Option {
    return func(s *Server) { s.maxConn = n }
}

func NewServer(host string, port int, opts ...Option) *Server {
    s := &Server{
        host:    host,
        port:    port,
        timeout: 30 * time.Second,
        maxConn: 100,
    }
    for _, opt := range opts {
        opt(s) // each option is a closure that modifies s
    }
    return s
}

srv := NewServer("localhost", 8080,
    WithTimeout(10*time.Second),
    WithMaxConn(50),
)

Closures for Encapsulation

// Generator pattern
func fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        result := a
        a, b = b, a+b
        return result
    }
}

fib := fibonacci()
for i := 0; i < 8; i++ {
    fmt.Print(fib(), " ") // 0 1 1 2 3 5 8 13
}

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

Closure vs Named Function

// Named function -- use when:
// - Logic is reusable across multiple callers
// - Function doesn't need captured state
// - You want a clear, testable unit
func add(a, b int) int { return a + b }

// Closure -- use when:
// - You need captured state (counters, config, context)
// - Building higher-order functions (middleware, options)
// - Single-use callbacks or goroutines
handler := func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Request from %s", r.RemoteAddr)
}

Quick Reference

Concept Syntax Notes
Function literal func(params) retType { ... } Anonymous function
Assign to var f := func() { ... } Variable holds function value
IIFE func() { ... }() Immediately invoked
Closure func() { x++ } (captures x) Captures variable, not value
Pass as arg sort.Slice(s, func(i, j int) bool { ... }) Callback pattern
Return closure func() func() int { return func() int {...} } Factory pattern
Goroutine closure go func() { ... }() Watch for shared variables
Middleware func(http.Handler) http.Handler Closures wrap handlers
Functional option type Option func(*Config) Closures configure structs

Best Practices

  1. Be explicit about capture — if a closure uses an outer variable, make sure the intent is clear
  2. Use parameter passing for goroutine closures (pre-1.22) — go func(v T) { ... }(val) avoids races
  3. Keep closures short — long anonymous functions should be extracted to named functions for readability
  4. Use closures for state encapsulation — counters, caches, and connection pools captured in closures are cleaner than package-level globals
  5. Prefer the functional options pattern over config structs for extensible APIs
  6. Use t.Cleanup() instead of deferred closures in tests — it's more predictable with subtests
  7. Don't nest closures deeply — more than 2 levels of nesting harms readability

Common Pitfalls

Loop Variable Capture (Pre-Go 1.22)

for _, v := range items {
    go func() {
        process(v) // BUG: all goroutines see the LAST value of v
    }()
}
Fix (pre-1.22): v := v inside the loop, or pass v as a parameter. Go 1.22+ fixes this automatically.

Data Race with Shared Closure Variable

total := 0
for i := 0; i < 100; i++ {
    go func() {
        total++ // DATA RACE: unsynchronized access
    }()
}
Use sync.Mutex, sync/atomic, or channels. Closures share the variable, not a copy.

Accidental Capture of Large Objects

func process(data []byte) func() {
    return func() {
        fmt.Println(len(data)) // keeps entire data slice alive
    }
}
The closure holds a reference to data, preventing garbage collection. Capture only what you need:
func process(data []byte) func() {
    n := len(data) // capture just the length
    return func() {
        fmt.Println(n)
    }
}

Deferred Closure with Loop Variable

for _, f := range files {
    file, _ := os.Open(f)
    defer file.Close() // BUG (pre-1.22): all defers use last file
}
Deferred closures capture by reference too. Wrap in a function or use defer inside an IIFE.

Nil Function Variable

var fn func()
fn() // PANIC: call of nil function
Always check if a function variable is nil before calling it, or ensure it's initialized.


Performance Considerations

Scenario Impact
Closure allocation Closures that capture variables are allocated on the heap (escape analysis)
No capture If a function literal captures nothing, it's equivalent to a named function — no allocation
Small closures Compiler may inline small closures — zero overhead
Hot-path closures Avoid creating closures in tight loops — the allocation can add up
sort.Slice The comparison closure is inlined by the compiler in most cases
Middleware chains Closures are created once at startup, not per request — negligible cost
Functional options Each With* call creates a small closure — trivial for configuration

Escape Analysis

If a closure captures a local variable and the closure escapes (returned, passed to goroutine), the variable moves to the heap. Use go build -gcflags='-m' to see escape analysis decisions.


Interview Tips

Interview Tip

"What is a closure in Go?" A closure is a function literal that captures variables from its enclosing scope. The key insight is that it captures the variable itself (by reference), not a copy of the value. This means the closure and the enclosing function share the same variable — mutations in one are visible in the other.

Interview Tip

"Explain the loop variable closure bug." Before Go 1.22, the loop variable was scoped to the entire for loop, so all closures created in the loop captured the same variable. After the loop, that variable held the last value, so all closures saw the same (wrong) value. Go 1.22 fixed this by creating a new variable per iteration.

Interview Tip

"How does the functional options pattern work?" Each option is a function (closure) that takes a pointer to the config struct and modifies it. The constructor accepts variadic options and applies them in order. This allows extensible, backward-compatible APIs — adding a new option doesn't change the constructor signature.

Interview Tip

"When would you use a closure vs a named function?" Use a closure when you need to capture state from the enclosing scope — counters, configuration, middleware context. Use a named function when the logic is stateless, reusable, and benefits from being independently testable.

Interview Tip

"Can closures cause memory leaks?" Yes — a closure holds a reference to every captured variable, keeping them alive for garbage collection purposes. If a long-lived closure captures a large slice or struct, that memory cannot be freed. Capture only what you need.


Key Takeaways

  • Functions are first-class values in Go — assignable, passable, returnable
  • Closures capture variables by reference, not by value — mutations are shared
  • The loop variable bug (pre-Go 1.22) is the most common closure pitfall — use v := v in older Go
  • IIFE (func() { ... }()) is useful for isolating scope
  • Middleware and functional options are the most important closure patterns in Go
  • Closures in goroutines can cause data races if they share mutable state — synchronize access
  • Closures that capture variables cause heap allocations (escape analysis)
  • Keep closures short and focused — extract long anonymous functions into named functions
  • Go 1.22+ fixed loop variable scoping, eliminating the most common closure bug