panic and recover: what every Go developer should know
Most Go developers encounter panic and recover early in their learning journey, but many never fully internalize when—or whether—to use them. After years of building production systems, we've learned that misunderstanding panic semantics costs teams real incidents: unhandled panics that crash services, overly broad recover calls that hide bugs, and library code that panics instead of returning errors.
Why panic exists, and why you'll almost never need it
Go's panic mechanism evolved from a simple premise: some errors are so fundamental that continuing execution is meaningless. If your program can't allocate memory, or if the runtime detects a race condition, panic is the right tool. But in application code—the systems we build daily—panic is almost always the wrong choice.
Think about the last time you saw a production incident. Was it because the code ran out of memory, or was it because business logic took an unexpected path and someone decided to panic instead of returning an error? We've investigated enough postmortems to say: it's almost always the latter.
Panic terminates the current goroutine and unwinds the stack, executing any deferred functions along the way. In a server context, if your panic escapes unhandled, the entire process dies. That's catastrophic for availability. Even if you recover from the panic in a middleware layer, you've already lost the request, logged unhelpfully, and created cognitive overhead for future debuggers.
Panic for unrecoverable programmer errors
There are legitimate cases for panic. The key word is programmer error—bugs in your own code that should never happen in production, and if they do, indicate a deployment mistake or broken invariant that no amount of graceful handling will fix.
Consider a configuration struct that requires validation before use:
type Config struct {
APIKey string
Timeout time.Duration
MaxRetry int
}
func NewConfig(apiKey string, timeout time.Duration, maxRetry int) *Config {
if timeout <= 0 {
panic("timeout must be positive")
}
if maxRetry < 0 {
panic("maxRetry cannot be negative")
}
return &Config{
APIKey: apiKey,
Timeout: timeout,
MaxRetry: maxRetry,
}
}
Why panic here? Because these constraints are invariants—they're not user input or external failures. They're promises your code makes to itself. If this panic fires in production, it means your deployment process is broken; you've passed invalid values at startup. The panic forces you to fix the bug immediately rather than silently accepting broken state.
Another legitimate case: when your code relies on a type assertion that should always succeed given your program's structure:
func ProcessUserData(data interface{}) {
user, ok := data.(*User)
if !ok {
panic("expected *User, got something else")
}
// Safe to use user
}
If this panic fires, it points to a type mismatch bug upstream—the kind of mistake that should be caught during code review or testing. Production panic signals "your assumptions about the type system are broken; stop the presses."
The pattern: panic only when the alternative is proceeding with corrupted or nonsensical state that violates core invariants.
Return errors for expected failures
Everything else—network timeouts, file not found, invalid user input, API rate limits, database connection drops—should be returned as errors. Expected failures are those your system can meaningfully handle, log, retry, or communicate back to the caller.
Let's look at a realistic example: a payment processing function that calls an external service.
// Bad: panics on network error
func ProcessPaymentBad(ctx context.Context, userID string, amount int64) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic("payment service unreachable")
}
// ...
}
// Good: returns error
func ProcessPayment(ctx context.Context, userID string, amount int64) error {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("payment service unreachable: %w", err)
}
// ...
return nil
}
The good version allows the caller to decide: retry with exponential backoff, log an alert, queue for later, or return a 503 to the user. The bad version nukes the entire service, potentially causing cascading failures across your infrastructure.
In production systems, network failures are guaranteed. Treating them as unrecoverable is a bug in your design.
recover in middleware to keep servers alive
For HTTP servers and request handlers, panic is especially dangerous because one handler's panic can bring down the entire process. This is where recover becomes essential—not to fix the panic, but to contain it and ensure your server stays alive to serve other requests.
The canonical pattern is middleware that wraps each handler:
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.Printf("panic in handler: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", handleData)
handler := RecoveryMiddleware(mux)
http.ListenAndServe(":8080", handler)
}
This ensures that if any handler panics, the middleware catches it, logs it, returns a 500 to the client, and the server stays up. Without this safety net, one buggy endpoint crashes your entire API.
More sophisticated frameworks like Echo or Chi include this out of the box. If you're rolling your own, add it immediately.
Important: recovery middleware logs the panic as a serious incident. This is intentional. A panic in production is a bug that needs immediate investigation. The middleware prevents cascading failure while the alert goes out.
Never panic in library code
This is non-negotiable. If you're writing a package that others will use, panic is forbidden except in the most extreme circumstances (and even then, reconsider).
Why? Because your callers can't predict when your code will panic. They might not be running recovery middleware. They might be calling your function from a goroutine pool that will log the panic and silently exit. They might be integrating your library into a larger system where a panic causes a cascade of failures you never intended.
Bad library code:
package paymentlib
func ChargeCard(apiKey string, amount int64) {
if apiKey == "" {
panic("apiKey required")
}
if amount <= 0 {
panic("amount must be positive")
}
// ...
}
Good library code:
package paymentlib
type ValidationError struct {
Field string
Message string
}
func ChargeCard(apiKey string, amount int64) error {
if apiKey == "" {
return &ValidationError{Field: "apiKey", Message: "required"}
}
if amount <= 0 {
return &ValidationError{Field: "amount", Message: "must be positive"}
}
// ...
return nil
}
Your library's error handling becomes the caller's contract. By returning errors, you let them decide whether to log, retry, escalate, or panic themselves (which they probably shouldn't, but at least it's their choice).
Standard library: look at packages like database/sql, net/http, encoding/json. They return errors for every conceivable failure. They don't panic. Follow their lead.
Common mistakes
- Panicking on invalid input: User input, environment variables, configuration files—all should return errors, never panic. The program must tolerate bad input gracefully.
- Recovering too broadly: A bare recover() in a defer captures all panics, including those from goroutines started within the function, leading to silent failures. Be specific about recovery scope.
- Logging panic without context: When you recover from a panic, include stack trace information (via debug.Stack() or runtime.Stack()) so future investigators have a fighting chance.
- Using panic for flow control: Some developers panic and recover to implement complex control flow. Don't. Use errors, channels, or context cancellation instead.
- Forgetting that recover only works in defer: Calling recover() directly in the function body does nothing. It must be in a deferred function.
Key takeaways
- Panic is for unrecoverable programmer errors—invariant violations, type assertion failures at boundaries, startup validation. Not for network timeouts, user errors, or external API failures.
- Return errors for everything your program can reasonably encounter and handle. Errors are the idiomatic Go way to signal failure.
- Add recovery middleware to your HTTP servers to catch panics and return 500s without crashing the process. This is defensive programming, not error handling.
- Never panic in library code. Let your callers decide how to handle failure by returning errors instead.
- When you do recover from a panic in production, treat it as a high-priority bug. Log the stack trace, alert your team, and investigate immediately.






Comments
Loading comments…