panic and recover
In most languages you may already know, errors that can’t be handled locally are raised as exceptions and caught with try/catch. Go deliberately has no exceptions — ordinary errors are just values returned from functions and checked with if err != nil. panic and recover exist for a narrower job: unrecoverable, programmer-error-level situations (an out-of-range index, a nil pointer dereference, a broken invariant) where continuing execution would be unsafe. Understanding exactly how they unwind the call stack, and how to use recover correctly inside a defer, is essential for writing Go programs and libraries that fail safely instead of crashing the whole process.
Overview: What panic and recover Are, and How They Work
Go’s default error-handling mechanism is the humble error value: a function that can fail returns an error alongside its normal result, and the caller checks it explicitly. This is deliberate — Go’s designers wanted control flow to stay visible in the source rather than jumping invisibly through exception handlers. panic and recover are a separate, narrower mechanism reserved for situations a program cannot reasonably continue from: a slice index past its length, a nil map write, a failed type assertion, dividing an integer by zero, or an explicit call to panic(...) when your own code detects a broken invariant. The Go runtime itself uses panic internally for exactly these situations.
Calling panic(v) immediately stops normal execution of the current function. Any functions that function had scheduled with defer still run, in last-in-first-out (LIFO) order, exactly as they would on a normal return. Once those deferred calls finish, control returns to the caller — but the caller doesn’t resume normally either: it also stops, runs its own deferred functions, and passes the panic up to its caller. This is called unwinding the stack, and it keeps propagating upward, frame by frame, until one of two things happens:
- No deferred function calls
recover(), the panic reaches the top of the goroutine’s stack, the Go runtime prints the panic value and a stack trace to standard error, and the whole program exits with status code 2. - Somewhere on the way up, a deferred function calls the built-in
recover(), which stops the unwinding. The function that scheduled the recovering call then returns normally to its own caller, and execution continues from there as if nothing happened, aside from whatever state your recovery code changed.
recover() is a built-in function that returns the value passed to panic, or nil if the goroutine isn’t currently panicking. Crucially, it only has an effect when called directly inside a deferred function. Calling it during normal execution, or inside a helper function that a deferred function merely calls, always returns nil and does nothing to stop the panic. This "directly" rule trips up a lot of Go developers; it’s covered in detail under Common Mistakes below.
One more crucial detail: panics are per-goroutine. If a goroutine panics and nothing inside that same goroutine recovers it, the entire program terminates — even if main‘s goroutine has its own defer/recover in place. A recover in one goroutine can never catch a panic from another goroutine. Any goroutine that might panic needs its own recovery logic if you want the rest of the program to survive it.
Syntax
Both panic and recover are built-in functions — no import is required to use them. The idiomatic pattern for catching a panic looks like this:
defer func() {
if r := recover(); r != nil {
// handle it: log, convert to an error, clean up, etc.
}
}()
| Piece | Meaning |
|---|---|
panic(v any) |
Built-in function that stops normal execution and begins unwinding the stack, carrying the value v (often a string or an error). |
func recover() any |
Built-in function that, when called directly inside a deferred function during an active panic, stops the unwind and returns the panic value. Returns nil otherwise. |
defer |
Schedules a function call to run when the surrounding function returns — whether normally or via panic. recover is only useful inside a deferred call. |
Examples
Example 1: An unrecovered panic crashes the program
This program prints two lines, then indexes past the end of a three-element slice. Slices aren’t bounds-checked at compile time, so this compiles fine but panics at runtime.
package main
import "fmt"
func main() {
fmt.Println("starting the program")
numbers := []int{1, 2, 3}
fmt.Println("about to access an out-of-range index")
fmt.Println(numbers[5])
fmt.Println("this line never runs")
}
Output (standard out):
starting the program
about to access an out-of-range index
Only the first two lines make it to standard output. The third fmt.Println never gets a chance to run because indexing numbers[5] panics before its result is ever printed. What you’d see on the terminal (which interleaves stdout with the runtime’s own stderr output) looks roughly like this:
starting the program
about to access an out-of-range index
panic: runtime error: index out of range [5] with length 3
goroutine 1 [running]:
main.main()
/tmp/prog.go:8 +0x1d
exit status 2
The panic: line and stack trace are printed to standard error, not standard output, and the process exits with status code 2. This is the default, no-recover behavior of every panic in Go.
Example 2: recover stops the crash
Wrapping the risky code with a deferred function that calls recover() stops the panic from propagating past safeCall, so main keeps running normally afterward.
package main
import "fmt"
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered from panic:", r)
}
}()
fmt.Println("inside safeCall, about to panic")
panic("something went wrong")
}
func main() {
fmt.Println("before safeCall")
safeCall()
fmt.Println("after safeCall, program continues normally")
}
Output:
before safeCall
inside safeCall, about to panic
recovered from panic: something went wrong
after safeCall, program continues normally
panic("something went wrong") immediately halts safeCall. Its one deferred function runs, calls recover(), and gets back the string that was passed to panic. Because the panic was recovered, safeCall returns normally to main, and the last fmt.Println in main runs exactly as it would have if no panic had ever happened.
Example 3: Converting a panic into a returned error
A very common real-world use of recover is at a function or package boundary: catch an unexpected panic and turn it into an ordinary error return, so callers can keep using Go’s normal error-checking idiom instead of worrying about crashes.
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
result = a / b
return result, nil
}
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)
}
}
Output:
10 / 2 = 5
error: recovered from panic: runtime error: integer divide by zero
safeDivide uses named return values (result, err) specifically so the deferred function can assign to err after a panic. Dividing by zero panics with runtime error: integer divide by zero; the deferred closure recovers it and wraps it into a normal error, which the caller then checks with the everyday if err != nil pattern — no crash, no exception syntax, just a value.
How Panic and Recover Work Step by Step
The order in which deferred functions run during a panic is easy to get wrong in your head, so it helps to trace through a case with several defers at once:
package main
import "fmt"
func demo() {
defer fmt.Println("deferred 1")
defer fmt.Println("deferred 2")
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
defer fmt.Println("deferred 3")
panic("boom")
}
func main() {
demo()
fmt.Println("back in main")
}
Output:
deferred 3
recovered: boom
deferred 2
deferred 1
back in main
demoregisters four deferred calls, in this order:deferred 1,deferred 2, the recovering closure,deferred 3.panic("boom")runs.demostops executing immediately; nothing after thepaniccall ever runs.- Go now runs
demo‘s deferred calls in LIFO order — last registered, first run.deferred 3was registered last, so it prints first. - Next in line is the recovering closure. It calls
recover(), which is currently valid because a panic is in progress and this call is directly inside a deferred function. It captures the value"boom"and printsrecovered: boom. At this instant, the unwinding stops — the panic will not propagate pastdemo. - Even though the panic is resolved,
demo‘s remaining deferred calls still run to completion, because all of a function’s defers always run before it returns, panic or not:deferred 2, thendeferred 1. - With every deferred call finished and the panic recovered,
demoreturns normally tomain, which printsback in mainas if nothing unusual had happened.
The key takeaway: recovering a panic doesn’t skip the rest of the current function’s defers, and it doesn’t rewind execution to some earlier point — it simply lets the function return normally once all of its own deferred calls have run.
Common Mistakes
Mistake 1: Calling recover() indirectly instead of directly inside the deferred function
recover() only stops a panic when it is called directly by the function that was deferred. Moving the recover() call into a helper function that the deferred function merely calls breaks it silently — the panic still propagates, uncaught.
package main
import "fmt"
func handlePanic() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}
func riskyOperation() {
defer func() {
handlePanic()
}()
panic("failure")
}
func main() {
riskyOperation()
fmt.Println("this never prints")
}
Output:
(nothing is printed to stdout -- recover() inside handlePanic is not called directly by the deferred function, so it never stops the panic; the program crashes with an unrecovered panic: failure)
The deferred function here is the anonymous closure func() { handlePanic() }, not handlePanic itself. recover() is called inside handlePanic, which is called by the deferred closure — not directly by it — so it returns nil and the panic keeps propagating. The fix is to call recover() in the body of the deferred function itself:
package main
import "fmt"
func riskyOperation() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("failure")
}
func main() {
riskyOperation()
fmt.Println("program continues")
}
Output:
recovered: failure
program continues
Interesting subtlety: if you had instead written defer handlePanic() (deferring a direct call to handlePanic, without wrapping it in a closure), it would have worked, because handlePanic itself would then be the deferred function, and recover() is called directly in its body. The rule is about which function is directly deferred, not about whether you use a named helper or an anonymous closure.
Mistake 2: Assuming a recover in main catches panics from other goroutines
Panics are strictly per-goroutine. A defer/recover in main can only catch panics that happen in main‘s own goroutine — never panics from goroutines started with go.
package main
import (
"fmt"
"sync"
)
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
panic("boom in goroutine")
}()
wg.Wait()
fmt.Println("this never prints")
}
Output:
(the program crashes -- a panic in a goroutine can only be recovered inside that same goroutine, so main's deferred recover never runs; nothing is printed)
Even though main has its own recover in place, it is powerless here: the panic happens on a different goroutine’s stack, and that goroutine has no recovery of its own, so the whole program terminates. The fix is to put the recovery logic inside the goroutine that might panic:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered in goroutine:", r)
}
}()
panic("boom in goroutine")
}()
wg.Wait()
fmt.Println("main continues safely")
}
Output:
recovered in goroutine: boom in goroutine
main continues safely
As a general rule, any goroutine that isn’t tightly controlled by you should have its own top-level defer/recover, or an unexpected panic anywhere inside it will take down the entire process.
Best Practices
- Prefer returning an
errorfor anything a caller might reasonably want to handle. Reservepanicfor situations that indicate a bug or a genuinely unrecoverable state. - Always call
recover()directly inside a deferred function — never inside a function that the deferred function calls. - Give every long-running or independently launched goroutine its own
defer/recoverif a panic there shouldn’t take down the whole program. - When you recover a panic at a boundary (an HTTP handler, a worker goroutine, a library’s public API), log or wrap the recovered value so the original cause isn’t lost — don’t swallow it silently.
- Use named return values plus a deferred
recoverwhen you want to convert an internal panic into a normalerrorreturn, as shown in Example 3. - Don’t use
panic/recoveras a general-purpose substitute forif err != nilor as agoto-like way to jump out of nested loops — it obscures control flow and is far slower than an ordinary return. - If you re-panic after inspecting a recovered value (for example, only handling errors you recognize and re-raising the rest with
panic(r)), make sure the caller further up the stack is prepared to see it.
Practice Exercises
- Write a function
safeSqrt(x float64) (result float64, err error)that treats a negative input as invalid by callingpanic("negative input"), and uses a deferredrecoverto convert that panic into a returnederrorinstead of crashing, following the pattern from thesafeDivideexample. Call it with both a positive and a negative number and print the results. - Before running it, predict the exact print order of a function with three
defer fmt.Printlncalls, a fourth deferred closure that callsrecover(), and apanicin between — then write the code and check your prediction. - Extend the goroutine example so that three goroutines are launched, each wrapped in its own
defer/recover, with async.WaitGroupsomainwaits for all three. Expect three "recovered in goroutine: …" lines (their order may vary) followed by a final line printed frommain.
Summary
panicandrecoverare Go’s mechanism for truly exceptional situations — routine errors should be returned as values and checked, not panicked.- Calling
panic(v)stops the current function, runs its deferred calls in LIFO order, and unwinds up the call stack until the program crashes or a deferred call recovers it. recover()only stops a panic when called directly inside a deferred function during an active panic; called anywhere else it just returnsnil.- All of a function’s deferred calls still run after a panic is recovered — recovery doesn’t skip them, it just lets the function return normally once they’ve finished.
- Panics are per-goroutine: a recover in
maincannot catch a panic from a different goroutine, so any goroutine that might panic needs its own recovery logic. - A common, idiomatic use of
recoveris converting an internal panic into a normal returnederrorat a function or package boundary, via named return values.
