Deep Engineering
Intermediate·Published·55 MIN

Functions and closures in Go: what gets captured and when

Interview questions on closures climb a ladder: can two functions be compared — what a closure is — what a counter prints — what closures created in a loop see — when defer arguments are evaluated and what a method value saves — does f(s...) pass a copy — what a closure costs. A run answers each step: a closure reads an assignment made after it was created; the same loop gave c c c in a module with go 1.21 and a b c with go 1.22; f(s...) wrote into the caller's slice; and calling a closure cost as much as a direct call — only creating one is expensive.

Full technical treatment

TL;DR

A function in Go is a value, and a closure is a function literal that refers to variables of the surrounding function. A function value has a type (func(int) int) and the zero value nil; it is stored in a variable, passed and returned. Calling nil panics, and two functions cannot be compared: f == g does not compile, comparison is allowed only against nil.

A closure captures the variable, not its value. The specification says so directly: such variables become shared between the function and the literal. That is why the closure from counter() gives 1 2 3 over three calls — each call changes the same n — of the two closures from one call of pair(), get sees the changes made by incr, and a closure created before x = 2 reads 2.

Beyond that comes what separates real understanding from having read about it. How many variables a loop has is decided by the go line in go.mod — and only for a variable the loop declares itself (for _, s := range): the same code on the same go1.24.7 compiler gave c c c in a module with go 1.21 and a b c with go 1.22, while a loop that assigns to a previously declared s (for _, s = range) gave c c c in both modules. A value is pinned only where the language evaluates something at once: a call argument, a defer argument, the receiver of a method value — g := v.Get; v.n = 2, and g() returned 1. f(s...) passes the slice itself, not a copy: a write inside is visible outside. If the closure escapes after optimizations, a captured variable that is modified goes with it to the heap — memory that outlives the function call and is freed by the garbage collector: for sinkF = counter() the run counted two allocations, while the same counter(), inlined and used in place, counted zero. The number of allocations is an observation of the compiler at a particular call site, not a property of the function. In our measurement (go1.24.7, linux/amd64, best of 11 rounds) calling a closure cost as much as a direct call, while creating a closure that escapes cost 12.8 times more; the mechanism explains where the difference comes from, and the ratio on your machine has to be measured.

Where to start
Before this lesson it is enough to understand
  • how to declare a function, a variable and a for … rangeloop, and how the declaration := differs from the assignment =;
  • that a parameter is a copy of the argument and a pointer leads to a shared variable — that is the lesson on pointers;
  • that a slice is a header over an array; the level of using one is enough, the details are in the lesson on slices;
  • that a method is declared with a value receiver or a pointer receiver — the details are in the lesson on method receivers;
  • that defer postpones a call until the function exits — the details are in the lesson on defer, panic and recover.
You do not need to know in advance
  • what a function literal, a method value and a variadic parameter ...T are;
  • what the stack, the heap, escape analysis and the -gcflags=-m flag are — what is needed is explained along the way, the details are in the lesson on escape analysis; Mechanism 5 and "Deeper" are Senior level and can be skipped on a first reading;
  • how the loop variable is tied to the go line in go.mod.

What is really being asked

The ladder is almost always this one:

  1. "Are functions first-class values in Go? Can two functions be compared?" — the warm-up; the second half catches people more often than the first.
  2. "What is a closure?" — "a function that remembers its environment" is correct but misses the main point: it remembers variables, not values.
  3. "What does this counter print?" (with code) — the same check, on code.
  4. "What do closures created in a loop print?" — a classic that has had two answers since Go 1.22, and go.mod chooses between them.
  5. "When are defer arguments evaluated? What does f := v.Method save?" — where a value does get pinned at the moment it is written.
  6. "Does f(s...) pass a copy of the slice?" — about the variadic parameter.
  7. "What does a closure cost?" — about the heap, and about what is actually expensive.

The ladder has one spine: a closure captures the variable, not the value; a value is pinned only where the language explicitly evaluates something — when passing an argument, in a defer statement, when taking a method value. Everything in this lesson that looks surprising follows from that sentence.

Base: a function is a value

The specification introduces a function type as the set of functions with one signature and names its zero value right away:

A function type denotes the set of all functions with the same parameter and result types. The value of an uninitialized variable of function type is nil.

— The Go Specification — Function types
language contractFunction types, closures, the loop variable semantics, variadic parameters, method values and defer are part of the language specification; the runs of bench/goclosure illustrate them rather than establish them.

So a function can be put into a variable, passed as a parameter and called through the parameter:

Fragment of bench/goclosure/internals.go (package-level functions):

GO
func inc(x int) int    { return x + 1 }
func double(x int) int { return x * 2 }
 
func apply(f func(int) int, x int) int { return f(x) }

Output of bench/goclosure/internals.go, section 1; labels translated:

