Idiomatic Panic and Recover Guidance
Guidelines for using panic and recover in Go, including when to panic versus return an error, how to keep panics inside package boundaries, and what patterns lead to safe and maintainable code
The panic and recover built‑ins give Go a structured way to signal and handle truly catastrophic conditions. Their power comes with a responsibility: used incorrectly, they make programs brittle, unpredictable, and hard to debug. This page walks through the concrete rules that separate idiomatic Go from code that misuses these mechanisms.
The Two Categories of Failure
Before deciding whether to panic or return an error, it helps to separate every failure a program can encounter into one of two buckets.
Expected failures are conditions the program is designed to handle. A file might not exist. A network connection might time out. User input might be malformed. In all these cases, the correct response is to return an error so the caller can decide what to do next.
Programmer mistakes are violations of the code’s own invariants. A function receives a nil pointer when its documentation says it must never be nil. An index is out of bounds. A data structure that should always hold a valid state has become corrupted. These are bugs — and bugs should not be handled; they should be exposed as loudly and as early as possible.
A mental model:
Think of a panic as a fire alarm. You don’t pull it when the door is locked — you go find the key. You pull it when there is smoke, because the building is unsafe. Returning an error is the key: it’s the normal, expected way to signal that something went wrong. A panic means the program has entered a state the developer never intended, and the safest course is to stop.
This distinction is the bedrock of all other guidance. Almost every misuse of panic comes from treating a recoverable, expected condition as if it were a bug.
When to Use Panic
Go’s creators and the broader community have narrowed the legitimate reasons to call panic to two specific categories. If a situation does not fit one of them, you should be returning an error.
1. An Unrecoverable Startup Condition
Some failures make it impossible for the program to proceed at all. If a web server cannot bind to its configured port, there is nothing left to do. If a critical database credential is missing and the application cannot function without it, returning an error that nobody can handle just delays the inevitable.
// This runs at program startup.
func main() {
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
panic("LISTEN_ADDR environment variable must be set")
}
// … rest of server setup
}
In this snippet, missing the address means the entire process has no useful work to do. A panic stops the program immediately, prints a clear message, and avoids the confusion of an application that is running but broken. This is the “fail fast” pattern applied at the process level.
2. A Programmer Error Detected at Runtime
When a function detects that a caller violated its contract — passing a nil pointer where a valid object is required, for example — that is a bug in the caller’s code. The program is not in a known-safe state, and there is no reasonable recovery path for the current operation.
// Compile parses a regular expression and returns a *Regexp.
// If the pattern cannot be parsed, it returns an error.
func Compile(expr string) (*Regexp, error) { … }
// MustCompile is like Compile but panics if the expression cannot be parsed.
// It is intended for use with constant patterns where a failure is a bug
// in the source code.
func MustCompile(expr string) *Regexp {
re, err := Compile(expr)
if err != nil {
panic("regexp: Compile(" + expr + "): " + err.Error())
}
return re
}
MustCompile (found in regexp, template, and several other standard library packages) is the canonical example. It exists so that global variable initializations like var emailRE = regexp.MustCompile(^[a-z]+@...$) can be written concisely. If the pattern is invalid, the developer made a mistake — the pattern is hard‑coded, not user‑supplied — and panicking at startup is the right call.
The Must prefix convention:
When you create a function that wraps an error‑returning operation and panics on failure, prefix its name with Must. This tells callers explicitly that this variant will not return normally for invalid input and should only be used when failure truly indicates a programmer error.
Runtime panics caused by the Go runtime itself — an out‑of‑bounds slice access, a nil pointer dereference, a failed type assertion — fall into the same category. They are the language’s way of saying “you wrote a bug here.” You can write your own panic calls to enforce your own invariants in the same spirit.
Do not panic on user input:
A common mistake is to call panic for an invalid value that came from outside the program: a malformed API request, a bad configuration file, or user‑typed text. These are expected failures. Use error returns so that callers can respond gracefully — for instance, by returning a 400 status code instead of crashing the entire server.
When to Return an Error
Every failure that is not a programmer mistake or a show‑stopping startup condition should be communicated through an error. This includes all interactions with the outside world: file I/O, network calls, database queries, and parsing of external data.
Consider a function that reads a configuration file:
// BAD: panics on a recoverable condition.
func ReadConfigBad(path string) *Config {
data, err := os.ReadFile(path)
if err != nil {
panic("failed to read config: " + err.Error())
}
// … parse and return config
}
// GOOD: returns the error so the caller can decide.
func ReadConfigGood(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
// … parse and return config
}
The first version makes the function unusable in any program that wants to handle a missing or unreadable file gracefully. The second version respects Go’s convention: the caller is in charge of deciding what level of severity a failure represents.
Panicking in a library is especially harmful:
When you write a library, you do not control the application that consumes it. A panic inside your library will terminate the user’s entire process unless they add explicit recover guards around every call. Public API functions must never let a panic escape. If you use panic internally for deep error unwinding (described below), always recover at the API boundary and return an error.
Keeping Panics Inside Package Boundaries
Some packages use panic internally as a control‑flow shortcut to unwind a deeply nested call stack and then convert that panic into an ordinary error at the public API surface. The encoding/json package in the standard library is a famous example: during marshaling and unmarshaling, deeply recursive functions may encounter an error. Rather than threading an error return value through dozens of function signatures, the package panics with a sentinel value, catches it at the top‑level export, and returns a proper error to the caller.
This technique is legitimate only when two conditions hold:
- The
panicis recovered in the same package, before any user code sees it. - The recovered value is converted into an
error— never silently swallowed.
package myparser
import "fmt"
// ParseExpression is the public entry point. It will never panic;
// all internal panics are recovered and returned as errors.
func ParseExpression(input string) (expr Expr, err error) {
defer func() {
if r := recover(); r != nil {
// Convert the panic back to an error.
err = fmt.Errorf("parse error: %v", r)
}
}()
expr = parseTopLevel(input)
return
}
// parseTopLevel and the functions it calls may panic instead
// of returning errors through every level of recursion.
func parseTopLevel(input string) Expr {
// … deeply nested parsing logic …
if somethingFatal {
panic("unexpected token at position …")
}
// …
}
Notice how the deferred function assigns to the named return value err. This ensures that a panic during parsing surfaces as a normal error, and the caller never needs to know that a panic was ever involved.
Why not just return errors everywhere?:
In a recursive descent parser with many levels of mutual recursion, threading an error through every call can make the code harder to read and maintain. The panic‑recover pattern trades a small amount of runtime overhead for significantly cleaner internal code. The key is that the trade‑off is invisible to any code outside the package.
The Role of recover — Catching Panics Safely
recover is the only way to stop a panic from crashing the entire program. It must be called directly inside a deferred function, and it returns nil if the goroutine is not currently panicking.
The most critical use of recover is to protect long‑running processes — especially servers — from crashing when a single request handler panics.
func worker(jobs <-chan Job) {
for job := range jobs {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("job %s panicked: %v\n%s", job.ID, r, debug.Stack())
// Optionally update metrics or notify a monitoring system.
}
}()
// The actual work; this may panic due to a bug.
processJob(job)
}()
}
}
An unhandled panic in any goroutine terminates the entire program, not just that goroutine. Wrapping each unit of work in a deferred recover isolates failures so that one bad job does not bring down the whole service.
Recovering does not fix the bug:
A recovered panic means the program continues running, but the state that led to the panic may still be inconsistent. After recovering, you should log the full stack trace and consider the operation failed. Do not silently swallow the panic — always record enough information to diagnose the root cause.
A recover that simply ignores the panic is one of the most dangerous anti‑patterns in Go. It hides the bug entirely, making the program appear healthy while it operates on potentially corrupted data.
Common Mistakes and Anti‑patterns
Several misuses of panic and recover show up repeatedly in Go code. Recognizing them prevents hard‑to‑diagnose failures.
- Using panic for normal control flow. A
panicis not a “super return” to break out of deeply nested loops. It is a signal that the program is in a state the developer did not intend. If you can anticipate the condition and there is a reasonable way to continue, return anerror. - Panicking on invalid user input. A malformed email address, a missing query parameter, or a badly formatted number are expected conditions. Handle them with validation and
errorreturns, not a crash. - Recovering without inspecting the panic value.
if r := recover(); r != nilfollowed by no logging or error propagation is a recipe for mysterious, silent failures. At minimum, log it. Better, convert it to anerrorand pass it upward. - Calling recover in the wrong layer.
recovermust be called directly by a deferred function. A helper function called from a deferred function will not stop the panic. The common idiom is an anonymous closure:defer func() { if r := recover(); r != nil { … } }(). - Leaving panics unprotected in goroutines. Because an unhandled panic kills the entire program, every
gostatement that launches a new goroutine must have arecoverat the top of its call stack unless you are certain the goroutine’s code can never panic. In practice, that certainty is rare.
Never recover from all panics indiscriminately:
Runtime panics like nil pointer dereferences or out‑of‑bounds access indicate bugs. While you can recover them, continuing as if nothing happened can lead to data corruption and unpredictable behavior. The safest action after catching such a panic is to log it, mark the affected operation as failed, and move on to the next independent unit of work — never to resume the panicking path.
Bringing It Together in a Server Context
A complete server startup and request‑handling example illustrates how these pieces fit together.
func main() {
// Unrecoverable startup condition: panic if critical config is missing.
port := os.Getenv("PORT")
if port == "" {
panic("PORT environment variable must be set")
}
http.HandleFunc("/", handleRequest)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
// Protect the handler from panics.
defer func() {
if rec := recover(); rec != nil {
log.Printf("handler panic: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
// Normal request processing; errors are returned, not panicked.
data, err := fetchData(r.Context())
if err != nil {
log.Printf("fetchData error: %v", err)
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
fmt.Fprintf(w, "Result: %v", data)
}
Every layer knows its job: main panics on an impossible‑to‑continue condition; handleRequest recovers from any unexpected bug while still returning a clean HTTP response; fetchData returns errors that the caller can handle.
Summary
The panic/recover mechanism in Go is not a general‑purpose exception system. It is a tool for two things: stopping the program when continuation is impossible or dangerous, and protecting package boundaries from internal implementation details.
The decision framework is straightforward:
- If the failure is a bug in the code, or the process cannot start without it →
panic. - If the failure is an expected outcome from outside the program → return
error. - If you are a package author → never let a
paniccross your public API. - If you launch a goroutine → wrap its entry point in a
recoverthat logs and cleans up.
Mastering this discipline unlocks the full power of defer, panic, and recover without the fragility that misusing them brings.