Day 14 — Panic & recover

Updated

July 30, 2026

Day 14 — Panic & recover

Stage II · ~3h (theory-heavy)
Goal: Know when panic is justified, how stack unwinding and recover work, and how to contain panics at process or request boundaries—without using panic as a substitute for error.

Important

Idiomatic Go: return error for expected failure. Panic for programming mistakes or truly unrecoverable situations. Recover at boundaries (main, HTTP middleware, worker supervisor)—not scattered through business logic.

Why this day exists

Every Go developer eventually:

  • Hits a nil pointer panic in production
  • Wonders whether to recover
  • Sees libraries that panic on programmer misuse (MustCompile)

Without a policy, recover becomes exception handling by another name—and error chains (Day 13) stop working. Day 14 draws hard lines.


Theory 1 — What panic does

panic("something impossible happened")
panic(err)
panic(fmt.Errorf("invariant broken: %d", n))

Panic:

  1. Stops normal execution of the current function
  2. Runs deferred functions of that function (LIFO)
  3. Unwinds to the caller, repeating
  4. If never recovered, process crashes with stack trace

Panic values

Any value can be paniced; conventionally a string or error. Recover returns that value as any.


Theory 2 — When panic is acceptable

Acceptable Example
Impossible state / violated invariant panic("unreachable") after exhaustive switch you believe complete
Programmer API misuse in init-time helpers template.Must, regexp.MustCompile
Truly unrecoverable corruption Continuing would worsen data loss
Not acceptable Prefer
File not found error
Bad user input error
Network timeout error
“I don’t want to thread error returns” Thread them anyway

Must pattern

func MustCompile(expr string) *regexp.Regexp {
    re, err := regexp.Compile(expr)
    if err != nil {
        panic(err)
    }
    return re
}

var re = regexp.MustCompile(`^\d+$`) // OK: fixed string at init

Do not MustCompile user-supplied patterns in a request path without recovery—or better, return error.


Theory 3 — recover

func safe() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    mightPanic()
    return nil
}

Rules

  1. recover only works inside a deferred function.
  2. It stops the panic and returns the panic value.
  3. Outside a panicking flow, recover() returns nil.
  4. Recovering in an inner function that is not deferred does nothing useful for the outer panic.
func wrong() {
    if r := recover(); r != nil { // almost always nil here
        // ...
    }
    panic("x")
}

Deferred still runs during panic

func f() {
    defer fmt.Println("cleanup")
    panic("boom")
}
// prints cleanup, then process dies if unrecovered

This is why defer mu.Unlock() still unlocks on panic—usually desirable.


Theory 4 — Boundaries, not blankets

Good: contain at the edge

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Fprintf(os.Stderr, "fatal panic: %v\n", r)
            os.Exit(2)
        }
    }()
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Good: HTTP middleware (preview)

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // log stack: debug.Stack()
                http.Error(w, "internal error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

One request panics → others continue.

Bad: recover inside every domain function

Hides bugs, produces empty error semantics, duplicates policy.


Theory 5 — Converting panic to error

func Call(fn func() error) (err error) {
    defer func() {
        if r := recover(); r != nil {
            if e, ok := r.(error); ok {
                err = fmt.Errorf("panic: %w", e)
            } else {
                err = fmt.Errorf("panic: %v", r)
            }
        }
    }()
    return fn()
}

Use for untrusted plugins or transitional code—not as default control flow.

Re-panic when you should not handle

defer func() {
    if r := recover(); r != nil {
        if !isBenign(r) {
            panic(r) // continue unwinding
        }
        // handle benign
    }
}()

Theory 6 — Stack traces

import "runtime/debug"

defer func() {
    if r := recover(); r != nil {
        fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, debug.Stack())
    }
}()

Log stacks at boundaries; do not print stacks for ordinary error returns.


Theory 7 — Goroutines and panics (preview)

A panic in a goroutine does not recover in another goroutine’s defer. Unrecovered panic in any goroutine crashes the process.

go func() {
    defer func() {
        if r := recover(); r != nil {
            // only protects this goroutine
            log.Println(r)
        }
    }()
    work()
}()

Stage III will revisit supervision. Today: know panics are per-goroutine for recovery.


Worked examples bank

Example A — Invariant panic

func abs(n int) int {
    if n >= 0 {
        return n
    }
    if n < 0 {
        return -n
    }
    panic("unreachable")
}

Example B — SafeCall wrapper

package safe

import "fmt"

func Call(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    fn()
    return nil
}

func CallErr(fn func() error) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    return fn()
}

Example C — Must for static data only

var goVersionRE = regexp.MustCompile(`^go1\.\d+`)

func ParseUserRegexp(expr string) (*regexp.Regexp, error) {
    return regexp.Compile(expr) // user input: return error
}

Example D — Demonstrate defer order with panic

func demo() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered", r)
        }
    }()
    defer fmt.Println("3")
    panic("x")
}
// prints 3, recovered x, 2, 1 — recover in its defer stops panic; remaining defers continue

Run and verify understanding of order.

Example E — Do not use panic for EOF

// wrong
func readAll(r io.Reader) []byte {
    b, err := io.ReadAll(r)
    if err != nil {
        panic(err)
    }
    return b
}

// right
func readAll(r io.Reader) ([]byte, error) {
    return io.ReadAll(r)
}

Labs

Suggested workspace: ~/lab/90daysofx/go/day14

Lab 1 — SafeCall package

mkdir -p ~/lab/90daysofx/go/day14
cd ~/lab/90daysofx/go/day14
go mod init example.com/day14

Implement safe.Call / safe.CallErr as above. Demo:

  1. Function returns error → pass through
  2. Function panics string → becomes error
  3. Function panics error value → wrapped or formatted

Lab 2 — Boundary recover in main

Wire run() such that an intentional panic in a deep helper is recovered only in main (or only in safe.Call at the top). Business helpers do not recover.

Lab 3 — Policy document

Write PANIC_POLICY.md (short):

  • When your project panics
  • Where recover is allowed
  • Explicit ban list (validation, I/O, …)

Lab 4 — Must misuse demo

Show regexp.MustCompile with invalid pattern crashes process. Then fix with Compile + error for a CLI flag pattern.


Common gotchas

Gotcha Fix
recover not in defer Always defer
Recover everywhere Boundaries only
Panic for normal errors Return error
Swallowing panic without log Log + stack
Assuming recover catches other goroutines It does not
Must* on user input Return error
Forgetting defers run on panic Rely on them for unlock/close

Checkpoint

  • Panic vs error policy stated
  • recover only in defer demonstrated
  • SafeCall converts panic → error
  • No domain validation via panic
  • Know unrecovered panic kills process
  • Goroutine recovery isolation understood

Commit

git add .
git commit -m "day14: panic recover policy + safe.Call"

Tomorrow

Day 15 — Modules deep dive: go.mod / go.sum literacy, semantic import versioning, go mod tidy, replace, and explaining versions like a code reviewer.