1) A FUNCTION IS A VALUE
────────────────────────
  var op func(int) int = inc         -> op(41) = 42
  apply(double, 5)                   -> 10
  %T op                              -> func(int) int
  unsafe.Sizeof(op)                  -> 8 bytes
  var nf func(int) int; nf == nil    -> true
  nf(1)                              -> panic: runtime error: invalid memory address or nil pointer dereference
  any(inc) == any(inc)               -> panic: runtime error: comparing uncomparable type func(int) int

The first four lines are a function as an ordinary value: it has a type and a size. Eight bytes is the size of a pointer on amd64; what lies behind that pointer is covered in "Deeper".

The fifth and sixth are the zero value. A variable of function type with no assignment equals nil, and it cannot be called:

Calling a nil function value causes a run-time panic.

— The Go Specification — Calls

That is why an optional handler — an OnError func(error) field in an options struct — is checked for nil before the call.

The last line is comparison. Two functions cannot be compared at all:

Slice, map, and function types are not comparable. However, as a special case, a slice, map, or function value may be compared to the predeclared identifier nil.

— The Go Specification — Comparison operators

The compiler catches it immediately, and for the same reason a function cannot be a map key:

Output of bench/goclosure/compile.sh (the file cases/funccmp/main.go intentionally does not compile):

funccmp:
  ./main.go:11:10: invalid operation: f == g (func can only be compared to nil)
  ./main.go:12:15: invalid map key type func(int) int

The line f == nil in the same file (line 10) gave no error. And if the functions are hidden in any, the compiler no longer sees the type, and the incomparability surfaces at run time: the panic comparing uncomparable type func(int) int from the run above.

That is enough for a Junior-level answer: a function in Go is a value with its own type; it can be assigned, passed, returned. The zero value is nil, calling nil panics. Functions can be compared only with nil. The next step is a function that is not just passed but created inside another one.

Mechanism 1: a closure captures the variable, not the value

A function literal is a function without a name, written right inside an expression. The specification defines it through the variables it refers to:

Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible.

— The Go Specification — Function literals

The key word is shared. Not "copied into the closure" — shared. The check:

Fragment of bench/goclosure/internals.go:

GO
func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}
 
func pair() (incr func(), get func() int) {
    n := 0
    incr = func() { n++ }
    get = func() int { return n }
    return
}

Output of bench/goclosure/internals.go, section 2; labels translated:

2) A CLOSURE CAPTURES THE VARIABLE, NOT THE VALUE
─────────────────────────────────────────────────
  c1 := counter(); c1() x3           -> 1 2 3
  c2 := counter(); c2()              -> 1
  incr, get := pair(); incr() x2     -> get() = 2
  x := 1; read := ...; x = 2         -> read() = 2
  set := func() { x = 10 }; set()    -> x = 10

Every line follows from the word "shared":

  • The counter. n is a variable of counter, and the closure does not copy it but changes it. counter returned long ago, yet n is alive: it is accessible through c1. A new call to counter() creates a new n, which is why c2() starts from 1.
  • Two closures over one variable. incr and get are created by one call of pair and share one n: after two incr() calls get() gives 2.
  • An assignment after creation. read was created at x = 1 and called after x = 2 — and it returned 2. The value 1 is not in the closure and never was: there is a reference to x.
  • A write from the closure. set assigned x = 10, and the surrounding function sees it.

Counters, generators, memoization and stateful handlers all come from this one idea: a variable that lives longer than the call that created it and is reachable only through the closure. The flip side is the same: a shared variable is seen by everyone who captured it. If such closures are called from different goroutines without synchronization, that is a data race — the one covered in the lesson on data races.

All of this fits in a short program. The full file bench/goclosure/cases/example/main.go can be copied into an empty directory next to a go.mod (module example, go 1.24) and run with go run .:

GO
package main
 
import "fmt"
 
func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}
 
func main() {
	c1 := counter()
	fmt.Println(c1(), c1(), c1())
	c2 := counter()
	fmt.Println(c2())
 
	var fs []func() string
	for _, s := range []string{"a", "b", "c"} {
		fs = append(fs, func() string { return s })
	}
	fmt.Println(fs[0](), fs[1](), fs[2]())
}

Output of bench/goclosure/example.sh; the header label is translated:

go1.24.7 linux/amd64 | full example: go run .

1 2 3
1
a b c

The last line is the loop from Mechanism 2 in a module with go 1.24.

An answer to "what is a closure" that survives the next question: a function literal that refers to variables of the surrounding function. Those variables are shared between the function and the literal and live as long as they are accessible: the closure sees their current value at the moment of the call, not the one at creation.

Mechanism 2: the loop variable — one per loop or one per iteration

The classic closure trap is a loop. How many variables does it have? Since Go 1.22 the specification answers — for variables the loop declares itself:

