defer Argument Evaluation Timing
When you write
defer f(x), Go evaluates the function valuefand the argumentxat the moment thedeferstatement runs — and saves those evaluated values. The call itself is postponed to when the surrounding function returns, but the arguments are already frozen. The spec is exact: “each time adeferstatement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked” (go.dev/ref/spec). The gotcha is the gap between two intuitions: “the deferred call runs at the end” (true) and “so it sees the variables’ final values” (false — it sees the values they had whendeferexecuted). A closure deferred instead of a call behaves differently, and conflating the two is the bug.
This is a focused gotcha note. The runtime mechanics of defer — the _defer record, open-coded defers, the deferred-call linked list, the panic-unwinding path — are defer Internals; here we assume that machinery and isolate one consequence: when arguments are snapshotted.
Mental Model
Split defer f(x) into two events that happen at two different times.
Event A — at the defer statement (now). Go evaluates f (which function value?) and x (what value?), and records the pair into a deferred-call entry. The argument x is copied by value into that entry, exactly as it would be for a normal function call. After this, the entry is a frozen snapshot: changing the variable x afterward does not touch the entry.
Event B — when the surrounding function returns (later). Go walks its deferred entries in last-in-first-out order and invokes each one, using the frozen function value and the frozen arguments from Event A.
So the answer to “what value does the deferred call see?” is: the value the argument expression had at Event A, not Event B. The deferred call runs late, but it runs with early arguments.
The crucial subtlety: defer someFunc(x) snapshots x. But defer func() { use(x) }() snapshots nothing about x — it snapshots the closure value (and the closure has no parameters). The closure body re-reads the variable x when it actually runs, at Event B. The closure captures the variable; the plain call copies the argument. Same defer keyword, opposite timing for the data inside.
flowchart TD subgraph PlainCall["defer fmt.Println(i) -- plain call"] P1["Event A (defer stmt runs):<br/>evaluate i -> snapshot value 0"] P2["i changes to 1 ..."] P3["Event B (function returns):<br/>call Println with frozen 0 -> prints 0"] P1 --> P2 --> P3 end subgraph Closure["defer func(){ fmt.Println(i) }() -- closure"] C1["Event A: evaluate the closure value<br/>(captures the VARIABLE i, no args)"] C2["i changes to 1 ..."] C3["Event B: run closure body,<br/>RE-READ i now -> prints 1"] C1 --> C2 --> C3 end style P3 fill:#1e3a5f,color:#fff style C3 fill:#7f1d1d,color:#fff
Diagram: the same variable i, deferred two ways. The plain call (left) snapshots i’s value at the defer statement and prints 0. The closure (right) snapshots only the closure value — which captures the variable — and re-reads i at execution time, printing 1. The insight: defer always evaluates its arguments early, but a closure has no arguments to evaluate, so the data it touches is re-read late.
Mechanical Walk-through
What the spec says, word for word
The spec’s “Defer statements” section (go.dev/ref/spec) makes four statements that fully determine the behavior:
- “Each time a
deferstatement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked.” — Arguments are evaluated at thedeferstatement, like any call’s arguments; the call is what is postponed. - “deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred.” — Last-in-first-out execution.
- “if the surrounding function returns through an explicit return statement, deferred functions are executed after any result parameters are set by that return statement but before the function returns to its caller.” — Deferreds run after
returnsets results, which is why a deferred closure can modify named results. - “If a deferred function value evaluates to
nil, execution panics when the function is invoked, not when thedeferstatement is executed.” — Even nil-ness of the function value is decided at Event A but only acted on at Event B.
The blog (go.dev/blog/defer-panic-and-recover) condenses this to its first “rule of defer”: “A deferred function’s arguments are evaluated when the defer statement is evaluated.” Its example: a function sets i := 0, runs defer fmt.Println(i), then i++, then returns — and it prints 0, “because i was evaluated when the defer statement ran.”
Why “evaluated as usual” matters
The phrase “evaluated as usual” is doing real work. It means the argument expression follows ordinary call-argument semantics: it is fully evaluated and the result is copied. For a value type (int, struct, string) that copies the value — a later mutation of the source variable is invisible to the deferred call. For a pointer it copies the pointer — the pointee can still change, and the deferred call will see those changes through the (frozen) pointer. For a slice it copies the slice header — a later append that reallocates is invisible, but element mutations through the shared backing array are visible (see Slice Aliasing and append Surprises). This is the same value/reference distinction as everywhere else in Go; defer does not change it, it just fixes the moment the copy is taken.
The defer/go parallel
The spec’s “Go statements” section uses near-identical language: “the function value and parameters are evaluated as usual in the calling goroutine” (go.dev/ref/spec). So go f(x) and defer f(x) snapshot x at the statement, not at execution. This is why the pre-Go-1.22 loop-variable bug bit both go and defer inside loops when written as closures, and why passing the loop variable as an argument (go f(i) / defer f(i)) was the classic fix — argument evaluation pinned the value. See Loop Variable Capture and Closure Variable Capture.
Receivers are arguments too
defer obj.Method(x) evaluates obj (the receiver) and x at Event A. If obj is later reassigned, the deferred call still uses the old obj. If Method has a pointer receiver, the pointer is snapshotted — the pointed-to struct can still mutate. A method value obj.Method is itself a closure over the receiver, so defer (obj.Method) snapshots the bound receiver at Event A as well.
Code Examples with Line-by-Line Commentary
Example 1 — the canonical snapshot
func a() {
i := 0
defer fmt.Println(i) // Event A: snapshot i == 0 into the deferred entry
i++ // i is now 1, but the snapshot is untouched
return // Event B: invoke Println with the frozen 0
}
// prints: 0Line 3 evaluates i immediately — value 0 — and stores it. Line 4 mutates i to 1, but that mutation cannot reach the already-frozen entry. Line 5 triggers Event B; the deferred Println runs with 0. This is the blog’s exact example.
Example 2 — closure: re-reads at execution time
func b() {
i := 0
defer func() { fmt.Println(i) }() // Event A: snapshot the CLOSURE; it captures variable i
i++ // mutate i; the closure captured the variable, not a value
return // Event B: run closure body, re-read i -> 1
}
// prints: 1Line 3 still has its argument list evaluated at Event A — but the closure literal func(){...} has no parameters, so there is nothing to snapshot except the closure value itself, which closes over the variable i (see Closure Variable Capture). When the body runs at Event B, it reads i then — value 1. The single-character difference between Example 1 and 2 — wrapping in func(){...}() — flips the timing of the data.
Example 3 — LIFO order with snapshotted arguments
func c() {
for i := 0; i <= 3; i++ {
defer fmt.Print(i) // four separate entries, snapshotting 0,1,2,3 respectively
}
}
// prints: 3210Each loop iteration executes a defer statement (Event A) and snapshots the current i — 0, then 1, 2, 3 — into four separate deferred entries. At Event B they run last-in-first-out, so the entry holding 3 runs first: output 3210. This is the spec’s own example. Note this is correct even before Go 1.22, because each defer snapshots the argument by value; the loop-variable bug never bit the argument form, only the closure form.
Example 4 — the resource-cleanup bug
func processAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // Event A snapshots THIS f; good. But...
// ... use f ...
}
return nil // Event B: all f.Close() calls run HERE, not per iteration
}The defer f.Close() is correct in what it closes — each iteration’s defer snapshots that iteration’s f (the receiver is an argument, evaluated at Event A). The bug is when: all the Close calls are deferred to the end of processAll, not the end of each iteration. Open 10,000 files and you hold 10,000 descriptors open until the function returns — a descriptor leak under load. The fix is to put the loop body in its own function so defer fires per iteration:
func processAll(paths []string) error {
for _, p := range paths {
if err := processOne(p); err != nil {
return err
}
}
return nil
}
func processOne(p string) error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // fires when processOne returns -- per file
// ... use f ...
return nil
}Example 5 — pointer argument: snapshot the pointer, not the pointee
func d() {
s := &strings.Builder{}
s.WriteString("start")
defer fmt.Println(s.String()) // Event A: evaluate s.String() NOW -> "start"
s.WriteString("-more")
return // Event B: prints the frozen "start"
}
// prints: startSubtle: s.String() is a method call in the argument position. It is evaluated at Event A, producing the string "start", which is snapshotted. Line 5’s later WriteString does not change the already-computed string. Contrast with defer fmt.Println(s) — that snapshots the pointer s, and fmt.Println calls s.String() at Event B, printing "start-more". The lesson: whatever expression you write as an argument is fully evaluated at Event A.
Failure Modes and How to Diagnose Them
Symptom: a deferred log/print shows a stale value. defer log.Printf("done in %v", time.Since(start)) — wait, that one is fine because time.Since(start) is evaluated at Event A… which is the bug if you wanted elapsed time at function exit. The fix is the closure form: defer func() { log.Printf("done in %v", time.Since(start)) }(). Diagnose by asking: do I want the value at the defer statement or at function exit? Plain call = former, closure = latter.
Symptom: descriptors / locks / connections held too long. defer in a loop (Example 4). Deferreds fire at function return, not loop-iteration end. Diagnose by counting how many defers accumulate before the surrounding function returns. Fix: extract the loop body into a function.
Symptom: defer recover() does not recover. recover must be called directly by a deferred function. defer recover() defers a call to recover — but recover’s argument list (empty) is evaluated at Event A and recover itself runs at Event B in the right place, so this actually can work for catching a panic — however the common mistake is defer fmt.Println(recover()), which evaluates recover() at Event A (before any panic) where it returns nil. Diagnose: recover only does something useful when called during unwinding; evaluate it inside a deferred closure, not as an argument. See panic and recover.
Symptom: a deferred call uses an old receiver. defer obj.Method() after obj is reassigned. The receiver was snapshotted at Event A. Fix: if you want the latest obj, defer a closure that reads obj at Event B.
Common Misunderstandings
“The deferred call runs at the end, so it sees final values.” Only the call is at the end; the arguments were frozen at the defer statement. (Examples 1, 3, 5.)
“defer f(x) and defer func(){ f(x) }() are the same.” They are not. The first snapshots x early; the second re-reads x late. (Examples 1 vs 2.)
“defer in a loop closes each resource at end of iteration.” No — at end of the function. (Example 4.)
“defer recover() is how you recover.” recover must run inside a deferred function during unwinding; the safe idiom is defer func() { if r := recover(); r != nil { ... } }(). Evaluating recover() as a deferred call’s argument runs it at Event A, before any panic, returning nil.
“Go 1.26 changed defer.” It did not — Go 1.26’s release notes list no defer changes (go.dev/doc/go1.26). The argument-evaluation rule has been stable since Go 1.0.
Alternatives and When to Choose Them
Plain defer f(x) — when you want the value snapshotted now. Logging the input arguments, deferring mu.Unlock() (no argument, trivially correct), defer f.Close() on a value you will not reassign. The early snapshot is a feature — it pins exactly the thing you have in hand.
defer func() { ... }() — when you want the value as of function exit. Computing elapsed time, reading a named return value to wrap an error, inspecting state that mutates during the function. The closure re-reads variables at Event B.
Closure that modifies a named return — the error-wrapping idiom. Because deferreds run after return sets results (spec point 3), a deferred closure can post-process the return value:
func load() (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("load: %w", err)
}
}()
// ... return err somewhere ...
}This requires the closure form and a named result. A plain defer cannot do it.
Extract a helper function — when defer in a loop must fire per iteration. Example 4’s fix. The function-call boundary becomes the defer boundary.
Production Notes
The argument-snapshotting rule is the first of the three “rules of defer” the official blog teaches, precisely because it is the one most people get wrong (go.dev/blog/defer-panic-and-recover). In real codebases the two recurring incidents are: (1) defer accumulating in a loop and exhausting file descriptors, database connections, or mutexes — caught in production under load, not in tests with small inputs; and (2) a deferred metric/log that should reflect end-of-function state but was written as a plain call and therefore froze a start-of-function value, producing silently wrong telemetry.
The discipline that prevents both: before writing any defer, decide explicitly which moment the data should reflect. If “now” — plain call. If “function exit” — closure. And if the defer is inside a loop, ask whether it should fire per-iteration; if so, the loop body belongs in its own function. The mechanism is small and fully specified; the bugs come entirely from not pausing to ask which of the two timings you actually want.
See Also
- defer Internals — the
_deferrecord, open-coded defers, the deferred-call list and unwinding - Closure Variable Capture — why a deferred closure re-reads variables instead of snapshotting them
- Loop Variable Capture —
defer/goin loops and the Go 1.22 per-iteration scoping change - panic and recover — why
recovermust be called directly by a deferred function - Slice Aliasing and append Surprises — what “snapshot the header” means when the argument is a slice
- Range Loop Semantics — the broader argument-vs-capture distinction in loops
- Go Internals MOC — parent map (§13 Common Gotchas)