Skip to content

Panic and Recover

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.

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"]
defer still runs LIFO during a panic; recover() in the right defer stops the unwind

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.

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 +0x2c
created by main.main in goroutine 1
.../main.go:9 +0x24
exit status 2

Every goroutine that can panic needs its own defer/recover at its top level; recovery does not cross goroutine boundaries in either direction.

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.

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.

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 default case 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.

SituationUse
Invalid user inputreturn error
File not foundreturn error
Nil pointer impossible by designpanic
Uninitialized required dependencypanic in init
Index out of rangepanic (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)
}

When a generated snippet uses panic/recover, check for these before trusting it:

  • Is recover() called from a helper function instead of literally inside the defer func() { ... }() body? One level of indirection is enough to make it always return nil.
  • Does a goroutine started with go func() { ... }() have its own defer/recover? A recover() in main or the parent goroutine does not protect it.
  • Does the function that recovers use a named return value? Assigning to an unnamed err inside the deferred closure has no effect on what the function actually returns.
  • Does os.Exit or log.Fatal appear inside a function that other code relies on for cleanup via defer? Neither runs pending defers.
  • Is a MustX function 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.

What you getBenefitCost
panicaborts immediately when an invariant is violatedcrashes the whole program if it is not recovered
recover in deferstops panic propagation, lets you convert it to an errormust be called directly inside a deferred function
panic for programmer errorsmakes clear it’s a bug, not an expected errorhard to test, hard to trace across goroutines
recover in a server handlerkeeps one bad request from crashing the whole servercan hide bugs that should be fixed, if overused
  • 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.

What does recover() return if called outside a deferred function?
What happens when riskyOp() runs?
func tryRecover() any {
    return recover()
}

func riskyOp() {
    defer func() {
        r := tryRecover()
        fmt.Println("recovered:", r)
    }()
    panic("boom")
}
What happens?
func main() {
    go func() {
        panic("boom in goroutine")
    }()
    time.Sleep(100 * time.Millisecond)
    fmt.Println("done")
}
Which of these is an appropriate use of panic?
deferred functions run in what order relative to other defers?
A function calls os.Exit(1) partway through, after registering a defer earlier in the same function. What happens to that defer?