Each iteration has its own separate declared variable (or variables)

— The Go Specification — For statements

And it describes the past in one sentence: "Prior to [Go 1.22], iterations share one set of variables instead of having their own separate variables". Which of the two semantics applies is decided by the language version of the module — the go line in go.mod — not by the compiler version. The check: the same main.go in two modules, go 1.21 and go 1.22, one go1.24.7 compiler:

Fragment of bench/goclosure/cases/loop12x/main.go (the file is the same in both modules):

GO
var names = []string{"a", "b", "c"}
 
func rangeClosures() []func() string {
    var fs []func() string
    for _, s := range names {
        fs = append(fs, func() string { return s })
    }
    return fs
}
 
// the example from the For statements section of the specification
func specExample() []func() int {
    var prints []func() int
    for i := 0; i < 5; i++ {
        prints = append(prints, func() int { return i })
        i++
    }
    return prints
}

Output of bench/goclosure/loopvar.sh, sections 1 and 2; labels translated:

1) MODULE WITH go 1.21 IN go.mod
────────────────────────────────
  closures from a range loop:     c c c
  value passed as a parameter:    a b c
  example from the spec:          6 6 6
  distinct &i over 3 iterations:  1
  allocations per 3-closure loop: 4
2) MODULE WITH go 1.22 IN go.mod
────────────────────────────────
  closures from a range loop:     a b c
  value passed as a parameter:    a b c
  example from the spec:          1 3 5
  distinct &i over 3 iterations:  3
  allocations per 3-closure loop: 3

The line with addresses is the whole mechanism. In the old module &i gave one address over three iterations: there is one variable, and each iteration assigns a new value to it. The three closures captured that one variable and are called after the loop — when it holds the last value. Hence c c c. In the new module there are three addresses: each iteration has its own variable, and each closure looks at its own.

The specification's example is subtler: the loop body itself does i++. In the old module the single i reached 6, and all three closures print 6. In the new one each iteration gets its own i, initialized with the previous one's value before the i++ in the header, and the i++ in the body changes only its own iteration's variable: 1 3 5 — exactly what the specification promises.

The middle line is a way to pin the value in any version: pass it as a parameter. The argument is copied into the parameter at the call, and each call has its own variable:

Fragment of the function paramClosures from the same main.go:

GO
mk := func(v string) func() string { return func() string { return v } }
for _, s := range names {
    fs = append(fs, mk(s))
}

The line s := s in the loop body does the same — it declares a new variable on every iteration. In modules with go 1.22 and later it is not needed if the loop itself declares s with :=; in older modules it is protection, not clutter.

The proviso "the loop declares it itself" is not a formality. The specification writes the rule of one variable per iteration for variables declared in the range header with :=; about the others it says something else:

If the iteration variables are not explicitly declared by the "range" clause, they must be preexisting. In this case, the iteration values are assigned to the respective variables as in an assignment statement.

— The Go Specification — For statements with range clause

Fragment of bench/goclosure/cases/loop12x/assign.go — the variable is declared before the loop, and the loop assigns to it:

GO
func assignClosures() []func() string {
    var s string
    var fs []func() string
    for _, s = range names {
        fs = append(fs, func() string { return s })
    }
    return fs
}

Output of bench/goclosure/loopvar.sh, section 3; labels translated:

3) VARIABLE DECLARED BEFORE THE LOOP: for _, s = range
──────────────────────────────────────────────────────
  module with go 1.21 in go.mod:
  var s string; for _, s = range names: c c c
  ./assign.go:14:6: assignClosures capturing by ref: s (addr=false assign=true width=16)
  ./assign.go:14:6: moved to heap: s
  module with go 1.22 in go.mod:
  var s string; for _, s = range names: c c c
  ./assign.go:14:6: assignClosures capturing by ref: s (addr=false assign=true width=16)
  ./assign.go:14:6: moved to heap: s

One variable for the whole loop in both modules: = assigns rather than declares, and the go line in go.mod changes nothing here.

The practical conclusion: "Go 1.22 fixed this" is only half true. A module whose go.mod declares 1.21 or lower keeps the old semantics even on a new compiler, and a loop with = instead of := shares one variable in any module. What this looks like in parallel subtests and what go vet says about it is in the lesson on testing; there the same trap produced a test that checked only the last case of the table and passed.

The last line of the run is a bonus: the new semantics here is not more expensive but one allocation cheaper. Why — in Mechanism 5.

Mechanism 3: what is evaluated at once — defer arguments and method values

If a closure reads a variable at the moment of the call, where does a value get pinned in advance? Where the language evaluates an expression and saves the result. In this topic there are two such places besides ordinary argument passing.

defer arguments. The rule from the specification:

Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked.

— The Go Specification — Defer statements

