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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.