Recover From Panics in Go the Right Way With Examples
How panic and recover work in Go: unwinding order, why recover must be called directly in a defer, panic(nil), goroutines, HTTP middleware and tests.

Panics have a bad reputation in Go because people reach for them as exceptions, and the language explicitly does not want that. Used for the narrow purpose they exist for, though, panic and recover are a small, sharp tool, and anyone who writes servers eventually needs the recover half. A single nil dereference in one request handler should not take down a process that is serving ten thousand other requests. recover is how you prevent that, and writing it correctly takes knowing five rules that are not obvious from the name.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide covers the mechanics, then the places where recover belongs, then the places where it does not. It belongs to the error handling guide and follows panic or error, which explains when to panic in the first place. Everything ran on Go 1.25.6.
What happens when a function panics
A call to panic(v) stops the current function and begins unwinding. The runtime goes back up the call stack, and in each frame it runs the deferred functions in last-in first-out order, just as it would on a normal return. If it reaches the top of the goroutine without a recovery, the program prints the panic value and a stack trace and exits with status 2.
package main
import "fmt"
func level3() {
defer fmt.Println("level3 deferred")
panic("something broke")
}
func level2() {
defer fmt.Println("level2 deferred")
level3()
fmt.Println("level2 after call: never printed")
}
func level1() {
defer func() {
r := recover()
fmt.Println("level1 recovered:", r)
}()
level2()
fmt.Println("level1 after call: never printed")
}
func main() {
level1()
fmt.Println("main continues")
}level3 deferred
level2 deferred
level1 recovered: something broke
main continuesThe deferred calls run from the innermost frame outward: level3, then level2, then the recovering function in level1. The statements after the panicking call never run in any frame, and when level1's deferred function recovers, level1 returns normally to main, which carries on. The recovery happens in the frame that deferred the recover, not in the frame that panicked: control cannot go back to where the panic happened, only forward from the function that caught it. That is why the usual shape is a function that runs some work and returns an error after catching a panic from inside it.
Rule 1: recover must be called directly by the deferred function
recover only does its job when it is called directly from a function that defer runs. If the deferred function calls a helper, and the helper calls recover, it returns nil and the panic continues:
package main
import "fmt"
func helper() any {
return recover()
}
func attempt() {
defer func() {
// This recover works: the deferred function calls it directly.
fmt.Println("outer recovered:", recover())
}()
defer func() {
// This one does not: helper calls recover, not the deferred function.
fmt.Println("helper got:", helper())
}()
panic("boom")
}
func main() {
attempt()
}helper got: <nil>
outer recovered: boomThe deferred function that calls helper() prints helper got: <nil>, and the panic is still active. The earlier-deferred function runs next, calls recover() directly, and catches it. This rule trips up people who write a reusable handlePanic() function and then write defer func() { handlePanic() }(). The fix is to defer the helper itself: defer handlePanic(), because then the helper is the deferred function and its recover call is direct. The language specification states the rule in terms of "the return value of recover is nil if ... recover was not called directly by a deferred function."
Rule 2: it returns nil unless a panic is in progress
Calling recover() in normal code returns nil and does nothing. That makes the standard idiom safe to put at the top of any function. A deferred if r := recover(); r != nil { ... } runs on every return, and does something only when the return was caused by a panic.
Since Go 1.21 a nil panic value cannot be mistaken for "no panic". Before then, panic(nil) made recover() return nil, indistinguishable from not panicking. Now:
package main
import "fmt"
func main() {
defer func() {
r := recover()
fmt.Printf("%T\n", r)
fmt.Println(r)
}()
panic(nil)
}*runtime.PanicNilError
panic called with nil argumentThe recovered value is a *runtime.PanicNilError, so the r != nil test is reliable. The old behavior is available with GODEBUG=panicnil=1, which only matters for programs that depended on it.
Rule 3: recover returns the panic value, and its type tells you what happened
The value is whatever was passed to panic: a string, an error, a custom type. Panics from the runtime itself (nil dereference, index out of range, division by zero, failed type assertion) carry a value that implements runtime.Error. That lets a recover boundary tell the two apart:
package main
import (
"errors"
"fmt"
"runtime"
)
func protect(f func()) (err error) {
defer func() {
switch r := recover().(type) {
case nil:
case runtime.Error:
err = fmt.Errorf("runtime error (bug): %w", r)
case error:
err = fmt.Errorf("panic with error: %w", r)
default:
err = fmt.Errorf("panic: %v", r)
}
}()
f()
return nil
}
func main() {
var m map[string]int
fmt.Println(protect(func() { m["x"] = 1 }))
fmt.Println(protect(func() { panic(errors.New("custom")) }))
fmt.Println(protect(func() { panic(42) }))
fmt.Println(protect(func() {}))
}runtime error (bug): assignment to entry in nil map
panic with error: custom
panic: 42
<nil>The type switch separates runtime errors, which are bugs, from errors someone raised deliberately and from everything else. All three become ordinary errors with %w where they wrap one, so errors.As and errors.Is keep working on whatever was panicked. A plain case nil: handles the no-panic return. In a service, the runtime case would log at error level with a stack trace, while a deliberate panic might be logged differently.
Capture the stack inside the deferred function
The stack that matters is the one at the point of the panic, and by the time you handle the error normally it is gone. Inside the deferred function it is still there, because the frames have not been fully unwound:
package main
import (
"fmt"
"runtime/debug"
"strings"
)
func inner() { var p *struct{ x int }; _ = p.x }
func outer() { inner() }
func main() {
defer func() {
r := recover()
stack := string(debug.Stack())
fmt.Println("recovered:", r)
for _, line := range strings.Split(stack, "\n") {
if strings.HasPrefix(line, "main.") {
fmt.Println(strings.SplitN(line, "(", 2)[0])
}
}
}()
outer()
}recovered: runtime error: invalid memory address or nil pointer dereference
main.main.func1
main.inner
main.outer
main.maindebug.Stack() includes the frames that panicked: main.inner, main.outer and main.main, in that order, along with runtime frames the code above filters out. Log the full text at your boundary and you can find the line without a debugger. This is the one place where capturing a trace is nearly free of objections (stack traces for errors).
Rule 4: a recover only covers its own goroutine
A panic unwinds one goroutine's stack. Another goroutine cannot catch it, and neither can the code that started it:
package main
import (
"fmt"
"time"
)
func main() {
defer func() { fmt.Println("recovered in main:", recover()) }()
go func() {
panic("worker failed")
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("not reached")
}panic: worker failed
goroutine 7 [running]:
main.main.func2()
example.test/lab/main.go:11 +0x2c
created by main.main in goroutine 1
example.test/lab/main.go:10 +0x40
exit status 2The recover in main never sees it. The goroutine's panic reaches the top of its own stack with nothing to stop it, and the whole process exits with status 2 and a trace. This is why every goroutine that can panic needs a deferred recover of its own, which a small wrapper provides:
package main
import (
"fmt"
"sync"
)
func safeGo(wg *sync.WaitGroup, errs chan<- error, f func()) {
wg.Go(func() {
defer func() {
if r := recover(); r != nil {
errs <- fmt.Errorf("worker panic: %v", r)
}
}()
f()
})
}
func main() {
var wg sync.WaitGroup
errs := make(chan error, 3)
safeGo(&wg, errs, func() { fmt.Println("ok") })
safeGo(&wg, errs, func() { var m map[int]int; m[1] = 1 })
wg.Wait()
close(errs)
for err := range errs {
fmt.Println(err)
}
}ok
worker panic: assignment to entry in nil mapThe panicking worker reports through the channel like any other failure, the healthy one finishes, and the process survives. This is the structure of a worker pool that is able to survive a bad job (goroutine panics and returning errors from goroutines cover it). Note the channel has room for every worker, so no sender blocks.
Rule 5: some failures are not panics
recover handles panics. The Go runtime also has fatal errors that kill the program without unwinding, and no deferred function runs for them. The common ones are a deadlock where every goroutine is blocked, a concurrent map read and write, running out of memory, and a stack overflow from unbounded recursion:
package main
import "fmt"
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
ch := make(chan int)
<-ch
}fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
...The deferred function never runs. The runtime prints fatal error: all goroutines are asleep - deadlock! and exits, because nothing could ever wake the program. The right response to these is to prevent them, with the race detector for maps (the race detector) and proper lifecycle management for channels (the deadlock error).
Cleanup still runs, so locks are released
The reason defer and panics share a mechanism is that unwinding runs every deferred call, which makes the cleanup idioms safe against panics. A mutex released with defer mu.Unlock() is unlocked even when the code between lock and return blows up:
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
count int
)
func increment(fail bool) {
mu.Lock()
defer mu.Unlock()
count++
if fail {
panic("bad state")
}
}
func main() {
func() {
defer func() { fmt.Println("recovered:", recover()) }()
increment(true)
}()
increment(false)
fmt.Println("count:", count)
}recovered: bad state
count: 2The second call locks the mutex without blocking, because the panicking call released it on the way out. Had Unlock been written as a plain statement at the end of the function, the panic would have skipped it, and the second Lock would have deadlocked the program. One caveat belongs next to this: the data guarded by the lock may be half-updated when the panic hit. Here count was incremented before the panic. The lock is free and the invariant may be broken, which is one reason to keep code between a lock and its unlock small and boring, and another reason that recovering is a decision and not a reflex.
A panic inside a deferred function
A deferred function can itself panic. When it does, the new panic replaces the one in flight, and a later recover receives the newest value:
package main
import "fmt"
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
defer func() { panic("second") }()
panic("first")
}recovered: secondThis prints recovered: second. The first panic is not lost to the runtime: when nothing recovers, it prints both, as panic: first followed by an indented panic: second before the goroutine trace. But a recover only sees the last one, so cleanup code that might fail should not panic in a deferred function. Return its error instead, or capture it as in errors from deferred Close calls.
What panic and recover cost
Programmers sometimes avoid recover for fear of its cost, and sometimes lean on panics as a fast exit. Both worry about the wrong thing. A benchmark of three versions of a function that fails every time shows the scale:
package main
import (
"errors"
"testing"
)
var errFail = errors.New("fail")
func viaError() error { return errFail }
func viaPanic() (err error) {
defer func() {
if r := recover(); r != nil {
err = errFail
}
}()
panic(errFail)
}
func noPanicWithDefer() (err error) {
defer func() {
if r := recover(); r != nil {
err = errFail
}
}()
return nil
}
var sink error
func BenchmarkReturnError(b *testing.B) {
for b.Loop() {
sink = viaError()
}
}
func BenchmarkDeferNoPanic(b *testing.B) {
for b.Loop() {
sink = noPanicWithDefer()
}
}
func BenchmarkPanicRecover(b *testing.B) {
for b.Loop() {
sink = viaPanic()
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkReturnError-10 547098326 2.441 ns/op 0 B/op 0 allocs/op
BenchmarkReturnError-10 573384793 2.079 ns/op 0 B/op 0 allocs/op
BenchmarkReturnError-10 571908906 2.074 ns/op 0 B/op 0 allocs/op
BenchmarkDeferNoPanic-10 337835062 3.542 ns/op 0 B/op 0 allocs/op
BenchmarkDeferNoPanic-10 339910964 3.555 ns/op 0 B/op 0 allocs/op
BenchmarkDeferNoPanic-10 335461104 3.573 ns/op 0 B/op 0 allocs/op
BenchmarkPanicRecover-10 17670087 68.27 ns/op 0 B/op 0 allocs/op
BenchmarkPanicRecover-10 17922238 67.38 ns/op 0 B/op 0 allocs/op
BenchmarkPanicRecover-10 17373615 68.85 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 11.158sOn an Apple M1 Max the medians were about 2.1 ns to return an error, 3.5 ns for a function that carries a deferred recover and never panics, and 68 ns for a panic caught by that recover, with no allocations in any of them. So a deferred recover that never fires adds about a nanosecond and a half, and an actual panic costs about thirty times a returned error. The panic here unwinds a single frame; deeper stacks cost more, because the runtime walks every frame it unwinds. The conclusion is not to avoid defer, which is nearly free, and not to use panic as a shortcut, which it is not one. Panics are for the exceptional case, and at that rate of occurrence 68 ns is irrelevant.
Goexit is not a panic
runtime.Goexit ends the calling goroutine after running its deferred functions, much as a panic would, but it is not a panic and recover does not see it:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() { fmt.Println("recover:", recover()) }()
runtime.Goexit()
fmt.Println("not reached")
}()
wg.Wait()
fmt.Println("main done")
}recover: <nil>
main doneThe deferred function runs and recover() returns nil, because nothing panicked. The test framework relies on this: t.FailNow and t.Fatal call Goexit to stop a test without crashing the run, which is why they must be called from the test's own goroutine and never from a goroutine the test started. If you write code that wraps recover, treat a nil result as "no panic", and do not assume the function completed, since Goexit can end it early.
Where recover belongs
The legitimate uses are few and similar: a boundary where independent units of work meet.
HTTP servers. The net/http server recovers panics in handlers itself, logs the stack and closes the connection. Middleware adds your own response or metrics around that (panic or error shows one).
Worker pools and job runners. One bad job must not stop the others.
Plugin or script hosts. Code you do not control runs inside yours.
Tests. A helper that asserts a function panics is a good use:
package main
import "testing"
func mustPanic(t *testing.T, f func()) (recovered any) {
t.Helper()
defer func() {
recovered = recover()
if recovered == nil {
t.Error("expected a panic")
}
}()
f()
return nil
}
func TestIndexPanics(t *testing.T) {
s := []int{1, 2, 3}
i := 5
r := mustPanic(t, func() { _ = s[i] })
if err, ok := r.(error); !ok || err.Error() == "" {
t.Errorf("unexpected panic value: %v", r)
}
}=== RUN TestIndexPanics
--- PASS: TestIndexPanics (0.00s)
PASS
ok example.test/lab 1.265sEverywhere else, recover is a smell. A recover inside a library function that returns an error hides a bug the caller should see. A recover that logs and continues in the middle of a loop leaves the data it was working on in an unknown state. A blanket recover in main that prints "something went wrong" removes the trace that would have found the cause. If you catch a panic you cannot handle, re-raise it (re-panicking) after cleaning up.
Record what you recover
A recovered panic is still a bug, and the boundary that catches it owes the team a record. At a minimum log the panic value and the stack at error level with the identifiers of the work that failed: the request ID, the route, the job name. Count recovered panics in a metric, and alert when the count is not zero for long, because a service that quietly absorbs a nil dereference on every tenth request looks healthy on every dashboard except the one you did not build. Return a generic failure to the caller, such as a 500 with no detail, since the panic value can contain internal text. And treat the cause as urgent anyway: recovery keeps the process alive, but the request that panicked has failed and the data it touched may be inconsistent.
Rules to remember
- Call
recover()directly in a function run bydefer. Defer a helper function itself, not a closure that calls it. - It returns nil when nothing is panicking, so the idiom is safe everywhere; since Go 1.21 a non-nil result always means a real panic.
- Switch on the type of the result to separate
runtime.Errorbugs from deliberate panics. - Recover per goroutine; the parent cannot do it for the child.
- Fatal errors are not recoverable; avoid them by design.
- Capture the stack inside the deferred function.
For the language's own statement of these rules, read the specification's section on handling panics and the Go blog's Defer, Panic, and Recover.