A method value. The expression v.Get without parentheses is also a function, "bound" to a receiver. The specification says when the receiver is bound:

The expression x is evaluated and saved during the evaluation of the method value; the saved copy is then used as the receiver in any calls, which may be executed later.

— The Go Specification — Method values

A check of both rules:

Fragment of bench/goclosure/internals.go:

GO
func deferDemo() {
    x := 1
    defer record("defer record(x)", x)
    defer func() { record("defer func() { record(x) }()", x) }()
    x = 2
}
 
type Counter struct{ n int }
 
func (c Counter) Get() int   { return c.n } // value receiver
func (c *Counter) PGet() int { return c.n } // pointer receiver

Output of bench/goclosure/internals.go, section 3; labels translated:

3) WHAT IS EVALUATED AT ONCE AND WHAT LATER
───────────────────────────────────────────
  x := 1; two defers; x = 2 — what they saw on exit:
    defer func() { record(x) }()     -> x = 2
    defer record(x)                  -> x = 1
  v := Counter{1};  g := v.Get;   v.n = 2 -> g() = 1, v.Get() = 2
  p := &Counter{1}; g := p.PGet;  p.n = 2 -> g() = 2   (PGet on *Counter)
  p := &Counter{1}; g := p.Get;   p.n = 2 -> g() = 1   (Get on Counter)

Two groups of lines, and in both the line runs along one question — what exactly was evaluated at the moment it was written:

  • defer record(x) evaluated the argument x at once — 1 was saved. defer func() { record(x) }() evaluated only the function itself, which has no arguments; it reads x on exit, when it already holds 2. The order of the lines is the reverse of the defer order — the last deferred runs first. More on defer is in the lesson on defer, panic and recover.
  • g := v.Get with a value receiver copied v. Changing v.n afterwards does not touch the copy: g() is 1, although v.Get() is already 2.
  • g := p.PGet with a pointer receiver saved the pointer — a copy of the address, which leads to the same variable. That is why g() sees 2.
  • g := p.Get is a value method taken through a pointer. The specification clarifies that pt.Mv here is equivalent to (*pt).Mv: the dereference and the copy happen at evaluation, and g() returned 1.

The practical consequence is handlers and callbacks. http.HandleFunc("/", srv.handle) or time.AfterFunc(d, c.flush) save a method value. If handle has a value receiver, the handler will work for its whole life with the copy of srv taken at registration. This is exactly the rule from the lesson on pointers, only here the copy is deferred in time.

Mechanism 4: the variadic parameter — the same slice or a new one

Inside func f(xs ...int) the parameter xs is a []int slice. Where it comes from depends on how the function was called. The specification distinguishes two cases. With a list of arguments:

Otherwise, the value passed is a new slice of type []T with a new underlying array whose successive elements are the actual arguments

— The Go Specification — Passing arguments to ... parameters

With a slice and ...:

If the final argument is assignable to a slice type []T and is followed by ..., it is passed unchanged as the value for a ...T parameter. In this case no new slice is created.

— The Go Specification — Passing arguments to ... parameters

The check:

Fragment of bench/goclosure/internals.go:

GO
func bump(xs ...int) {
    if len(xs) > 0 {
        xs[0] = 100
    }
}
 
func first(xs ...int) *int { return &xs[0] }
 
func add9(xs ...int) []int { return append(xs, 9) }

Output of bench/goclosure/internals.go, section 4; labels translated:

4) VARIADIC PARAMETER: THE SAME SLICE OR A NEW ONE
──────────────────────────────────────────────────
  s := []int{1, 2, 3}; bump(s...)       -> s = [100 2 3]
  a1 := 1; bump(a1, 2, 3)               -> a1 = 1
  first(s...) == &s[0]                  -> true
  isNil()                               -> true
  s2 := make([]int, 2, 4); add9(s2...)  -> s2[:3] = [0 0 9], len(s2) = 2
  • bump(s...) wrote 100 into the caller's s[0]: xs and s are headers over one array, and first(s...) == &s[0] confirms it by address.
  • bump(a1, 2, 3) received a new slice of argument copies — a1 is untouched.
  • With no arguments xs is nil: that is what the specification says.
  • The last line is a more serious consequence. add9 did an append inside. s2 has length 2 and spare capacity, so append wrote 9 into the caller's array, beyond its length: s2[:3] shows [0 0 9]. s2 itself does not see it (len(s2) = 2), but the caller's next append(s2, …) will overwrite that nine or be overwritten by it.

The rule for code: f(s...) passes a slice; it does not copy arguments. A function that must not touch the caller's data copies the slice itself (slices.Clone). How the header, the array and spare capacity work is in the lesson on slices.

Mechanism 5: a captured variable goes to the heap along with the closure

