Skip to content

Fix: Go Panic Not Recovered — panic/recover Patterns and Common Pitfalls

FixDevs ·

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 terminated

Or 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 — calling recover() directly (not via defer) always returns nil. 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 own recover() if it might panic.
  • The deferred function must be directly enclosingdefer func() { recover() }() works. defer someFunc() where someFunc calls recover() also works. But if someFunc calls anotherFunc which calls recover(), the recover() 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 panicruntime.Goexit() terminates the current goroutine without panicking. recover() does not intercept it. defered functions still run.

Panic with a nil valuepanic(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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles