Function Values and Closures Internals

In Go a function value is a first-class value: it can be assigned to a variable, passed as an argument, returned, and stored in a data structure. The Go specification says “a function type denotes the set of all functions with the same parameter and result types” and that “a function value contains references to the (possibly anonymous) function and enclosed variables” (Go spec). Concretely, at runtime a function value is a pointer to a tiny heap object the runtime calls a funcval — its first word is the address of the compiled machine code, and any captured variables are stored immediately after it. A closure is exactly this: a function value whose funcval carries a non-empty captured-variable environment. Understanding this representation explains why closures can outlive their enclosing function, why capturing variables forces heap allocation, and why the pre-Go-1.22 loop-variable bug behaved the way it did.

Mental Model

Think of a function value as a two-part object: code plus environment. The code part never changes — it is the single compiled body of the function, shared by every value created from the same function literal. The environment part is per-value: it holds the specific variables this particular closure captured. A variable of function type holds a single pointer; that pointer points at the funcval, and the funcval begins with the code pointer and continues with the environment.

graph LR
    subgraph "function-typed variable"
        V["f  (one word)"]
    end
    subgraph "funcval (heap object)"
        FN["fn: code pointer"]
        E1["captured var 1 (or *ptr to it)"]
        E2["captured var 2 (or *ptr to it)"]
    end
    subgraph "code segment (shared, read-only)"
        CODE["compiled function body"]
    end
    V --> FN
    FN --> CODE
    E1 -.part of same object.- FN
    E2 -.part of same object.- FN

Diagram: a function-typed variable is one pointer-sized word holding the address of a funcval. The funcval’s first word is a code pointer into the shared, read-only code segment; everything after it is the closure’s captured environment. The insight: the code is shared and immutable, the environment is per-closure and mutable — two closures from the same literal differ only in their environment.

A plain top-level function with no captures still has a function value, but its funcval is trivial: just the code pointer, with an empty environment. The compiler can allocate such funcvals statically in read-only memory because there is nothing per-instance about them. A genuine closure — one that captures variables — needs a fresh funcval each time its literal is evaluated, because each evaluation captures a potentially different environment.

Mechanical Walk-through

The funcval struct

The runtime defines the in-memory shape of every function value in src/runtime/runtime2.go:

type funcval struct {
	fn uintptr
	// variable-size, fn-specific data here
}

The struct is deliberately minimal. fn is the entry address of the compiled function. The comment “variable-size, fn-specific data here” is the whole story of closures: the compiler lays out the captured variables contiguously after the fn word, and the size of the overall object depends on how much was captured (runtime2.go). Because the runtime type funcval only declares the header, the rest is known only to the compiler that generated the closure.

Calling through a function value

When you call f() where f has function type, the compiler does not emit a direct call. It emits an indirect call through the function value. The mechanism is a closure context register: before transferring control, the caller loads the address of the funcval into a designated register, then jumps to funcval.fn. The called code, if it is a closure, reads its captured variables by dereferencing that context register at known offsets. The Go assembly documentation confirms a function can be flagged NEEDCTXT — “this function is a closure so it uses its incoming context register” (go.dev/doc/asm).

The register is architecture-specific, and the authoritative list lives in Go’s internal-ABI specification. On amd64 it is RDX: the special-purpose-register table marks RDX with the call meaning “Closure context pointer.” Other architectures pick a different register — R26 on arm64, R11 on ppc64, X26 on riscv64, R29 on loong64, R12 on s390x — each documented in that architecture’s special-purpose-register table (cmd/compile/abi-internal). The spec is explicit that “calls to closures store the address of the closure object in the closure context pointer register prior to the call,” which is exactly the funcval address described above.

The key consequence: a closure call costs one extra register load over a direct call, plus the indirection through funcval.fn (which the Devirtualization pass sometimes removes when the concrete function is statically known). A closure that captures nothing still goes through this path if it is invoked through a function-typed variable.

What “capture” means and how variables escape

A closure captures a variable when its body references a variable declared in an enclosing scope. The compiler must decide how to store that variable so the closure sees the right thing. There are two cases:

  1. Capture by value, read-only. If the closure only reads the captured variable and the variable is never modified after the closure is created, the compiler may copy the value directly into the funcval environment. No sharing, no escape forced by this alone.

  2. Capture by reference. If either the closure or the enclosing function modifies the captured variable after the closure is created — so they must agree on a single mutable cell — the compiler promotes the variable to the heap and stores a pointer to it in the funcval. The enclosing function’s own access to that variable is also rewritten to go through the pointer. Now both the enclosing frame and the closure share one heap cell.

Case 2 is why capturing a variable in a closure forces a heap allocation. Escape Analysis is the compiler pass that detects this: if a function literal references a variable and that literal may outlive the variable’s lexical scope (because the literal is returned, stored in a longer-lived structure, or passed to go), the variable escapes to the heap so it remains valid after the enclosing stack frame is gone. The funcval itself also escapes in those cases — it too is heap-allocated, since a stack-allocated funcval would dangle the moment its frame returned.

A closure that does not outlive its creator — for example, one passed to a function that only calls it synchronously and never stores it — can sometimes keep both the funcval and the captured variables on the stack. Escape analysis proves the closure does not escape and the allocation is elided. This is why slices.SortFunc(s, func(a, b int) int { ... }) with a non-capturing or non-escaping comparator can be allocation-free, while return func() int { return n } always allocates.

Loop variables: the pre-1.22 interaction

Before Go 1.22, a for loop’s iteration variable had per-loop scope: one variable instance shared across all iterations. A closure created inside the loop body that captured that variable captured the single shared cell (case 2 above). When the loop finished, the cell held the final value, so every closure observed it. The Go blog’s canonical example prints c, c, c instead of a, b, c (loopvar-preview):

for _, v := range values {
    go func() { fmt.Println(v); done <- true }()
}

Go 1.22 changed for loops so the iteration variables have per-iteration scope — “we plan to change for loops to make these variables have per-iteration scope instead of per-loop scope” (loopvar-preview). Mechanically, the compiler now creates a fresh variable instance each iteration. A closure created in iteration k captures iteration k’s instance; the example above now prints a, b, c. The change only applies to packages whose go.mod declares go 1.22 or later, preserving backward compatibility. See Loop Variable Capture for the full history, the Let’s Encrypt production bug it caused, and the per-file //go:build controls. The closure machinery itself did not change — only how many distinct variables exist for a closure to capture.

Code and Specification

A closure that escapes — heap allocation forced

func counter() func() int {
	n := 0                       // captured by reference, modified by the closure
	return func() int {          // function literal: a closure over n
		n++                      // mutates the shared cell
		return n
	}
}
 
func main() {
	c1 := counter()              // c1 holds a *funcval; n#1 lives on the heap
	c2 := counter()              // c2 holds a different *funcval; n#2 on the heap
	println(c1(), c1(), c2())    // 1 2 1 — c1 and c2 have independent environments
}

Line-by-line: n := 0 declares the captured variable. Because the returned closure both outlives counter (it is returned) and mutates n, escape analysis moves n to the heap. The return func()... allocates a funcval on the heap whose environment is a pointer to that heap cell. c1 := counter() and c2 := counter() each run counter afresh, so each gets a distinct n and a distinct funcval. The output 1 2 1 proves the environments are independent: c1 increments its own n twice, c2 increments a different n once.

Compiling with -gcflags=-m (see Compiler Optimization Diagnostics) reports something like moved to heap: n and func literal escapes to heap, the audit trail of the two allocations.

A non-escaping closure — no allocation

func sumPositive(xs []int) int {
	total := 0
	apply := func(x int) {       // closure over total, but...
		if x > 0 {
			total += x
		}
	}
	for _, x := range xs {
		apply(x)                 // ...apply never escapes sumPositive
	}
	return total
}

Here apply is created, called, and discarded entirely within sumPositive. Escape analysis proves the funcval does not escape; total and the funcval both stay on the stack, and the program allocates nothing. The closure abstraction is free in this shape — a crucial fact for writing idiomatic Go without paying for it.

Comparing function values

Function values are not comparable for equality beyond comparison with nil. The spec permits f == nil but f == g for two function values is a compile error. The reason is the funcval representation: two values from the same literal have the same fn but different environments, and two values could share an environment, so neither pointer identity nor structural equality gives a meaningful answer. The language sidesteps the ambiguity by forbidding the comparison. See Comparing Structs and Interfaces.

Common Misunderstandings

“A closure copies the variables it captures.” Usually false for mutated variables. If the variable is modified after capture, the closure shares a single heap cell with its creator (capture by reference). Only read-only captures may be copied. The mental shortcut “closures capture variables, not values” is correct for the cases that matter.

“Closures are slow.” A closure call is one indirect call plus one context-register load over a direct call — negligible. The real cost is the heap allocation of an escaping funcval and its environment, which shows up in profiles and GC pressure, not in call overhead.

go func(){...}() always allocates.” It usually does, because passing a literal to go makes it outlive the calling frame, so the funcval escapes. But the captured variables escape independently — a goroutine closure that captures nothing still allocates a funcval but no environment.

“The pre-1.22 loop bug was a closure bug.” It was a scoping bug. Closures behaved exactly as specified — they captured the variable they were told to capture. The defect was that the language gave them one shared variable instead of one per iteration. Go 1.22 fixed the scoping, not the closure machinery.

“Method values are different from closures.” A method value like obj.Method is in fact a closure: the compiler builds a funcval whose environment captures the receiver obj. Calling the method value invokes Method with the captured receiver. This is why taking a method value of a large struct receiver can allocate.

Alternatives and When to Choose Them

A closure is the right tool when behavior must carry a small amount of private mutable state and you want that state hidden from callers — iterators, callbacks, memoizers, option-builders. The alternative is an explicit struct with a method: store the state in struct fields, attach a method, pass the struct. This makes the captured state visible and named, is comparable (structs of comparable fields are comparable), and gives you control over allocation (you choose when the struct escapes). Prefer the struct when the state is large, when you need several related operations sharing it, or when you want the value to be comparable or serializable. Prefer the closure when the state is one or two variables and the brevity genuinely aids readability. For hot loops where allocation matters, an explicit struct that you allocate once and reuse beats a closure recreated each iteration.

Production Notes

The single most common performance surprise from closures is unintended heap allocation in a hot path. A comparator passed to a sort, a callback passed to Walk, or a function returned from a constructor — each may allocate a funcval per call. The diagnostic workflow is: run the benchmark with -benchmem, see non-zero allocs/op, then recompile with -gcflags=-m and look for func literal escapes to heap and moved to heap. The fix is usually to hoist the closure out of the loop (create it once), to make captured variables read-only so they can be copied rather than shared, or to replace the closure with a struct-and-method you allocate once.

The Go 1.22 loop-variable change quietly eliminated a class of production bugs. Google reported “zero reports of any problems in production code” after forcing the new semantics internally for four months (loopvar-preview). The cost is a subtle one: code that deliberately relied on the shared variable (rare, and usually itself a latent bug) changes behavior, and per-iteration scoping means a fresh variable — and potentially a fresh escape — each iteration, a negligible cost in practice but worth knowing when reading escape-analysis output for loop bodies.

See Also