The specification promises that captured variables "survive as long as they are accessible", and says nothing about where they live: in the function's frame (on the stack) or on the heap. The compiler decides. If, after optimizations, a closure stays reachable after the function returns, its variable must outlive the frame, and the compiler moves it to the heap. The file below is built with -l — no inlining — so the returned closures in it escape. The compiler names the decision itself:

Fragment of bench/goclosure/cases/escape/main.go; the comments with line numbers are added here:

GO
func counter() func() int {   // line 6
    n := 0
    return func() int {
        n++
        return n
    }
}
 
func local() int {            // line 15
    n := 0
    inc := func() { n++ }
    inc()
    inc()
    return n
}
 
func fixed() func() int {     // line 24
    x := 42
    return func() int { return x }
}

Output of bench/goclosure/escape.sh; labels translated:

go1.24.7 linux/amd64 | go build -gcflags='-m -l': decisions about closures

  ./main.go:7:2: moved to heap: n
  ./main.go:8:9: func literal escapes to heap
  ./main.go:17:9: func literal does not escape
  ./main.go:26:9: func literal escapes to heap
  ./main.go:36:33: t.Get escapes to heap
  ./main.go:38:10: xs does not escape
  ./main.go:47:33: ... argument does not escape

Lines 7–8 are counter: both the variable n and the closure itself go to the heap. Line 17 is local: the closure is called in place and goes nowhere, and n stayed in the frame, even though it is modified. Lines 36–47 are methodValue and sum from the same file; we will come back to them below. Line 26 is fixed: the closure escaped, but there is no moved to heap: x line. The answer is in the -m=2 report:

Output of bench/goclosure/escape.sh, second part; labels translated:

  -gcflags='-m=2 -l', capture lines only:
  ./main.go:7:2: counter capturing by ref: n (addr=false assign=true width=8)
  ./main.go:16:2: local capturing by ref: n (addr=false assign=true width=8)
  ./main.go:25:2: fixed capturing by value: x (addr=false assign=false width=8)
implementation detail · Go 1.24Capture by value is an optimization of the gc compiler, not a language rule: semantically a closure always captures the variable. The condition is in escape.go, lines 245–246 in go1.24.7 (261–262 in go1.25.1); the -m=2 output is the same on both versions.

Nobody reassigns x, so the compiler copies it into the closure instead of a reference — the program cannot notice the difference, since the variable does not change. The condition is written in escape.go: "Capture by value for variables <= 128 bytes that are never reassigned". n in counter changes (assign=true) — capture by reference, and n moves to the heap as a separate object.

This also explains the last line of Mechanism 2: in the module with go 1.21 the loop variable s is reassigned on every iteration — capture by reference and moved to heap: s; with go 1.22 each iteration has its own s that nobody changes — capture by value:

Output of bench/goclosure/loopvar.sh, sections 1 and 2, the -m=2 lines; labels translated:

  -gcflags=-m=2, function stored:
  ./main.go:58:9: stored capturing by ref: s (addr=false assign=true width=16)
  ./main.go:58:9: moved to heap: s
  -gcflags=-m=2, function stored:
  ./main.go:58:9: stored capturing by value: s (addr=false assign=false width=16)

Hence 4 and 3: in the old module, the variable s on the heap plus three closure objects; in the new one, only three objects, each holding a copy of s.

What this means in memory allocations per call:

Output of bench/goclosure/internals.go, section 5; labels translated:

5) MEMORY ALLOCATIONS PER CALL
──────────────────────────────
  closure called in place (inPlace)            0
  counter(): n changes, closure returned       2
  fixed(): x unchanged, closure returned       1
  method value gv.Get stored                   1
  sum(1, 2, 3)                                 0
  sum(s...)                                    0

counter() costs two allocations: n and the closure object. Here the result of counter() is stored in the global variable sinkF, so it certainly escapes. fixed() costs one: x lies inside the closure object. A method value stored in a global variable costs one as well: it needs somewhere to keep the copy of the receiver (t.Get escapes to heap in the report). A variadic call whose slice does not escape does without the heap (... argument does not escape).

The general rule is the same as in the lesson on escape analysis: capturing by itself costs nothing; what costs is the closure outliving the frame. Typical sources of such allocations are go func() { ... }() and callbacks stored in structs.

implementation detail · Go 1.24The allocation counts are an observation of the gc compiler go1.24.7 (the same on go1.25.1) at a particular call site, not a language contract: the specification speaks only about the lifetime of variables.

That "outliving the frame" is decided at the call site, not in the function's declaration, is visible from the same counter() called three different ways (counterNI is its copy with //go:noinline):

Output of bench/goclosure/internals.go, section 6; labels translated:

6) ALLOCATIONS OF counter() DEPEND ON THE CALL SITE
───────────────────────────────────────────────────
  c := counter(); c(); c() — in place          0
  sinkF = counter() — stored outside           2
  c := counterNI(); c(); c() — noinline        2

