Fix: Go Panic Not Recovered — panic/recover Patterns and Common Pitfalls
Part of: Go, Rust & Systems Errors
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.
The per-goroutine scope rule is the most common source of confusion. In most languages with exception handling (Java, Python, C#), an unhandled exception in a child thread terminates only that thread. In Go, an unhandled panic in any goroutine terminates the entire process. There is no global panic handler. This design is intentional: Go treats unrecovered panics as programming errors that should be fixed, not silently swallowed. But it means that every goroutine you launch, whether it’s a background worker, an HTTP handler, or a one-off computation, must either be guaranteed not to panic or must include its own recover().
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 and Structured Logging
Include stack traces when logging recovered panics. Use Go’s log/slog (available since Go 1.21) for structured output that integrates with log aggregation systems:
import (
"fmt"
"log/slog"
"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 with slog
func runWithStructuredRecovery(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
stack := string(debug.Stack())
slog.Error("panic recovered",
"panic_value", fmt.Sprintf("%v", r),
"stack_trace", stack,
)
err = fmt.Errorf("panic: %v", r)
}
}()
fn()
return nil
}
// Structured error type for programmatic handling
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 panics to Sentry or Datadog:
// Sentry integration — capture panic with full context
import "github.com/getsentry/sentry-go"
func httpRecoveryWithSentry(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
hub := sentry.CurrentHub().Clone()
hub.Scope().SetRequest(r)
hub.RecoverWithContext(r.Context(), rec)
// Sentry captures the panic value, stack trace, and request context
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Datadog APM — panics are captured automatically if you use dd-trace-go middleware
// For manual capture:
import "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
func recoverWithDatadog(fn func()) {
defer func() {
if r := recover(); r != nil {
span, _ := tracer.SpanFromContext(context.Background())
span.SetTag("error", true)
span.SetTag("error.msg", fmt.Sprintf("%v", r))
span.SetTag("error.stack", string(debug.Stack()))
span.Finish()
}
}()
fn()
}The integration differs per platform. Sentry’s Go SDK has dedicated Recover and RecoverWithContext methods that automatically extract the stack trace and attach it to an event. Datadog requires manual span tagging. New Relic’s Go agent captures panics through its newrelic.WrapHandle middleware for HTTP handlers but requires manual RecoverPanic calls for goroutines.
Fix 5: Docker and Kubernetes Panic Behavior
Panics interact with container orchestration in ways that are not obvious from local development.
Docker container exit codes: When a Go program panics and the panic is not recovered, the Go runtime prints the stack trace to stderr and calls os.Exit(2). Docker reports this as exit code 2. This is different from an OOM kill (exit code 137) or a SIGTERM (exit code 143). Check the exit code to distinguish panics from resource issues:
# Check why a container stopped
docker inspect --format='{{.State.ExitCode}}' my-container
# Exit code 2 = unrecovered panic
# Exit code 137 = OOM killed (SIGKILL)
# Exit code 143 = SIGTERM (graceful shutdown)Kubernetes CrashLoopBackOff from panics: If a Go service panics on startup (e.g., a Must* function fails during init), Kubernetes restarts the pod. If the panic happens every time (bad config, missing secret, unreachable database), the pod enters CrashLoopBackOff. The backoff delay increases exponentially (10s, 20s, 40s, up to 5 minutes), making the service unavailable for extended periods.
To avoid this, validate configuration and dependencies at startup with proper error handling instead of panic:
// WRONG — panics on missing env var, causes CrashLoopBackOff
func init() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
panic("DATABASE_URL not set")
}
}
// BETTER — return error from main, exit cleanly with informative message
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
os.Exit(1)
}
}
func run() error {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return fmt.Errorf("DATABASE_URL environment variable is required")
}
// ...
return nil
}Go race detector interaction: The race detector (-race flag) reports data races by calling panic internally. These panics cannot be recovered with recover(). If you run tests or services with -race enabled and see panics you can’t catch, check whether the panic message starts with “DATA RACE” — that’s the race detector, not your code. The race detector is typically enabled only in test and CI environments, not in production, because it adds 5-10x overhead.
Fix 6: 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 7: 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. Go’s goroutine stacks start small (a few KB) and grow dynamically up to a maximum (default 1GB, configurable with runtime.SetMaxStack), but once the maximum is hit, the runtime aborts with no chance for recovery.
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().
Panic during init() cannot be recovered — if a panic occurs inside an init() function, it runs before main() and there is no deferred function in scope to catch it. The program exits immediately. Validate prerequisites inside main() (or a run() function called from main) where you can defer a recovery handler.
Recovering a panic re-panics — if your recovery handler itself panics (e.g., logging to a nil logger), the second panic is not caught by the same deferred function. The program crashes. Keep recovery handlers simple and test them independently.
For related Go issues, see Fix: Go Interface Nil Panic, Fix: Go Goroutine Leak, Fix: Go Channel Deadlock, and Fix: Go Context Deadline Exceeded.
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.