Panic and Recover
What is panic?
Section titled “What is panic?”A panic stops normal execution of the current goroutine. The goroutine immediately stops running its current function, begins unwinding the call stack, and runs any deferred functions it encounters on the way out. If nothing intercepts the panic, the program crashes and prints a stack trace.
Panics happen in two ways:
- Runtime errors — the Go runtime triggers them automatically for nil pointer dereferences, out-of-bounds slice/array accesses, a failed single-value type assertion
x.(T)on an interface value, and similar impossible operations. - Explicit calls — you call
panic(value)with any value you like (commonly a string or an error).
panic("something that should never happen")panic(fmt.Errorf("invariant violated: %v", state))Both forms unwind the stack the same way. The only difference is what value recover() returns.
defer + recover
Section titled “defer + recover”Recall from Functions: deferred calls run in LIFO order — the last defer registered runs first. During a panic this still holds: every deferred function on the stack runs, in reverse registration order, before the goroutine gives up (unless one of them calls recover()).
recover() is a built-in function that intercepts a panic. It only works when called inside a deferred function — calling recover() outside a defer always returns nil.
The canonical pattern:
defer func() { if r := recover(); r != nil { // r is the value passed to panic() fmt.Println("recovered:", r) }}()When the deferred function calls recover() and the goroutine is panicking, recover() stops the panic, returns the panic value, and allows the deferred function to finish normally. After the deferred function returns, the function that panicked returns — not to where panic was called, but to its own caller with zero values for any named return variables (unless you set them in the deferred function).
A common use is converting a panic into a returned error at a package boundary:
func safeCall(fn func()) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("recovered: %v", r) } }() fn() return nil}safeCall only works because err is a named return value. recover() runs inside the deferred function, which is a separate closure from safeCall itself — the only way for it to affect what safeCall returns is to assign to a variable that is already part of the return signature. A deferred function cannot use an ordinary return err statement of its own; assigning to the named result is the only channel back to the caller.
flowchart TD P["panic() called"] --> D3["deferred #3 runs (LIFO)"] D3 --> D2["deferred #2 runs"] D2 --> D1["deferred #1 runs -- calls recover() directly"] D1 --> R["recover() intercepts -- unwinding stops"] R --> Return["function returns normally to its caller"]
recover must be called directly by the deferred function
Section titled “recover must be called directly by the deferred function”recover() only intercepts a panic when it is called directly inside a function that was itself deferred. Calling it one level removed — from a helper function that the deferred function calls — does nothing: recover() returns nil, and the panic keeps unwinding as if recover() had never been called.
// tryRecover calls recover(), but not directly from a deferred function --// it is one level too deep, so recover() returns nil here.func tryRecover() any { return recover()}
func riskyOp() { defer func() { r := tryRecover() // BUG: recover() is not called directly by this deferred func fmt.Println("recovered:", r) }() panic("boom")}Verified on 1.26.5, calling riskyOp() prints recovered: <nil> and then the program still crashes with panic: boom — the fmt.Println runs (proving the deferred function itself did execute), but tryRecover’s call to recover() was too far from the panic to catch it, so the panic propagates anyway. Only recover() written literally inside the defer func() { ... }() body works.
A panic in a goroutine kills the process
Section titled “A panic in a goroutine kills the process”An unrecovered panic in any goroutine — not just main’s — crashes the entire program, and no recover() anywhere else can stop it. A defer/recover pair in main provides no protection for panics in goroutines it starts:
func main() { defer func() { if r := recover(); r != nil { fmt.Println("main recovered:", r) // never reached } }() go func() { panic("boom in goroutine") }() time.Sleep(100 * time.Millisecond) fmt.Println("never printed")}Verified on 1.26.5, this crashes the whole process — main’s recover() never fires, and neither "main recovered" nor "never printed" appears:
panic: boom in goroutine
goroutine 19 [running]:main.main.func1() .../main.go:10 +0x2ccreated by main.main in goroutine 1 .../main.go:9 +0x24exit status 2Every goroutine that can panic needs its own defer/recover at its top level; recovery does not cross goroutine boundaries in either direction.
log.Fatal and os.Exit skip defers
Section titled “log.Fatal and os.Exit skip defers”os.Exit (and log.Fatal, which calls it) terminates the process immediately — it does not unwind the stack, and it does not run any pending deferred functions, even ones registered earlier in the same function:
func work() { defer fmt.Println("deferred cleanup ran") // BUG: never runs fmt.Println("doing work") os.Exit(1)}Verified on 1.26.5, this prints only doing work — "deferred cleanup ran" never appears, because os.Exit bypasses defer entirely rather than triggering normal unwinding. Reserve os.Exit/log.Fatal for main (or very near it), after any cleanup that matters has already happened through ordinary control flow — never inside a function that other code expects to clean up after itself via defer.
The Must* convention
Section titled “The Must* convention”A function named MustX (or mustX if unexported) is a deliberate exception to “return an error, don’t panic”: it does the work of X, but panics instead of returning an error when it fails. The convention signals the trade-off in the name itself, so callers can see at the call site that a failure here is unrecoverable.
regexp.MustCompile is the canonical example: pattern from a hardcoded string at package init is either always valid or the program has a bug, so panicking during startup is the useful failure mode. mustPositive in the playground below follows the same pattern for input the caller controls entirely. Reach for Must* only when a failure genuinely means “the caller made a programming mistake, not a runtime condition” — never for a MustX that wraps I/O, network calls, or anything driven by external input.
When to use panic
Section titled “When to use panic”Panic is not a general-purpose error-handling tool. The rule: use panic for programmer errors and impossible states — conditions that can only occur if the code itself is wrong, not because of bad input or environmental conditions.
Appropriate uses:
- Violated invariants — a state that should be structurally impossible has been reached, indicating a bug in the program.
- Package initialization failures —
init()cannot proceed because a required resource is missing or misconfigured. Panicking here is acceptable because the program cannot run correctly regardless. - Unreachable branches — marking a
defaultcase or exhaustive switch branch that should never execute (as documentation and a safety net).
Not appropriate:
- File not found, network timeout, invalid user input, missing database rows — these are expected runtime conditions that callers must handle. Return
(T, error)for all of these.
Library code has an extra obligation: a library should almost never panic on bad caller input. regexp.MustCompile is a good example of the boundary: it is appropriate for a fixed pattern known at package init — a bad pattern there is a programmer error, and panicking during startup is cheap and immediate. If the pattern instead comes from a caller or user input, use regexp.Compile and return the error normally; never wrap a caller-supplied pattern in MustCompile.
Panic vs error: a quick guide
Section titled “Panic vs error: a quick guide”| Situation | Use |
|---|---|
| Invalid user input | return error |
| File not found | return error |
| Nil pointer impossible by design | panic |
| Uninitialized required dependency | panic in init |
| Index out of range | panic (runtime) |
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("recovered panic: %v", r) } }() if b == 0 { panic("division by zero") } return a / b, nil}
func mustPositive(n int) int { if n <= 0 { panic(fmt.Sprintf("expected positive, got %d", n)) } return n}
func main() { result, err := safeDivide(10, 2) if err != nil { fmt.Println("error:", err) } else { fmt.Println("10 / 2 =", result) }
result, err = safeDivide(10, 0) if err != nil { fmt.Println("error:", err) } else { fmt.Println("10 / 0 =", result) }
fmt.Println(mustPositive(5))} Re-panicking, runtime.Error, and panic(nil)
Section titled “Re-panicking, runtime.Error, and panic(nil)”A deferred function can inspect what it recovered and decide the panic isn’t its business — logging it, then calling panic(r) again with the same value to let it continue unwinding past this point:
defer func() { if r := recover(); r != nil { if _, ok := r.(runtime.Error); ok { fmt.Println("runtime error, re-panicking:", r) panic(r) // re-panic: preserve the original value and type } }}()runtime.Error is the interface every panic the runtime itself generates satisfies — index out of range, nil dereference, failed type assertion, and similar. Checking for it lets code distinguish “the program hit an impossible state” from “someone deliberately called panic(someValue)” before deciding whether to re-panic, log more aggressively, or handle it differently.
panic(nil) gets special treatment: since Go 1.21, recovering it does not yield a bare nil (which would be indistinguishable from “there was no panic at all”). It yields a *runtime.PanicNilError:
defer func() { r := recover() fmt.Printf("%v\n%T\n", r, r)}()panic(nil)Verified on 1.26.5, this prints panic called with nil argument and *runtime.PanicNilError. Before Go 1.21, recover() after panic(nil) returned plain nil, making it impossible to tell “this deferred function ran because of a panic” from “this deferred function ran normally” — the *runtime.PanicNilError type closes that hole.
package main
import ( "fmt" "os" "runtime")
// mightRepanic recovers a runtime.Error just long enough to log it, then// re-panics with the same value so the caller still sees the failure.func mightRepanic() { defer func() { if r := recover(); r != nil { if _, ok := r.(runtime.Error); ok { fmt.Println("runtime error, re-panicking:", r) panic(r) // re-panic: preserve the original value and type } fmt.Println("recovered non-runtime panic:", r) } }() var s []int _ = s[5] // triggers a runtime.Error: index out of range}
func demoRepanic() { defer func() { r := recover() fmt.Println("demoRepanic recovered:", r) if _, ok := r.(runtime.Error); ok { fmt.Println("still a runtime.Error after re-panic") } }() mightRepanic()}
func demoPanicNil() { defer func() { r := recover() fmt.Printf("panic(nil) recovers as: %v (%T)\n", r, r) }() panic(nil)}
func main() { demoRepanic() demoPanicNil()
defer fmt.Println("this defer never runs -- os.Exit skips it") fmt.Println("about to exit") os.Exit(0)} Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses panic/recover, check for these before trusting it:
- Is
recover()called from a helper function instead of literally inside thedefer func() { ... }()body? One level of indirection is enough to make it always returnnil. - Does a goroutine started with
go func() { ... }()have its owndefer/recover? Arecover()inmainor the parent goroutine does not protect it. - Does the function that recovers use a named return value? Assigning to an unnamed
errinside the deferred closure has no effect on what the function actually returns. - Does
os.Exitorlog.Fatalappear inside a function that other code relies on for cleanup viadefer? Neither runs pending defers. - Is a
MustXfunction called with input that comes from a user, a file, or the network, rather than a fixed value known at compile/init time?
🐛 Spot the bug
func tryRecover() any { return recover()}
func riskyOp() { defer func() { r := tryRecover() fmt.Println("recovered:", r) }() panic("boom")}
func main() { riskyOp() fmt.Println("reached after riskyOp")}Show the answer
recover() only works when the call is written directly inside a deferred function — here it is one level removed, inside tryRecover, which riskyOp’s deferred function merely calls. Verified on 1.26.5: this prints recovered: <nil>, then the program still crashes with panic: boom — "reached after riskyOp" never prints. The fix is to inline the call: r := recover() directly inside the defer func() { ... }() body.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| panic | aborts immediately when an invariant is violated | crashes the whole program if it is not recovered |
| recover in defer | stops panic propagation, lets you convert it to an error | must be called directly inside a deferred function |
| panic for programmer errors | makes clear it’s a bug, not an expected error | hard to test, hard to trace across goroutines |
| recover in a server handler | keeps one bad request from crashing the whole server | can hide bugs that should be fixed, if overused |
Common Misconceptions
Section titled “Common Misconceptions”- panic is an exception that should be used for error handling — panic is only for programmer errors (nil pointer, out of bounds, invariant violation).
- recover can catch a panic anywhere — recover only works inside a deferred function called directly by the panicking goroutine.
- panic propagates across goroutines — panic never crosses a goroutine boundary — an unrecovered panic in a goroutine crashes the whole program.
- defer doesn’t run during a panic — defer always runs, including during panic stack unwinding — that is exactly why recover has to live inside a defer.
💡 Real-world example
net/http: the server recovers a panic that happens in a handler immediately, logs the stack trace, and closes the connection (it does not write a 500 response — the connection is simply cut); to close it silently without logging, panic with the value
http.ErrAbortHandler.encoding/json: internal panics are recovered and converted into a returned error before they reach the public API.