An inlined counter() whose closure is used in place and handed to nobody did without the heap: after inlining the closure does not escape. The non-inlined copy costs two allocations: inside it the compiler cannot see that the result will not escape further. So the right answer to "how many allocations does counter() take" starts with "where and how was it called": the code is correct either way, and the placement and the cost are decided by the compiler.

Deeper: what a function value is and what a call costs

implementation detail · Go 1.24The layout of a function value and of a closure object is an implementation detail of the gc compiler, not of the specification: cmd/compile/abi-internal.md lines 356–364 and 410, typecheck/func.go line 94, walk/closure.go lines 15–31 in go1.24.7.

unsafe.Sizeof(op) gave 8 bytes: a function value is a pointer. The description of the compiler's internal ABI (cmd/compile/abi-internal.md, lines 356–359 in go1.24.7) says what it points to:

A closure object begins with a pointer-sized program counter representing the entry point of the function, followed by zero or more bytes containing the closed-over environment.

— cmd/compile/abi-internal.md

An ordinary function has no environment — zero bytes. For a closure it is the captured variables; the compiler describes the object in typecheck/func.go like this:

Fragment of a comment in typecheck/func.go, line 94:

GO
//	clos = &struct{F uintptr; X0 *int; X1 *string}{func.1, &i, &s}

The field F is the literal's code, followed by the captured variables: by reference (a pointer, like *int here) or by value (a copy). On a call the object's address is put into the closure context register — RDX on amd64 (line 410 of the same document) — and the literal's body fetches the captured data from there.

Two conclusions follow, and a measurement checks them. First: an ordinary function stored in a variable is the same kind of function value without captured data, so calling a closure should cost about as much. Second: what is expensive is not calling but creating a closure that escapes — that is a heap allocation of the object. And a literal called directly in place (func() { … }()) is rewritten by the compiler into a normal call with the captured variables as arguments; the comment on directClosureCall says "This avoids allocation of a closure object". inPlace in the run also has zero allocations, but for a different reason: the closure is stored in a variable and goes nowhere (func literal does not escape for the similar local), so its object stays in the frame.

measured observationbench/goclosure/practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2, best of 11 rounds of testing.Benchmark on a machine shared with other jobs; one call per iteration, addOne and makeAdder are marked //go:noinline, the result is written to a global variable. This is one configuration: on another machine, version and load the ratios may differ — measure them yourself.

Output of bench/goclosure/practice.go, task 2; labels translated:

  addOne(i)        direct call               1.63 ns   allocs 0   ratio  1.00
  viaVar(i)        function in a variable    1.86 ns   allocs 0   ratio  1.14
  viaClo(i)        closure in a variable     1.73 ns   allocs 0   ratio  1.06
  makeAdder(i)(i)  create and call          20.82 ns   allocs 1   ratio 12.77

addOne is marked //go:noinline, so the direct call is a real call, not an inlined addition. Calling a function through a variable and calling a closure through a variable do not differ from the direct call within noise: 1.86 and 1.73 ns against 1.63. But creating the closure makeAdder(i) and storing it outside costs 20.82 ns and one allocation — 12.8 times more than a direct call.

A summary to keep in mind instead of "closures are slow":

operationallocationscost relative to a direct call
call a closure that already exists0≈1 (1.06 in the run)
call a closure that does not escape0the object stays in the frame
create a closure that escapes1–212.8 in this measurement, with one allocation

The numbers are an observation of this configuration; they answer "can it differ", not "how it will be in your code": a closure becomes expensive when it is created in a hot loop and let out — and that is what to look for with a profile.

How to answer in an interview

Short answer: a function in Go is a value: it has a type and the zero value nil, it is passed and returned; it can be compared only with nil. A closure is a function literal that refers to variables of the surrounding function, and those variables are shared: the closure sees their current value at the moment of the call, not a copy from the moment of creation.

That is enough to answer correctly. What follows is what to add if the interviewer digs.

If the interviewer digs deeper

On "what does the counter print" — one variable per call. "Each call of counter() creates its own n, and the closure changes it: three calls give 1 2 3, a new counter starts from 1. Two closures from one call share one variable."

On "closures in a loop" — name go.mod. "Since Go 1.22 each iteration has its own variable, but only if the loop declares it itself (:=) and only in modules whose go.mod declares 1.22 or later; for _, s = range with a previously declared s gives c c c there too. The same loop on go1.24.7 gave c c c with go 1.21 and a b c with go 1.22. In old code the value is passed as a parameter or s := s is written."

On "when is a value pinned" — where the language evaluates at once. "defer arguments are evaluated at the defer, the closure body on exit. A method value v.Get with a value receiver copies v when taken: after v.n = 2 it returned 1. With a pointer receiver the address is copied, and changes are visible."

