How to Create Function Closures in Go

Create a Go function closure by defining an anonymous function that captures outer scope variables and returns it.

The loop variable trap

You write a loop that spawns ten goroutines to process a slice of URLs. Inside the loop, you capture the loop variable and pass it to a closure. You run the program. Every single goroutine processes the last URL in the slice. You stare at the screen, convinced the compiler broke. It did not. The closure captured a reference to the loop variable itself, not a snapshot of its value at that iteration. By the time the goroutines actually start running, the loop has finished and the variable holds its final value.

This is the most common introduction to closures in Go. It feels like a bug, but it is a direct consequence of how the language handles variable capture. Understanding why it happens removes the mystery and turns closures from a source of panic into a precise tool.

What a closure actually is

A closure is a function value that remembers the environment where it was created. Regular functions only see their parameters and global variables. Closures see their parameters, global variables, and any local variables that were in scope when the function literal was defined. They carry those captured variables along with them, no matter where they are assigned or returned.

Think of a closure as a function wearing a backpack. The backpack holds copies of the variables the function needs from its birthplace. When you call the closure later, it reaches into the backpack instead of looking at the original scope. The original scope might be gone. The function might have returned. The backpack still exists, and the variables inside it are still alive.

Go implements this by moving captured variables from the stack to the heap. The compiler generates a hidden structure to hold the captured values, and the closure holds a pointer to that structure. This mechanism is invisible in your source code, but it dictates memory layout and performance.

The simplest closure

Here is the canonical example that demonstrates the mechanics without extra noise. A factory function takes a base value, creates an anonymous function that captures it, and returns that function.

// makeAdder returns a function that adds its argument to a captured base value.
func makeAdder(base int) func(int) int {
	// The returned function captures base from the outer scope.
	// It does not copy base. It holds a reference to the heap-allocated variable.
	return func(n int) int {
		return base + n
	}
}

func main() {
	// base is 5. The closure captures it and moves it to the heap.
	addFive := makeAdder(5)
	
	// base is 10. A second closure captures a separate heap variable.
	addTen := makeAdder(10)
	
	// Each closure operates on its own captured state.
	fmt.Println(addFive(3)) // prints: 8
	fmt.Println(addTen(3))  // prints: 13
}

The key detail is that base is not copied into the closure. The closure and the outer function share the same underlying variable. If you mutate base inside the closure, the change persists across calls. This shared state is what makes closures useful for counters, accumulators, and stateful wrappers.

How the compiler handles it

When the compiler sees a function literal that references a variable from an outer scope, it runs escape analysis. If the variable would normally live on the stack, the compiler promotes it to the heap. It allocates a struct-like block of memory, copies the initial value into it, and passes a pointer to that block to the closure.

This promotion happens at compile time. You do not write heap allocation code. The compiler does it automatically. The tradeoff is clear: closures are convenient, but they carry a small allocation cost. Every captured variable adds to the heap footprint. If you create millions of closures in a tight loop, the garbage collector will work harder.

Go follows a strict convention for naming function receivers: one or two letters that match the type, like (c *Counter) Increment(). Closures do not have receivers, but they do have captured state. Treat that state like a receiver. Name the captured variables clearly, and keep the closure focused on a single responsibility. The community expects closures to be short. If a closure grows past twenty lines, it usually means the logic belongs in a named method on a struct.

Trust the compiler to move variables to the heap. Do not try to outsmart it with manual pointers. Let the language handle the memory layout, and focus on the logic.

Closures in real code

Closures shine when you need to wrap behavior with configuration. A retry mechanism is a perfect example. You want to execute a flaky operation, but you also want to control the backoff strategy, the maximum attempts, and the error logging. A closure captures the configuration and the operation, returning a clean function that handles the retry loop.

// Retry wraps a function with exponential backoff and a maximum attempt limit.
// It captures the operation, maxAttempts, and baseDelay from the outer scope.
func Retry(operation func() error, maxAttempts int, baseDelay time.Duration) func() error {
	// The closure captures all three parameters.
	// They remain accessible even after Retry returns.
	return func() error {
		var lastErr error
		delay := baseDelay
		
		for attempt := 1; attempt <= maxAttempts; attempt++ {
			lastErr = operation()
			if lastErr == nil {
				return nil
			}
			
			// Back off before the next attempt, but not after the last one.
			if attempt < maxAttempts {
				time.Sleep(delay)
				delay *= 2
			}
		}
		
		return lastErr
	}
}

func main() {
	// The closure captures the specific operation and retry config.
	// It can be passed around like any other function value.
	retryFetch := Retry(func() error {
		// Simulate a flaky network call.
		return nil
	}, 5, 100*time.Millisecond)
	
	// Calling the closure executes the retry logic.
	retryFetch()
}

The closure here acts as a factory. You pass in the dynamic parts (the operation, the limits, the delay), and it returns a ready-to-use function. The captured variables are frozen at creation time. You can assign the returned function to a package-level variable, pass it to a worker pool, or store it in a map. The state travels with it.

Closures also pair naturally with Go's error handling convention. The if err != nil { return err } pattern is verbose by design. It forces you to acknowledge failure at every step. When you wrap operations in closures, keep that pattern inside the closure. Do not swallow errors silently. Return them up the chain so the caller can decide how to handle them.

Where things go wrong

The loop variable trap mentioned earlier was a real pain point for years. In Go versions before 1.22, the loop variable was reused across every iteration. A closure capturing it would always see the final value. The compiler rejected programs that tried to capture loop variables in goroutines with a warning, but it was not a hard error until recently. Go 1.22 changed the semantics: the loop variable is now created fresh for each iteration. The trap is gone, but the mental model remains important.

If you write code that captures a loop variable in a closure, the compiler now treats it as a new variable each time. You no longer need the classic i := i workaround. The language handles it for you.

Memory leaks are a different story. Closures capture variables by reference. If a closure captures a large slice, a database connection, or a channel, that resource stays alive as long as the closure exists. If you store the closure in a long-lived map or pass it to a background goroutine that never exits, the captured resource never gets garbage collected. The worst closure bug is the one that silently holds memory for days until the OOM killer steps in.

The compiler will complain with assignment to entry in nil map if you try to store a closure in an uninitialized map. It will reject cannot use func literal as type string in return if you mismatch types. These are straightforward. The runtime panics are harder. A closure that writes to a shared slice without synchronization will cause data races. The race detector will flag it, but only if you run with -race. Run it in CI. Do not skip it.

Closures are stateful wrappers. Treat them like small objects. Keep their captured state minimal, and ensure every long-lived closure has a clear lifecycle.

When to reach for a closure

Use a closure when you need to bundle behavior with configuration and return a single callable unit. Use a closure when you want to create a factory that produces specialized functions without defining a new type. Use a closure when you need to capture local variables in a callback or middleware chain. Use a plain function when the logic does not depend on outer state: named functions are easier to test and document. Use a struct with methods when the captured state grows beyond two or three variables: explicit fields are easier to debug than hidden closure environments. Use a channel or mutex when multiple goroutines need to share and mutate the same state: closures capture references, but they do not provide synchronization.

Closures are cheap. They are not free. Pick the right shape for the problem.

Where to go next