Skip to main content

Anonymous Functions and Closures in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 7 min read

An anonymous function is a function without a name — a function literal that you can assign to a variable, pass as an argument, or invoke immediately. A closure is an anonymous function that closes over variables from its surrounding scope — it captures references to those variables, not copies.

This distinction matters: closures share state with their enclosing scope. Changes to a captured variable are visible in the closure, and changes the closure makes are visible outside. This is both the power and the most common source of bugs.

For function fundamentals see Go functions definition. For goroutine patterns that use closures see Go goroutines.

Function Literals

A function literal has the same syntax as a function declaration, minus the name:

// Assign to a variable
double := func(n int) int {
    return n * 2
}
fmt.Println(double(5))  // 10

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

// Pass as an argument
nums := []int{3, 1, 4, 1, 5}
sort.Slice(nums, func(i, j int) bool {
    return nums[i] < nums[j]
})

sort.Slice is a common example — the comparison function is almost always an anonymous function because it’s specific to one call site and not reusable elsewhere.

Closures: Capturing Variables

A closure captures variables from its enclosing scope by reference. This means it sees the current value of a variable whenever it’s called — not the value at the time the closure was created:

x := 10
f := func() {
    fmt.Println(x)  // prints whatever x is NOW, not 10
}
f()   // 10
x = 20
f()   // 20 — closure sees the updated x

This behavior is useful for building stateful functions. A classic example is a counter that increments each time it’s called:

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

c1 := makeCounter()
fmt.Println(c1())  // 1
fmt.Println(c1())  // 2
fmt.Println(c1())  // 3

c2 := makeCounter()
fmt.Println(c2())  // 1 — c2 has its own separate n

Each call to makeCounter creates a new n variable and a new closure over it. c1 and c2 don’t share state.

The Loop Variable Trap

The most common Go closure bug: capturing a loop variable by reference in a goroutine or deferred function. By the time the closure runs, the loop has finished and the variable holds its final value:

// ❌ All goroutines print 3 (the final value of i)
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i)  // captures i by reference
    }()
}
time.Sleep(time.Millisecond)
// Output: 3 3 3 (or some permutation)

Fix by passing the loop variable as an argument:

// ✅ Each goroutine gets its own copy
for i := 0; i < 3; i++ {
    go func(n int) {
        fmt.Println(n)  // n is a copy, not a reference to i
    }(i)
}

Or, since Go 1.22, loop variables are re-created per iteration — the bug no longer occurs in for range loops over integers in recent Go versions. But passing as an argument remains the clearest, version-independent pattern.

The same trap applies to defer:

// ❌ All defers print "world" (final value of s)
words := []string{"hello", "world"}
for _, s := range words {
    defer fmt.Println(s)  // captures s by reference
}

// ✅ Each defer gets its own copy
for _, s := range words {
    s := s  // shadow with a new variable
    defer fmt.Println(s)
}

Practical Patterns

Middleware and Handler Factories

Closures are ideal for functions that return configured handler functions — the configuration is captured by the closure:

func rateLimit(limit int, next http.Handler) http.Handler {
    // limiter is captured — shared across all requests to this handler
    limiter := rate.NewLimiter(rate.Limit(limit), limit)

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Each call to rateLimit creates a new limiter bound to the returned handler. Two different routes can have different rate limits without any global state.

Lazy Initialization

sync.Once combined with a closure initializes something exactly once:

var (
    dbOnce sync.Once
    db     *sql.DB
)

func getDB() *sql.DB {
    dbOnce.Do(func() {
        var err error
        db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
        if err != nil {
            log.Fatal(err)
        }
    })
    return db
}

The closure captures db and err from the enclosing scope. sync.Once guarantees the closure runs at most once, even if getDB is called concurrently from many goroutines.

Retry Logic

Closures make it natural to wrap any operation with retry behavior:

func withRetry(ctx context.Context, maxAttempts int, op func() error) error {
    var err error
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        if ctx.Err() != nil {
            return ctx.Err()
        }
        err = op()
        if err == nil {
            return nil
        }
        if attempt < maxAttempts {
            backoff := time.Duration(attempt*attempt) * 100 * time.Millisecond
            time.Sleep(backoff)
        }
    }
    return fmt.Errorf("failed after %d attempts: %w", maxAttempts, err)
}

// Usage — op is a closure over req and client
err := withRetry(ctx, 3, func() error {
    _, err := client.Do(req)
    return err
})

map / filter / reduce

Go generics (1.18+) let you write reusable higher-order functions. Pre-generics, these worked with any and type assertions:

// Map with generics — applies f to every element
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// Filter — returns elements for which f returns true
func Filter[T any](s []T, f func(T) bool) []T {
    var result []T
    for _, v := range s {
        if f(v) {
            result = append(result, v)
        }
    }
    return result
}

// Usage
prices := []float64{9.99, 24.99, 4.99, 49.99}

expensive := Filter(prices, func(p float64) bool { return p > 10 })
// [24.99 49.99]

doubled := Map(expensive, func(p float64) float64 { return p * 2 })
// [49.98 99.98]

Closures and Goroutines

When a closure is passed to go, the goroutine and the calling code may run concurrently. Any variable the closure captures is accessed from two goroutines simultaneously — a data race unless protected:

// ❌ Data race: results is written concurrently without protection
results := make([]int, len(items))
var wg sync.WaitGroup
for i, item := range items {
    wg.Add(1)
    go func(idx int, val Item) {
        defer wg.Done()
        results[idx] = process(val)  // concurrent writes to different indices
    }(i, item)
}
wg.Wait()

Concurrent writes to different indices of a slice are actually safe (no overlap), but the Go race detector will flag it unless you’re careful. For shared state like a map or a counter, always use a mutex or atomic operation.

Defer with Closures for Error Wrapping

A deferred closure that modifies named return values is an elegant pattern for adding context to errors:

func loadConfig(path string) (cfg *Config, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("loadConfig(%s): %w", path, err)
        }
    }()

    f, err := os.Open(path)
    if err != nil {
        return nil, err  // deferred closure will wrap this
    }
    defer f.Close()

    // ... parse config ...
    return cfg, nil
}

The closure captures the named return err. Whenever the function returns with a non-nil error, the defer adds the file path as context — without repeating the wrapping at each return site.

Summary

  • A closure captures variables from its enclosing scope by reference — changes to captured variables are visible in the closure and vice versa
  • Loop variable capture is the most common bug: the closure sees the final loop value, not each iteration’s value — fix by passing the variable as an argument
  • Closures are the foundation of Go middleware, factory functions, and callback patterns
  • Each call to a function that returns a closure creates a new, independent set of captured variables
  • Goroutines that use closures may have race conditions on shared state — protect with mutexes or use channels

Resources

Comments

👍 Was this article helpful?