On "does f(s...) copy?" — no. "The same slice is passed: a write to xs[0] is visible to the caller, and an append inside writes into the caller's array when there is spare capacity. A new slice is created only for a list of arguments f(a, b, c)."

On "what does it cost" — creation, not the call. "In our measurement a closure call cost as much as a direct call. What costs more is creating a closure that escapes after optimizations: its variables and the object itself go to the heap — moved to heap: n, two allocations for a counter() whose result was stored in a global variable; an inlined counter() used in place gave zero. How many times that is on your machine has to be measured."

Next they ask

Next they ask

How do you give each goroutine in a loop its own value in an old module?

Short answer

Pass the value as a parameter: go func(v string) { use(v) }(s) — the argument is evaluated when the goroutine is started and copied into its parameter. Or declare a new variable in the body: s := s. In the run the parameter gave a b c in both modules — with go 1.21 and with go 1.22.

The best fix for living code is to raise the go line in go.mod to 1.22 or later: then the new semantics applies to the whole module. Before that it is worth understanding that the behaviour of all loops in the module changes, including those that accidentally relied on the shared variable. How go vet catches this mistake in subtests is in the lesson on testing.

Next they ask

How does the method value v.Get differ from the method expression Counter.Get?

Short answer

The method value v.Get binds the receiver: it is a func() int, and v is already copied into it. The method expression Counter.Get does not bind the receiver: it is a func(Counter) int, and the receiver is passed as the first argument. In the specification: "given f := T.Mv, f is invoked as f(t, 7) not t.f(7)".

Hence the choice. If you need a function that works with one particular receiver — a method value, with a copy taken at that moment. If you need a function that can be applied to different receivers (say, in a table of handlers) — a method expression.

Next they ask

Why can't a function be a map key?

Short answer

Because a map key must be comparable, and functions are not — except for comparison with nil. The compiler answers invalid map key type func(int) int.

If you need a "set of handlers", make the key something comparable: a name, a number, a pointer to a struct that holds the function. Comparing through any is no way out: in the run any(inc) == any(inc) panicked with comparing uncomparable type func(int) int.

Common misconceptions

Claim

A closure remembers the variable's value at the moment it is created

Actually

It refers to the variable itself. A closure created at x = 1 and called after x = 2 returned 2. A value is pinned only where the language evaluates something at once: a call argument, a defer argument, the receiver of a method value.

Claim

Since Go 1.22 the loop variable in closures is no longer a trap

Actually

Only for a variable the loop declares itself (:=), and only in modules whose go.mod declares 1.22 or later. The same loop on the go1.24.7 compiler in a module with go 1.21 gave c c c, and &i had one address over three iterations; and for _, s = range with a previously declared s gave c c c with go 1.22 too.

Claim

Two functions can be compared if they are the same function

Actually

f == g does not compile: func can only be compared to nil. Through an interface the comparison compiles but fails: comparing uncomparable type func(int) int.

Claim

f(s...) passes a copy of the elements to the function

Actually

The slice itself is passed: bump(s...) changed the caller's s[0] to 100, and &xs[0] == &s[0]. A new slice is created only for a call with a list of arguments.

Claim

The method value v.Get sees later changes to v

Actually

Not with a value receiver: v is copied when the value is taken, and after v.n = 2 g() returned 1. With a pointer receiver the address is copied, and changes are visible: 2.

Claim

Every closure allocates on the heap

Actually

Only one that escapes after optimizations. A closure called in place has 0 allocations, func literal does not escape. A counter() whose result was stored in a global variable has 2 allocations, while the same counter(), inlined and used in place, has 0. This is an observation of the go1.24.7 compiler, not a language guarantee.

Claim

Calling a closure is slower than calling an ordinary function

Actually

