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.
- 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
deferpostpones a call until the function exits — the details are in the lesson on defer, panic and recover.
- what a function literal, a method value and a variadic parameter
...Tare; - what the stack, the heap, escape analysis and the
-gcflags=-mflag 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
goline ingo.mod.
What is really being asked
The ladder is almost always this one:
- "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.
- "What is a closure?" — "a function that remembers its environment" is correct but misses the main point: it remembers variables, not values.
- "What does this counter print?" (with code) — the same check, on code.
- "What do closures created in a loop print?" — a classic that has had
two answers since Go 1.22, and
go.modchooses between them. - "When are
deferarguments evaluated? What doesf := v.Methodsave?" — where a value does get pinned at the moment it is written. - "Does
f(s...)pass a copy of the slice?" — about the variadic parameter. - "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.
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):
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.
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 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 key word is shared. Not "copied into the closure" — shared. The check:
Fragment of bench/goclosure/internals.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.
nis a variable ofcounter, and the closure does not copy it but changes it.counterreturned long ago, yetnis alive: it is accessible throughc1. A new call tocounter()creates a newn, which is whyc2()starts from1. - Two closures over one variable.
incrandgetare created by one call ofpairand share onen: after twoincr()callsget()gives2. - An assignment after creation.
readwas created atx = 1and called afterx = 2— and it returned2. The value1is not in the closure and never was: there is a reference tox. - A write from the closure.
setassignedx = 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 .:
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)
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):
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:
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.
Fragment of bench/goclosure/cases/loop12x/assign.go — the variable is
declared before the loop, and the loop assigns to it:
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.
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.
A check of both rules:
Fragment of bench/goclosure/internals.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 receiverOutput 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 argumentxat once —1was saved.defer func() { record(x) }()evaluated only the function itself, which has no arguments; it readsxon exit, when it already holds2. The order of the lines is the reverse of thedeferorder — the last deferred runs first. More ondeferis in the lesson on defer, panic and recover.g := v.Getwith a value receiver copiedv. Changingv.nafterwards does not touch the copy:g()is1, althoughv.Get()is already2.g := p.PGetwith a pointer receiver saved the pointer — a copy of the address, which leads to the same variable. That is whyg()sees2.g := p.Getis a value method taken through a pointer. The specification clarifies thatpt.Mvhere is equivalent to(*pt).Mv: the dereference and the copy happen at evaluation, andg()returned1.
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
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 check:
Fragment of bench/goclosure/internals.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...)wrote100into the caller'ss[0]:xsandsare headers over one array, andfirst(s...) == &s[0]confirms it by address.bump(a1, 2, 3)received a new slice of argument copies —a1is untouched.- With no arguments
xsisnil: that is what the specification says. - The last line is a more serious consequence.
add9did anappendinside.s2has length 2 and spare capacity, soappendwrote9into the caller's array, beyond its length:s2[:3]shows[0 0 9].s2itself does not see it (len(s2) = 2), but the caller's nextappend(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:
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)
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.
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
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.
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:
// 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.
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":
| operation | allocations | cost relative to a direct call |
|---|---|---|
| call a closure that already exists | 0 | ≈1 (1.06 in the run) |
| call a closure that does not escape | 0 | the object stays in the frame |
| create a closure that escapes | 1–2 | 12.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
How do you give each goroutine in a loop its own value in an old module?
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.
How does the method value v.Get differ from the method expression Counter.Get?
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.
Why can't a function be a map key?
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
A closure remembers the variable's value at the moment it is created
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.
Since Go 1.22 the loop variable in closures is no longer a trap
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.
Two functions can be compared if they are the same function
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.
f(s...) passes a copy of the elements to the function
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.
The method value v.Get sees later changes to v
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.
Every closure allocates on the heap
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.
Calling a closure is slower than calling an ordinary function
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.
| Version | Change | What it means for code |
|---|---|---|
| go 1.21 in go.mod | One 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.mod | Each 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 = range | The 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
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
Knowledge check
x := 1; read := func() int { return x }; x = 2. What does read() return?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- 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 valuenil; it is stored in a variable, passed and returned. Callingnilpanics, and two functions cannot be compared:f == gdoes not compile, comparison is allowed only againstnil. - 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()gives1 2 3over three calls — each call changes the samen— of the two closures from one call ofpair(),getsees the changes made byincr, and a closure created beforex = 2reads2. - Beyond that comes what separates real understanding from having read about it. How many variables a loop has is decided by the
goline ingo.mod— and only for a variable the loop declares itself (for _, s := range): the same code on the same go1.24.7 compiler gavec c cin a module withgo 1.21anda b cwithgo 1.22, while a loop that assigns to a previously declareds(for _, s = range) gavec c cin both modules. A value is pinned only where the language evaluates something at once: a call argument, adeferargument, the receiver of a method value —g := v.Get; v.n = 2, andg()returned1.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: forsinkF = counter()the run counted two allocations, while the samecounter(), 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.
In fact
- It refers to the variable itself. A closure created at
x = 1and called afterx = 2returned2. A value is pinned only where the language evaluates something at once: a call argument, adeferargument, the receiver of a method value. - Only for a variable the loop declares itself (
:=), and only in modules whosego.moddeclares 1.22 or later. The same loop on the go1.24.7 compiler in a module withgo 1.21gavec c c, and&ihad one address over three iterations; andfor _, s = rangewith a previously declaredsgavec c cwithgo 1.22too. f == gdoes not compile:func can only be compared to nil. Through an interface the comparison compiles but fails:comparing uncomparable type func(int) int.- The slice itself is passed:
bump(s...)changed the caller'ss[0]to100, and&xs[0] == &s[0]. A new slice is created only for a call with a list of arguments. - Not with a value receiver:
vis copied when the value is taken, and afterv.n = 2g()returned1. With a pointer receiver the address is copied, and changes are visible:2. - Only one that escapes after optimizations. A closure called in place has 0 allocations,
func literal does not escape. Acounter()whose result was stored in a global variable has 2 allocations, while the samecounter(), inlined and used in place, has 0. This is an observation of the go1.24.7 compiler, not a language guarantee. - 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.
By version
- go 1.21 in go.mod
- One 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 gave6 6 6, and&ihad one address. Checked by the run ofbench/goclosure/loopvar.sh.< - go 1.22 in go.mod
- Each iteration has its own variable:
a b c,1 3 5, three addresses. Thegoline ingo.moddecides, not the compiler version.< - any, for _, s = range
- The variable is declared before the loop, and the loop assigns to it:
c c cboth in a module withgo 1.21and withgo 1.22. The rule of one variable per iteration applies only to variables the loop declares itself.<
What is covered
- What is really being asked
- Base: a function is a value
- Mechanism 1: a closure captures the variable, not the value
- Mechanism 2: the loop variable — one per loop or one per iteration
- Mechanism 3: what is evaluated at once — defer arguments and method values
- Mechanism 4: the variadic parameter — the same slice or a new one
- Mechanism 5: a captured variable goes to the heap along with the closure
- Deeper: what a function value is and what a call costs
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
8 SOURCES
- 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
- 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
- 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.shreproduces 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 - 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
- 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
- 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 - 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 - 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