Two Taxonomies of Failure
A file-not-found and a nil map write both break your program, but they are not the same kind of event, and Go gives them different mechanisms on purpose.
The file-not-found is an expected failure: the world is allowed to be that way, some caller can do something about it, and the condition was foreseeable when the API was designed. It is an error value — returned, wrapped, and decided on by the caller (The error Interface and Error Messages).
The nil map write is a programmer error: the code has violated an invariant — writing to a map that was never made, indexing past a slice, dereferencing nil, sending on a closed channel, a failed type assertion. No caller can sensibly "handle" it; the only honest response is to stop at the exact line where the assumption broke. Go's runtime does that with a panic:
m := map[string]int{}
m["x"] = 1 // panic: assignment to entry in nil map
A panic terminates the normal flow: no more statements in the current function, the stack unwinds, deferred calls run on the way out, and unless something calls recover, the program prints the panic value plus a stack trace and exits with a non-zero status.
The decision rule, stated as a question: could a competent caller distinguish this failure and choose a response? If yes, return an error — the caller might retry, fall back, or report 404. If no — if reaching this line means the program's own logic is broken — panic. A third category exists for programs at startup: a CLI that reads PORT=abc from its own config file may panic with a precise message rather than thread a strconv error through eight setup functions, because there is no caller left to decide. The panic's stack trace points at the exact bad line, which is more diagnostic than any returned error would be.
What you must not do is borrow the categories: panicking on a missing file turns a routine 404 into a crashed process, and recovering an invariant violation deep in a library turns a loud bug into a silent wrong answer. The next two chapters cover the mechanics that make both mistakes possible.
The Unwind: What Runs Before the Crash
A panic does not jump blindly to the exit. It executes a precise sequence — and recover works only by plugging into that sequence.
func main() {
defer fmt.Println("main defer 2")
defer fmt.Println("main defer 1")
risky()
fmt.Println("after risky")
}
func risky() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("boom")
}
// recovered: boom
// main defer 1
// main defer 2
Walk the trace. panic("boom") starts unwinding risky — no statement after it runs. The deferred closure fires, recover() returns "boom", and the panic stops: risky returns normally, so main continues and prints after risky. Had the closure not called recover, unwinding would have continued into main, running its two defers newest-first, and the program would crash with the original panic.
Three rules define when recover works, and they explain most "why didn't my recover work?" debugging sessions:
- It must be called directly by a deferred function.
recover()in the function body does nothing — during normal execution it returns nil, and during a panic in the same frame it is too late to intercept anything. - It must be in the goroutine that is panicking. A deferred recover in a different goroutine cannot catch your panic; each goroutine unwinds its own stack (Goroutine Basics).
- It returns nil when nothing is panicking. The idiomatic
if r := recover(); r != nilguard works because of this — the deferred closure is harmless on the success path.
Also worth internalising: runtime failures are panics too. Index out of range, nil pointer dereference, integer division by zero, x.(T) assertion failure — a single recover at your process boundary catches them all, which is why one recovery layer can convert "the process died" into "this request got a 500 with a stack trace in the log." Recovery does not fix anything; it decides where the failure is reported instead of at the bottom of the stack.
Recover Belongs at the Boundary, Not in the Middle
The single legitimate place to recover is a boundary where you can make the failure whole for one unit of work: one HTTP request, one job, one loaded plugin — while keeping the process alive.
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
The handler goroutine survives, the client gets a 500, and the stack trace lands in the log for the on-call engineer. Note that net/http already does exactly this per connection — a handler panic kills that connection and gets logged, not the server — so writing your own layer is about controlling the response shape and the logging, not about preventing process death.
Now the mistakes, so you can see why "boundary" is doing work in that sentence:
Recovering mid-library. A getUser function that recovers a nil-pointer panic and returns (User{}, nil) hides a real bug behind a plausible zero value. The invariant is still violated; the caller is now acting on wrong data. If your function cannot make the failure whole, let it propagate to a caller that can.
Recovering and swallowing. defer recover() with no logging converts crashes into silent misbehavior — the hardest failure class to debug, because nothing fails visibly and nothing is recorded.
Recovering in a goroutine you did not create. A panic inside go worker() needs its own defer func(){ recover() }() inside that goroutine; a recover in the spawner's stack will not see it. This is also why an unrecovered panic in any goroutine takes down the entire process, not just the goroutine.
The shape to remember: errors travel up as values; panics travel up as control flow; both need an owner. The owner of an error is the layer that decides the response. The owner of a panic is the edge of the system — the handler, the job runner, the test harness — where "log everything, answer 500, keep serving" is a coherent decision.
What Your Package Is Allowed to Panic About
Exported Go code carries an implicit contract with its callers, and panic discipline is part of it. A function's documentation should be able to answer one question: which conditions come back as errors, and which conditions mean the caller is broken?
| Situation | Mechanism | Why |
|---|---|---|
| input the world can legitimately produce (missing file, bad user row) | return error |
caller can retry, fall back, or report it |
violated precondition of your own code (nums[0] on an empty slice you were told was non-empty) |
let it panic | no sensible recovery exists at that line |
| fatal setup at startup (unparseable config constant) | explicit panic in main/init |
no caller remains; stack trace pinpoints the line |
| untrusted execution boundary (HTTP handler, script VM, plugin) | defer + recover → log → error response |
keeps the process alive, isolates one unit of work |
| library function receiving an out-of-contract argument | return an error, or document the panic | never surprise a caller who only checks errors |
Two helper shapes cover most legitimate panics. In main-adjacent code, a must helper turns setup failures into immediate, well-located crashes:
func mustParseInt(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(fmt.Errorf("invalid integer %q in config: %w", s, err))
}
return n
}
The program cannot run without this value, so failing loudly at startup beats threading the error through five setup functions where it would be ignored anyway. In library code, the mirrored helper is the recover-and-wrap that leaves the panic type intact for the boundary to log:
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("job %d panicked: %v", id, r)
}
}()
(That form must run in the same goroutine as the panic — fine for a synchronous job runner, impossible across go statements.) The sharpest interview answer on this topic is the asymmetry: libraries return errors and almost never recover; binaries recover at their boundaries and rarely need to panic. Panics handle "my code is broken"; errors handle "the request failed." Keeping them in their lanes is what makes a Go codebase's failure behaviour predictable to read before you run it.
Quiz
1. What does this print? ```go func f() { defer func() { fmt.Println("r:", recover()) }() panic("x") } func main() { f() fmt.Println("done") } ```
2. A developer writes `func save() { ...; r := recover(); if r != nil { log(r) } }` — the recover is called directly in the function body, not in a defer. What happens when save panics?
3. A request handler dereferences a nil pointer deep in the service layer. With no recover anywhere, what is the outcome?
4. A function validates user input from an API request and finds the email malformed. What is the correct mechanism?
Flashcards
Question
What are the two requirements for recover() to stop a panic?
Click to reveal answer
Answer
It must be called directly by a deferred function, and that function must run in the goroutine that is panicking. Outside those conditions recover returns nil (if not panicking) and has no effect on the panic.
Question
In what order do things run when a panic is raised inside nested functions?
Click to reveal answer
Answer
Statements after the panic never run; deferred calls fire newest-first while the stack unwinds; if one calls recover the panic stops and that frame returns normally, otherwise unwinding continues to the top and the process exits with a stack trace.
Question
When should a Go function panic instead of returning an error?
Click to reveal answer
Answer
When the condition means the code itself is broken — a violated invariant like indexing past a slice you knew was non-empty — and no caller could meaningfully choose a response. Expected, caller-decidable failures always come back as error values.
Question
Why does recovery belong at request/job boundaries rather than inside library functions?
Click to reveal answer
Answer
Only the boundary can make the failure whole: log the stack, answer 500, keep the process serving. A library that recovers mid-call hides the invariant violation and hands back partial state — the bug survives, now silent.
Question
Does an unrecovered panic in one goroutine take down only that goroutine?
Click to reveal answer
Answer
No — any unrecovered panic terminates the entire process. Recover must live inside the goroutine that panics, which is why worker goroutines need their own deferred recover if you want them fault-isolated.
Revision Notes
Key Takeaways
- 1. Errors are for expected, caller-decidable failures; panics are for violated invariants where no response exists
- 2. A panic runs deferred calls LIFO while unwinding; unrecovered panics kill the whole process, not just the goroutine
- 3. recover works only as a direct call from a deferred function in the panicking goroutine, and returns nil when idle
- 4. Recover at boundaries where the failure can be made whole — one request, one job — and log with the stack trace
- 5. net/http already recovers per connection; your middleware customises the response and the logging
Interview Tips
- • "Error or panic for X?" — answer with the caller-can-decide test, then give one example of each and what happens if you swap them
- • "Walk me through what recover actually does" — deferred calls during unwind, direct-call rule, same-goroutine rule, nil when idle; mention runtime panics are caught the same way
- • "Where would you put recover in a web server?" — per-request middleware, log with debug.Stack, return 500; note net/http's built-in behavior so you sound like you've read the source
- • "Have you ever written panic() in application code?" — the must-helper for unparseable startup config: no caller remains, and the stack trace points at the exact bad line
Cheat Sheet
panic, recover, and When to Fail
Choose the mechanism:
| Question | If yes | If no |
|---|---|---|
| Can the world legitimately produce this? | return error | it is an invariant violation -> panic |
| Can the caller decide a response (retry/404/fallback)? | return error | let it crash loudly |
| Is there no caller left (startup config in main)? | explicit panic with context | propagate normally |
| Is this an execution boundary (handler/job/plugin)? | defer + recover -> log -> error response | do not recover below the boundary |
Recover rules: direct call | inside a deferred function | in the panicking goroutine; returns nil when not panicking (if r := recover(); r != nil).
Runtime panics a boundary recover catches: nil deref, index out of range, divide by zero, type assertion failure, send on closed channel, nil map write.
Code template:
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s: %v\n%s", r.URL.Path, rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Continue Learning
Before You Start
- No prerequisites listed.