In our measurement, no: 1.73 ns against 1.63 ns for a direct call, within noise. What costs more is creating a closure that escapes: 20.82 ns and one allocation. This is one configuration (go1.24.7, linux/amd64, //go:noinline, a global sink for the result) — measure on your own machine.

Version history

The lesson's runs are go1.24.7 and go1.25.1, linux/amd64; checked on 14.10.2026. By that date Go 1.26 and Go 1.27 had been released — the lesson did not measure them.

VersionChangeWhat it means for code
go 1.21 in go.modOne loop variable for the whole loop: on the go1.24.7 compiler three closures from a range loop gave c c c, the specification's example gave 6 6 6, and &i had one address. Checked by the run of bench/goclosure/loopvar.sh.pass the value to the closure as a parameter or write s := s
go 1.22 in go.modEach iteration has its own variable: a b c, 1 3 5, three addresses. The go line in go.mod decides, not the compiler version.a closure in a loop sees its own iteration's value
any, for _, s = rangeThe variable is declared before the loop, and the loop assigns to it: c c c both in a module with go 1.21 and with go 1.22. The rule of one variable per iteration applies only to variables the loop declares itself.one variable per loop with any go line

Practice

Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from the script's run, not assigned.

Practice · predict the output

counter returns a closure over its own variable n. bump writes 10 into xs[0]. T has a method Get with a value receiver. What does this code print?
func counter() func() int {
n := 0
return func() int { n++; return n }
}

func bump(xs ...int) { xs[0] = 10 }

type T struct{ n int }

func (t T) Get() int { return t.n }

a, b := counter(), counter()
a()
a()
fmt.Println(a(), b())
s := []int{1, 2, 3}
bump(s...)
fmt.Println(s)
v := T{n: 1}
f := v.Get
v.n = 2
fmt.Println(f(), v.Get())

Practice · estimate

makeAdder(k) returns the closure func(x int) int { return x + k }; the function is not inlined, and the result is stored in a global variable. How many times more expensive is "create a closure with makeAdder(i) and call it" than a direct call of the non-inlined addOne(i)?
times

Knowledge check

Question 1 of 5

x := 1; read := func() int { return x }; x = 2. What does read() return?

Sources & further reading

8 SOURCES

  1. The Go Specification — Function types, Calls, Comparison operatorsOfficial documentation. The zero value: «The value of an uninitialized variable of function type is nil». Calling nil: «Calling a nil function value causes a run-time panic». Comparison: «Slice, map, and function types are not comparable. However, as a special case, a slice, map, or function value may be compared to the predeclared identifier nil». Checked against doc/go_spec.html of the go1.24.7 and go1.25.1 distributions.https://go.dev/ref/spec#Function_types
  2. The Go Specification — Function literalsOfficial documentation. The definition of a closure: «Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible».https://go.dev/ref/spec#Function_literals
  3. The Go Specification — For statementsOfficial documentation. The new semantics: «Each iteration has its own separate declared variable (or variables)» — marked Go 1.22. The old one: «Prior to [Go 1.22], iterations share one set of variables instead of having their own separate variables». The specification's example prints 1 3 5 and 6 6 6 respectively; the run of bench/goclosure/loopvar.sh reproduces both outputs. The new rule is for variables declared in the header with :=; for the others (section For statements with range clause): «If the iteration variables are not explicitly declared by the "range" clause, they must be preexisting. In this case, the iteration values are assigned to the respective variables as in an assignment statement»; section 3 of loopvar.sh: for _, s = range gave c c c with go 1.21 and with go 1.22. Checked 14.10.2026.https://go.dev/ref/spec#For_clause
  4. The Go Specification — Passing arguments to ... parametersOfficial documentation. A call with a list of arguments: «the value passed is a new slice of type []T with a new underlying array whose successive elements are the actual arguments». A call with s...: «If the final argument is assignable to a slice type []T and is followed by ..., it is passed unchanged as the value for a ...T parameter. In this case no new slice is created».https://go.dev/ref/spec#Passing_arguments_to_..._parameters
  5. The Go Specification — Method values and Defer statementsOfficial documentation. A method value: «The expression x is evaluated and saved during the evaluation of the method value; the saved copy is then used as the receiver in any calls, which may be executed later». Defer: «Each time a "defer" statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked».https://go.dev/ref/spec#Method_values
  6. cmd/compile/internal/escape/escape.go — capture by valueGo source code. Lines 245–246 in go1.24.7 (261–262 in go1.25.1): the comment «Capture by value for variables <= 128 bytes that are never reassigned» and n.SetByval(!loc.addrtaken && !loc.reassigned && n.Type().Size() <= 128). Line 259 (go1.24.7) prints «capturing by value» or «capturing by ref» under -m=2 — the lesson quotes these lines.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/escape/escape.go#L245-L246
  7. cmd/compile/abi-internal.md — a func value in the internal ABIGo source code. Lines 356–359 in go1.24.7 and go1.25.1: «A func value (e.g., var x func()) is a pointer to a closure object. A closure object begins with a pointer-sized program counter representing the entry point of the function, followed by zero or more bytes containing the closed-over environment». Line 410: on amd64 the closure context pointer register is RDX.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/abi-internal.md
  8. cmd/compile/internal/typecheck/func.go and walk/closure.go — how a closure is builtGo source code. typecheck/func.go, line 94 in go1.24.7 and go1.25.1: a closure is created as clos = &struct{F uintptr; X0 *int; X1 *string}{func.1, &i, &s} — a pointer to the code plus the captured variables. walk/closure.go, lines 15–31: a direct call of a function literal is rewritten into a normal call with the captured variables as arguments — «This avoids allocation of a closure object». runtime/runtime2.go, lines 174–177 in go1.24.7 (173–176 in go1.25.1): type funcval struct { fn uintptr; ... }.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/typecheck/func.go#L88-L102