Defer, Panic, and Stack Unwind
Defer, Panic, and Stack Unwind
Overview
defer schedules a call to run when the surrounding function returns — normally or via panic. Modern Go open-codes many defers (cheap), but loops that defer still accumulate work until return. Panic unwinds the stack running defers until recover or process crash.
Errors: Panic and recover.
Diagram: Panic unwind
panic(value)
│
v
run defers LIFO (this frame)
│
v
caller frames … until recover
│
├── recover in defer ──► stop, resume
└── none ──► crash + stacks
Defer Semantics
func f() {
defer fmt.Println("second")
defer fmt.Println("first") // LIFO
fmt.Println("body")
}
// body, first, secondArguments evaluated immediately
func g() {
x := 1
defer fmt.Println(x) // prints 1 even if x changes later
x = 2
}To see final values, defer a closure:
defer func() { fmt.Println(x) }()Named results
Deferred closures can assign named return values — used carefully in middleware-style wrappers:
func wrap() (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("wrap: %w", err)
}
}()
return do()
}Cost Model
| Pattern | Cost |
|---|---|
| Few defers per function (open-coded) | Very low |
defer inside hot loop |
Registers many defers; prefer explicit unlock or scoped helper |
defer mu.Unlock() after Lock |
Idiomatic and fine outside mega-hot paths |
// Prefer for tight loops:
mu.Lock()
// critical
mu.Unlock()
// Prefer for error-prone multi-return:
mu.Lock()
defer mu.Unlock()Panic Unwind
panic(value)
-> run defers in current function (LIFO)
-> pop frame
-> run defers in caller
-> ...
-> recover in a deferred call? stop unwind, return
-> else crash process (dump stacks)
recover only works directly inside a deferred function during that panic:
defer func() {
if r := recover(); r != nil {
// log, convert to error
}
}()What recover cannot do
- Fix corrupted program state magically
- Replace typed API errors as the default control flow
- Catch panics in other goroutines — each G panics independently
go func() {
defer func() { recover() }() // only protects this goroutine
mayPanic()
}()Stack Growth
Goroutine stacks start small and grow/shrink (copy stack). Pointers into stack frames are invisible to Go code safely — the compiler/runtime rewrites them. Do not pass pointers from Go stack to C without pinning rules (cgo).
Experiment
go mod init example
go run .package main
import "fmt"
func deferOrder() {
defer fmt.Println("D1")
defer fmt.Println("D2")
fmt.Println("body")
}
func deferArgs() {
x := 1
defer fmt.Println("arg", x)
defer func() { fmt.Println("closure", x) }()
x = 9
}
func recoverDemo() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
panic("boom")
}
func main() {
deferOrder()
deferArgs()
fmt.Println("recovered", recoverDemo())
}Expected output:
body
D2
D1
closure 9
arg 1
recovered panic: boom
What to notice: LIFO order; args vs closure capture; panic converted to error only with defer+recover.
Try next: Benchmark loop with defer unlock vs paired unlock for a million iterations — see if it matters in your Go version.