defer Internals
A
deferstatement schedules a function call to run when the surrounding function returns — whether it returns normally, via an earlyreturn, or while unwinding a panic. Deferred calls run in Last-In-First-Out (LIFO) order, their arguments are evaluated at the point thedeferstatement executes (not when the call later runs), and they may read and assign the surrounding function’s named return values (defer, panic, recover). What looks like one tidy keyword is, in the runtime, three distinct implementations that the compiler chooses between: heap-allocated_deferrecords, stack-allocated_deferrecords (since Go 1.13, September 2019), and open-coded defers (since Go 1.14, February 2020) — the last of which costs almost nothing because there is no record at all.
Mental Model
Think of defer as a per-goroutine stack of pending cleanup actions. Each defer statement pushes an action; every function-exit path pops and runs the actions that belong to that function, newest first. The subtlety the runtime hides is where the pushed action lives. In the slow path it is a real _defer struct linked onto the goroutine; in the fast path the compiler has proven the defers are simple enough that it can skip the struct entirely and instead emit the deferred calls inline at every return site, guarded by a bitmask. The fast path is the common case in modern Go, which is why defer is no longer something to avoid in hot loops the way it was before Go 1.13.
flowchart TD A["compiler sees<br/>defer statements<br/>in a function"] --> B{"all defers run<br/>at most once?<br/>(none in a loop)"} B -- no --> H["heap-allocated _defer:<br/>deferproc + heap alloc<br/>linked on goroutine g._defer"] B -- yes --> C{"≤ 8 defers AND<br/>≤ 8 return sites?"} C -- no --> S["stack-allocated _defer:<br/>deferprocStack<br/>record on caller's frame"] C -- yes --> O["OPEN-CODED defers:<br/>no _defer struct at all;<br/>calls inlined at each return,<br/>guarded by a deferBits bitmask"] H --> R["deferreturn / gopanic<br/>walks g._defer chain"] S --> R O --> X["each return site:<br/>if deferBits&mask { call }"] style O fill:#d4edda style X fill:#d4edda
Diagram: the compiler’s decision tree for implementing defer. The insight: which implementation you get is not a language-level choice — it is an optimization the compiler makes per function based on whether your defers are statically simple. The green “open-coded” path is the common, near-free case; the heap path is the expensive fallback reserved for defers inside loops.
Mechanical Walk-through
The three rules of defer
Before the runtime, the language contract. The canonical Go blog post states three rules verbatim:
- “A deferred function’s arguments are evaluated when the defer statement is evaluated.” So in
i := 0; defer fmt.Println(i); i++, the deferred call prints0—i’s value was copied at thedefer, before the increment. This is itself a famous gotcha; see defer Argument Evaluation Timing. - “Deferred function calls are executed in Last In First Out order after the surrounding function returns.” Four
defers in a loop over0..3print3 2 1 0. - “Deferred functions may read and assign to the returning function’s named return values.”
func c() (i int) { defer func() { i++ }(); return 1 }returns2. Thereturn 1first writes1into the named result sloti, then the deferred closure runs and increments that slot, then the function actually returns. This is the mechanism behinddefer-based error decoration (defer func() { err = wrap(err) }()).
The _defer record
When the compiler cannot prove the fast path, it materializes a _defer struct. From src/runtime/runtime2.go, the struct is:
type _defer struct {
heap bool
rangefunc bool // true for rangefunc list
sp uintptr // sp at time of defer
pc uintptr // pc at time of defer
fn func() // can be nil for open-coded defers
link *_defer // next defer on G; can point to either heap or stack!
head *atomic.Pointer[_defer]
}Walking the fields: heap records whether this record was heap-allocated (so the runtime knows whether to free it). sp and pc capture the stack pointer and program counter at the moment the defer ran — the runtime uses sp to decide which defers belong to which stack frame when unwinding. fn is the closure to call (it can be nil for open-coded defers, whose function is found elsewhere). link chains records into a singly linked list rooted at the goroutine’s g._defer field; crucially the comment notes a link “can point to either heap or stack” — the chain is heterogeneous. rangefunc/head support the Go 1.23 range-over-function feature, where a loop body’s defers need their own atomic list.
Path 1 — heap-allocated defers (the original, the slow fallback)
In the original implementation (and still today whenever a defer appears inside a loop), each defer statement compiles to a call to the runtime function deferproc. deferproc allocates a _defer record (from a per-P pool, falling back to the heap), fills in fn, sp, pc, and pushes it onto g._defer. At every function exit the compiler inserts a call to deferreturn, which pops records belonging to the current frame off g._defer and calls each fn. The open-coded defers design doc quantifies the cost: in Go 1.12 a defer was about 50 ns, against roughly 6 ns for a direct call — an order of magnitude, enough that performance-sensitive code deliberately avoided defer.
Path 2 — stack-allocated defers (Go 1.13)
Go 1.13 (release notes) added stack allocation for defers that the compiler can prove execute at most once per function call — i.e. not inside a loop. Instead of deferproc allocating on the heap, the compiler reserves space for the _defer record in the calling function’s own stack frame and calls deferprocStack, which merely initializes that pre-allocated record and links it. The record still exists and the g._defer chain is still walked at return, but the heap allocation — the expensive part — is gone. The design doc reports this roughly 30% faster, bringing a defer down to about 35 ns. The _defer.heap boolean exists precisely to tell these two cases apart at cleanup time.
Path 3 — open-coded defers (Go 1.14, the modern fast path)
Go 1.14 (release notes) introduced open-coded defers, which eliminate the _defer record entirely for the common case. The design doc describes the mechanism: when a function has at most 8 defers, none of them in a loop, and at most 8 distinct function-exit points, the compiler does not call deferproc at all. Instead:
- It assigns each
deferstatement a bit in an 8-bit bitmask calleddeferBits, stored in a stack slot. - At each
deferstatement, it emits code that stores the function pointer and arguments into dedicated stack slots and sets the corresponding bit indeferBits. - At every function-exit point, it emits the deferred calls inline, each one guarded by a bit test. The doc’s illustrative codegen:
if deferBits & 1<<1 != 0 { deferBits &^= 1<<1; tmpF2(tmpB) }.
In the no-panic case this is just a few bit operations plus the calls themselves — “no more expensive than open-coding the call,” which was the design goal. The bit guard is necessary because a defer may be conditional (if cond { defer x() }); the bit records whether that defer was actually reached.
The panic path needs special handling, because with no _defer records the runtime cannot walk a chain. The compiler emits a FUNCDATA table — runtime/funcdata metadata — recording, for each open-coded defer, the stack slot of its function pointer, of its arguments, and of the deferBits byte, varint-encoded. When a panic unwinds through a frame with open-coded defers, gopanic reads this funcdata, loads deferBits to learn which defers actually ran, reconstructs each call, and runs them in reverse. Each such function also contains an unreachable deferreturn code segment so that a successful recover has somewhere to resume to. The _panic struct carries deferBitsPtr and slotsPtr fields exactly to point at this open-coded state during unwinding.
The deferBits bitmask is what makes open-coded defers handle conditional defers correctly. Consider func f(cond bool) { if cond { defer cleanup() }; ... }. The compiler cannot statically know whether cleanup should run — it depends on cond at runtime. So the defer statement, when reached, sets its bit; the function-exit code unconditionally tests if deferBits & mask != 0 and only then calls cleanup, also clearing the bit (deferBits &^= mask) so a second exit path does not double-run it. A function with three defers, two of them conditional, simply has three bits; at exit the codegen tests each. This is why the limit is 8 defers — deferBits is a single byte. A ninth defer, or a defer inside a loop (where one bit cannot represent “ran N times”), forces the whole function back to the _defer-record paths. The 8 exit-point limit exists because the deferred-call sequence is open-coded at every return; beyond eight returns the code duplication is judged not worth it and the function falls back. The net effect, per the design doc: for the overwhelmingly common function — a handful of unconditional or conditional defers, a few returns, no loop — defer compiles to bit-twiddling plus the calls, and the runtime functions deferproc/deferreturn are never entered at all on the normal path.
Code Examples
Argument evaluation timing
func snapshot() {
t := time.Now() // 1
defer log.Printf("elapsed: %v", t) // 2 -- BUG: t evaluated here
time.Sleep(2 * time.Second) // 3
}Line 2’s defer captures t now, at registration time, so the log line reports the time at line 1, not “2 s later.” The arguments are snapshotted by rule 1. The fix is to defer a closure: defer func() { log.Printf("elapsed: %v", time.Since(t)) }() — the closure captures t by reference and time.Since runs at call time.
LIFO order and resource cleanup
func process(name string) error {
f, err := os.Open(name) // 1
if err != nil {
return err
}
defer f.Close() // 2 -- runs last
gz, err := gzip.NewReader(f) // 3
if err != nil {
return err // 4 -- f.Close() still runs
}
defer gz.Close() // 5 -- runs before f.Close()
// ... read from gz ...
return nil // 6 -- gz.Close() then f.Close()
}Line 4 returns early — line 2’s f.Close() still runs because the defer was already registered, but line 5’s defer was never reached, so gz.Close() does not run. On the line-6 path both run, LIFO: gz.Close() (registered last) then f.Close(). LIFO matters here: the gzip reader must close before the underlying file.
One defer site, multiple deferred calls — the tracing pattern
Effective Go notes a consequence of rule 1 that is easy to miss: because arguments are evaluated at registration time, “a single deferred call site can defer multiple function executions.” Its tracing example:
func trace(s string) string { // 1 runs at defer-registration
fmt.Println("entering:", s)
return s
}
func un(s string) { // 2 runs at function return
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a")) // 3
fmt.Println("in a")
}Line 3 is one defer statement but produces two effects at two different times. trace("a") is the argument to the deferred un call, so by rule 1 it is evaluated immediately when the defer runs — printing entering: a and returning the string "a", which is snapshotted as un’s argument. Then un("a") is the deferred call, which runs at a’s return — printing leaving: a. Nesting a inside b (each with its own defer un(trace(...))) yields perfectly bracketed entering: b / entering: a / leaving: a / leaving: b output, because of LIFO. This idiom — argument-side “enter”, call-side “leave” — only works because of the precise timing in rule 1.
Mutating the named return value
func transfer(db *sql.DB) (err error) { // 1 -- named result `err`
tx, err := db.Begin() // 2
if err != nil {
return err
}
defer func() { // 3
if err != nil { // 4 -- reads the result slot
tx.Rollback()
} else {
err = tx.Commit() // 5 -- writes the result slot
}
}()
// ... do work, possibly setting err ...
return err // 6
}Line 1 names the result err. Line 6’s return err copies err into the result slot; then the line-3 closure runs, inspects the slot (line 4), and either rolls back or overwrites the slot with tx.Commit()’s result (line 5). The caller sees whatever the closure last wrote. This only works with a named result — an anonymous (error) result has no slot the closure can name.
Failure Modes and Common Misunderstandings
defer in a loop accumulates. for _, f := range files { fd, _ := os.Open(f); defer fd.Close() } does not close each file at the end of its iteration — defer is function-scoped, so all closes pile up until the function returns, leaking file descriptors meanwhile. Additionally, a defer inside a loop is exactly the construct that disqualifies the fast paths: the compiler must fall back to deferproc/heap allocation because the defer can run an unbounded number of times. Fix: wrap the body in a closure or call a helper function so each defer is bounded.
Forgetting that arguments are snapshotted. defer mu.Unlock() is fine (mu is a pointer-ish receiver evaluated to the same mutex), but defer fmt.Println(counter) captures counter’s current value — see defer Argument Evaluation Timing.
Assuming defer is free or always allocates. Both are wrong. Pre-1.13 it allocated; post-1.14 the common case allocates nothing. To know which path your function got, compile with -gcflags=-m and look for “stack-allocated defer” / heap messages, or inspect with the techniques in Compiler Optimization Diagnostics.
os.Exit skips defers. defer runs on function return and panic unwinding. os.Exit terminates the process immediately — no deferred call runs. Neither does a deferred call run if the goroutine is killed by a fatal runtime error (throw).
Alternatives and When to Choose Them
For deterministic cleanup, defer is idiomatic and should be the default — readability outweighs the few nanoseconds even in the rare slow path. The alternative is manual cleanup at every return site, which is error-prone (the whole reason defer exists) and only worth considering inside a tight loop where the per-iteration heap-allocated _defer genuinely shows up in a profile — and even then, the better fix is usually to lift the loop body into a function so the fast path re-engages. For cleanup tied to object lifetime rather than function scope, defer does not apply; see Finalizers and runtime.AddCleanup (best-effort, GC-driven) — but finalizers are a last resort, not a defer substitute. For panic-safe cleanup specifically, defer is mandatory: it is the only construct that runs during stack unwinding (see Panic Propagation and Stack Unwinding).
Production Notes
The Go 1.13/1.14 work was driven by real-world avoidance of defer in hot paths — the standard library itself contained hand-unrolled cleanup to dodge defer cost. After Go 1.14, the release notes explicitly call out that “most uses of deferred functions now incur almost zero overhead,” and standard-library code was simplified to use defer freely. As of the Go 1.26 baseline, the three-path model still holds: open-coded for the simple common case, stack-allocated for once-per-call defers exceeding the 8-defer/8-exit limits, heap-allocated for defers in loops. Practical guidance: use defer for all resource cleanup; if a profile (see pprof and Profiling) points at runtime.deferproc/runtime.newdefer, look for a defer inside a loop and hoist it.
See Also
- panic and recover — the unwinding mechanism
defercooperates with - Panic Propagation and Stack Unwinding — how
gopanicwalks defers up the stack - defer Argument Evaluation Timing — the rule-1 gotcha in depth
- The Error Interface · Error Wrapping and errors.Is errors.As —
defer-based error decoration - Function Values and Closures Internals — what a deferred closure captures
- Goroutine Stacks · Stack vs Heap Allocation — where
_deferrecords live - Go Internals MOC — parent map