Day 14 — Panic & recover
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.
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:
- Stops normal execution of the current function
- Runs deferred functions of that function (LIFO)
- Unwinds to the caller, repeating
- 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 initDo 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
recoveronly works inside a deferred function.
- It stops the panic and returns the panic value.
- Outside a panicking flow,
recover()returnsnil.
- 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 unrecoveredThis 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 continueRun 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/day14Implement safe.Call / safe.CallErr as above. Demo:
- Function returns error → pass through
- Function panics string → becomes error
- 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
recoveronly 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.