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
}()
}
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¶
- Be explicit about capture — if a closure uses an outer variable, make sure the intent is clear
- Use parameter passing for goroutine closures (pre-1.22) —
go func(v T) { ... }(val)avoids races - Keep closures short — long anonymous functions should be extracted to named functions for readability
- Use closures for state encapsulation — counters, caches, and connection pools captured in closures are cleaner than package-level globals
- Prefer the functional options pattern over config structs for extensible APIs
- Use
t.Cleanup()instead of deferred closures in tests — it's more predictable with subtests - 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
}()
}
v := v inside the loop, or pass v as a parameter. Go 1.22+ fixes this automatically.
Data Race with Shared Closure Variable
Usesync.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
}
}
data, preventing garbage collection. Capture only what you need:
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
}
defer inside an IIFE.
Nil Function Variable
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 := vin 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