Fix: Go Panic Not Recovered — panic/recover Patterns and Common Pitfalls
Quick Answer
How to handle Go panics correctly — recover() placement, goroutine panics, HTTP middleware recovery, defer ordering, distinguishing panics from errors, and when not to use recover.
The Problem
A recover() call doesn’t catch a panic:
func riskyOperation() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered:", r) // Never prints
}
}()
go func() {
panic("goroutine panic") // This crashes the whole program
}()
time.Sleep(100 * time.Millisecond)
}Or a panic in an HTTP handler crashes the entire server:
panic: runtime error: index out of range [5] with length 3
goroutine 12 [running]:
main.handleRequest(...)
/app/handlers.go:42 +0x1c4
// Server process exits — all active connections terminatedOr recover() is called but returns nil despite a panic occurring:
func safeCall() {
r := recover() // Called directly, not in a deferred function
if r != nil {
fmt.Println("Recovered") // Never prints — recover() returns nil
}
panic("this panics")
}Why This Happens
recover() has strict rules that trip up many developers:
recover()only works inside a deferred function — callingrecover()directly (not viadefer) always returnsnil. It must be in a function that was deferred before the panic.recover()can’t catch panics from other goroutines — each goroutine has its own call stack. A panic in goroutine B can’t be recovered by goroutine A, even if A started B. Every goroutine must have its ownrecover()if it might panic.- The deferred function must be directly enclosing —
defer func() { recover() }()works.defer someFunc()wheresomeFunccallsrecover()also works. But ifsomeFunccallsanotherFuncwhich callsrecover(), therecover()is too far down the call stack and won’t intercept the panic. recover()only catches panics, not runtime crashes — stack overflows and other fatal errors (throw) cannot be recovered.
Fix 1: Use recover() Correctly
The correct pattern for catching panics:
package main
import "fmt"
// CORRECT — recover() inside a directly deferred function
func safeOperation() (err error) {
defer func() {
if r := recover(); r != nil {
// Convert panic to an error return
err = fmt.Errorf("panic recovered: %v", r)
}
}()
// Code that might panic
riskyCall()
return nil
}
// WRONG — recover() not in a deferred function
func wrongPattern() {
r := recover() // Returns nil — not in deferred context
if r != nil {
fmt.Println("This never prints")
}
panic("unrecovered panic")
}
// WRONG — recover() too many call frames away
func recoverHelper() {
if r := recover(); r != nil { // Too far from the panic — returns nil
fmt.Println("Not caught")
}
}
func anotherWrongPattern() {
defer recoverHelper() // deferred, but recover() is one level too deep
panic("unrecovered")
}
// CORRECT — recover() in the directly deferred function (even if it calls helpers)
func logAndRecover(r interface{}) {
fmt.Printf("Panic: %v\n", r)
// Logging, metrics, etc.
}
func correctWithHelper() {
defer func() {
if r := recover(); r != nil { // This recover() is in the deferred func
logAndRecover(r) // OK to call helpers after recovering
}
}()
panic("this IS caught")
}Fix 2: Recover from Panics in Goroutines
Each goroutine must handle its own panics:
// WRONG — panic in goroutine crashes the entire program
func startWorker(jobs <-chan Job) {
go func() {
for job := range jobs {
process(job) // If this panics — crash
}
}()
}
// CORRECT — each goroutine has its own recover
func startWorker(jobs <-chan Job, errors chan<- error) {
go func() {
defer func() {
if r := recover(); r != nil {
// Send the panic as an error — don't crash the program
errors <- fmt.Errorf("worker panic: %v", r)
}
}()
for job := range jobs {
process(job)
}
}()
}
// Worker pool with panic recovery
func runWorkerPool(numWorkers int, jobs <-chan Job) {
var wg sync.WaitGroup
errors := make(chan error, numWorkers)
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("Worker %d panicked: %v\n%s",
workerID, r, debug.Stack())
}
}()
for job := range jobs {
processJob(job)
}
}(i)
}
wg.Wait()
close(errors)
}Helper for launching safe goroutines:
// SafeGo — launches a goroutine that recovers panics and logs them
func SafeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Goroutine panic: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
// Usage
SafeGo(func() {
processItem(item)
})Fix 3: HTTP Server Panic Recovery Middleware
Prevent a single panicking handler from crashing the entire HTTP server:
// net/http — recovery middleware
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// Log the panic with full stack trace
log.Printf("HTTP handler panic: %v\n%s", rec, debug.Stack())
// Send 500 to the client (if headers not already sent)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Apply to router
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)
// Wrap with recovery middleware
handler := recoveryMiddleware(mux)
http.ListenAndServe(":8080", handler)
}With Gin framework:
// Gin has built-in recovery middleware
r := gin.New()
r.Use(gin.Recovery()) // Recovers panics, returns 500
// Or custom recovery handler
r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
if err, ok := recovered.(string); ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err,
})
}
c.AbortWithStatus(http.StatusInternalServerError)
}))With Echo framework:
e := echo.New()
e.Use(middleware.Recover()) // Built-in
// Custom recovery
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
LogLevel: log.ERROR,
LogErrorFunc: func(c echo.Context, err error, stack []byte) error {
log.Errorf("Panic: %v\n%s", err, stack)
return nil
},
}))Fix 4: Capture Stack Traces from Panics
Include stack traces when logging recovered panics:
import (
"fmt"
"runtime/debug"
)
func runWithRecovery(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
// Capture stack trace at the point of recovery
stack := debug.Stack()
err = fmt.Errorf(
"panic: %v\n\nStack trace:\n%s",
r,
stack,
)
}
}()
fn()
return nil
}
// Structured logging version
type PanicError struct {
Value interface{}
StackTrace string
}
func (e *PanicError) Error() string {
return fmt.Sprintf("panic: %v", e.Value)
}
func safeRun(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = &PanicError{
Value: r,
StackTrace: string(debug.Stack()),
}
}
}()
fn()
return
}Sending panic info to an error tracker:
func httpRecovery(next http.Handler, tracker ErrorTracker) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := debug.Stack()
// Report to error tracking service (Sentry, Rollbar, etc.)
tracker.Report(PanicEvent{
Value: fmt.Sprintf("%v", rec),
Stack: string(stack),
URL: r.URL.String(),
Method: r.Method,
Headers: r.Header,
})
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Fix 5: Understand When to Panic vs Return Errors
Go convention: use errors for expected failure conditions; use panic only for programming errors or truly unrecoverable states.
// WRONG — using panic for expected errors
func getUser(id int) *User {
user, err := db.FindUser(id)
if err != nil {
panic(err) // Caller can't handle this gracefully
}
return user
}
// CORRECT — return errors for expected failures
func getUser(id int) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("getUser(%d): %w", id, err)
}
return user, nil
}
// Legitimate uses of panic — programming invariant violations
func mustPositive(n int) int {
if n <= 0 {
// This is a programming error — caller passed invalid data
// panic is appropriate (will be caught in tests)
panic(fmt.Sprintf("mustPositive: argument must be positive, got %d", n))
}
return n
}
// Package initialization — if setup fails, the package is unusable
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
// Package can't work without a database — panic at startup
panic(fmt.Sprintf("failed to connect to database: %v", err))
}
}The Must pattern for initialization:
// MustCompile panics if the regex is invalid — appropriate at init time
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Similar pattern for your own code
func mustLoadConfig(path string) *Config {
cfg, err := loadConfig(path)
if err != nil {
panic(fmt.Sprintf("failed to load config from %s: %v", path, err))
}
return cfg
}
var config = mustLoadConfig("/etc/app/config.yaml")Fix 6: Defer Order and Panic Interaction
Deferred functions run in LIFO (last in, first out) order, even during panics:
func demonstrateDeferOrder() {
defer fmt.Println("deferred 1 — runs last")
defer fmt.Println("deferred 2 — runs second")
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r) // Runs first
}
}()
defer fmt.Println("deferred 3 — runs before recover? No — recover is last deferred")
panic("test panic")
}
// Output:
// deferred 3 — runs before recover? No — recover is last deferred
// recovered: test panic
// deferred 2 — runs second
// deferred 1 — runs last
// NOTE: The recover() defer runs in the correct position (LIFO).
// After recovering, remaining deferred functions still run.Cleanup with defer during panics:
func processFile(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
// Always close the file — even during a panic
closeErr := f.Close()
if closeErr != nil && err == nil {
err = closeErr
}
// Also recover panics — convert to error
if r := recover(); r != nil {
err = fmt.Errorf("panic during file processing: %v", r)
}
}()
// Process file — if this panics, file is still closed via defer
return parseFile(f)
}Still Not Working?
runtime.Goexit() is not a panic — runtime.Goexit() terminates the current goroutine without panicking. recover() does not intercept it. defered functions still run.
Panic with a nil value — panic(nil) causes a panic, but recover() returns nil. This is indistinguishable from “no panic occurred”:
defer func() {
r := recover()
if r == nil {
// Could be: no panic, OR panic(nil)
// Go 1.21+ introduced PanicNilError to distinguish these
}
}()In Go 1.21+, panic(nil) is now wrapped in a *runtime.PanicNilError, so recover() returns a non-nil value.
Stack overflow cannot be recovered — a runaway recursion that exceeds the stack limit causes a stack overflow which is a fatal error. recover() cannot catch it.
Signal panics (SIGSEGV, etc.) — CGo or unsafe pointer operations that cause memory access violations produce signals that crash the runtime. These are not recoverable with recover().
For related Go issues, see Fix: Go Interface Nil Panic and Fix: Go Goroutine Leak.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Go Generics Type Constraint Error — Does Not Implement or Cannot Use as Type
How to fix Go generics errors — type constraints, interface vs constraint, comparable, union types, type inference failures, and common generic function pitfalls.
Fix: Go Error Handling Not Working — errors.Is, errors.As, and Wrapping
How to fix Go error handling — errors.Is vs ==, errors.As for type extraction, fmt.Errorf %w for wrapping, sentinel errors, custom error types, and stack traces.
Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend
How to fix Java record issues — compact constructor validation, custom accessor methods, Jackson serialization, inheritance restrictions, and when to use records vs regular classes.