Go Panic and Recover: Error Recovery
• Panic is for programmer errors and truly exceptional conditions—use regular error returns for expected failures and business logic errors
Key Insights
• Panic is for programmer errors and truly exceptional conditions—use regular error returns for expected failures and business logic errors • Recover only works inside deferred functions and stops panic propagation up the call stack, allowing graceful degradation instead of program termination • Wrapping goroutines with panic recovery is critical since panics in goroutines crash the entire program, not just the goroutine
Introduction to Panic in Go
Go’s panic mechanism represents a fundamental departure from the language’s idiomatic error handling. While Go encourages explicit error returns for expected failures, panic exists for situations where the program has entered an invalid state that it cannot reasonably recover from.
A panic occurs when the runtime detects an impossible condition (like indexing out of bounds) or when you explicitly call panic(). Unlike errors, which flow through your program’s normal control flow, panics unwind the stack and terminate the program unless intercepted.
func demonstratePanic() {
// This will panic: index out of range
slice := []int{1, 2, 3}
_ = slice[10]
}
func nilPointerPanic() {
var ptr *int
// This will panic: nil pointer dereference
fmt.Println(*ptr)
}
func explicitPanic() {
// Explicitly trigger a panic
panic("something went catastrophically wrong")
}
The key distinction: errors represent expected failure modes in your domain logic (file not found, network timeout, invalid input), while panics represent programming mistakes or conditions that should never happen in correct code.
Understanding the Panic Mechanism
When a panic occurs, Go immediately stops normal execution and begins unwinding the call stack. As it unwinds, it executes any deferred functions in reverse order (last deferred, first executed). If the panic reaches the top of the goroutine’s stack without being recovered, the program prints a stack trace and terminates.
func outer() {
defer fmt.Println("outer: deferred")
fmt.Println("outer: start")
middle()
fmt.Println("outer: end") // Never executes
}
func middle() {
defer fmt.Println("middle: deferred")
fmt.Println("middle: start")
inner()
fmt.Println("middle: end") // Never executes
}
func inner() {
defer fmt.Println("inner: deferred")
fmt.Println("inner: start")
panic("something went wrong")
fmt.Println("inner: end") // Never executes
}
// Output:
// outer: start
// middle: start
// inner: start
// inner: deferred
// middle: deferred
// outer: deferred
// panic: something went wrong
// [stack trace follows]
This unwinding behavior ensures that cleanup code in deferred functions runs even during abnormal termination. It’s your last chance to release resources, close files, or log critical information before the program dies.
The Recover Function
The recover() function is Go’s mechanism for regaining control during a panic. When called inside a deferred function, recover intercepts the panic, stops the unwinding process, and returns the value passed to panic(). If there’s no panic in progress, recover returns nil.
func safeFunction() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered from panic: %v\n", r)
}
}()
// This will panic but won't crash the program
panic("controlled chaos")
fmt.Println("This never executes")
}
func main() {
safeFunction()
fmt.Println("Program continues normally")
}
// Output:
// Recovered from panic: controlled chaos
// Program continues normally
Critically, recover only works when called directly from a deferred function. Calling it from a helper function won’t work:
func attemptRecover() interface{} {
return recover() // Always returns nil—wrong context
}
func brokenRecovery() {
defer attemptRecover() // Won't catch the panic
panic("this will crash")
}
Practical Recovery Patterns
HTTP Middleware Recovery
Web servers must never crash from a single bad request. Panic recovery middleware protects your entire service:
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// Log the panic with stack trace
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
log.Printf("Panic recovered: %v\n%s", err, buf[:n])
// Return 500 to client
http.Error(w, "Internal Server Error",
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Goroutine Safety
Panics in goroutines crash the entire program, not just the goroutine. Always wrap goroutine logic with recovery:
func safeGoroutine(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Goroutine panic: %v", r)
// Optionally restart, report to monitoring, etc.
}
}()
fn()
}()
}
// Usage
safeGoroutine(func() {
// Your potentially panicking code
processData()
})
Converting Panics to Errors
Sometimes you need to call code that might panic but want to handle it as a regular error:
func callDangerousCode(input string) (result string, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("operation panicked: %v", r)
}
}()
result = dangerousFunction(input)
return result, nil
}
This pattern is useful when wrapping third-party libraries or legacy code that uses panic inappropriately.
Best Practices and Anti-patterns
Use errors for expected failures:
// GOOD: Expected failure, return error
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
// ...
}
// BAD: File not existing is expected, don't panic
func loadConfig(path string) *Config {
data, err := os.ReadFile(path)
if err != nil {
panic(err) // Wrong!
}
// ...
}
Use panic for programmer errors:
// GOOD: Invalid state that should never happen
func (s *Server) Start() {
if s.listener != nil {
panic("server already started") // Programming mistake
}
// ...
}
Don’t swallow panics silently:
// BAD: Silent recovery hides bugs
defer func() {
recover() // Ignoring the error
}()
// GOOD: Log and potentially re-panic
defer func() {
if r := recover(); r != nil {
log.Printf("Panic: %v\n%s", r, debug.Stack())
// Optionally re-panic if you can't handle it
panic(r)
}
}()
Don’t use panic for control flow:
// BAD: Using panic like exceptions
func findUser(id int) User {
user, err := db.Query(id)
if err != nil {
panic(err) // Don't do this
}
return user
}
// GOOD: Return errors normally
func findUser(id int) (User, error) {
return db.Query(id)
}
Advanced Scenarios
Custom Panic Values
You can panic with any value, including structured data:
type PanicError struct {
Code string
Message string
Context map[string]interface{}
}
func processTransaction(tx Transaction) {
if tx.Amount < 0 {
panic(PanicError{
Code: "INVALID_AMOUNT",
Message: "transaction amount cannot be negative",
Context: map[string]interface{}{"amount": tx.Amount},
})
}
}
func handleTransaction(tx Transaction) error {
defer func() {
if r := recover(); r != nil {
if pe, ok := r.(PanicError); ok {
log.Printf("Structured panic: %s - %s", pe.Code, pe.Message)
}
}
}()
processTransaction(tx)
return nil
}
Testing Panic Behavior
Use table-driven tests to verify panic conditions:
func TestDivide_Panics(t *testing.T) {
tests := []struct {
name string
a, b int
wantPanic bool
panicMsg string
}{
{"normal", 10, 2, false, ""},
{"divide by zero", 10, 0, true, "division by zero"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer func() {
r := recover()
if tt.wantPanic && r == nil {
t.Error("expected panic but didn't get one")
}
if !tt.wantPanic && r != nil {
t.Errorf("unexpected panic: %v", r)
}
if tt.wantPanic && r != nil {
if msg := fmt.Sprint(r); msg != tt.panicMsg {
t.Errorf("panic message = %v, want %v", msg, tt.panicMsg)
}
}
}()
divide(tt.a, tt.b)
})
}
}
Conclusion
Panic and recover are powerful tools, but they’re not replacements for proper error handling. Use them judiciously: panic for programmer errors and impossible states, recover strategically at service boundaries to prevent cascading failures.
The golden rule: if you can reasonably handle a failure condition, return an error. Reserve panic for situations where continuing execution would compromise program correctness or safety. When you do recover, always log the panic with sufficient context—silent recovery masks bugs that need fixing.
In production systems, wrap your HTTP handlers and goroutines with recovery logic. Your users will thank you when a single bad request doesn’t bring down your entire service. But remember: recovery is a safety net, not a solution. The real fix is eliminating the conditions that cause panics in the first place.