Defer, Panic, and Stack Unwind

Updated

September 8, 2026

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, second

Arguments 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.