- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Go codebase you inherited. You grep for recover(.
Twenty-three hits. Most of them look like this:
func doWork(job Job) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered: %v", r)
}
}()
process(job)
}
Somebody wrapped a function in a blanket recover so a bad job
wouldn't take down the process. It reads like defensive engineering.
It is the opposite. That recover swallows nil-pointer
dereferences, out-of-range index bugs, and every logic error that
should have crashed loudly in a test. The process keeps running with
a corrupted goroutine, and the bug ships.
Go gives you panic and recover on purpose, but the language
design pushes you hard toward errors as values. panic is for the
situations where returning an error is either impossible or
dishonest. There are three of those. Everywhere else, a recover
is hiding a bug you'd rather find.
The default position: panic means the program is broken
Start from what the Go team actually says. The standard library
panics for programmer errors: index out of range, nil map writes,
type assertions that fail without the comma-ok form, integer divide
by zero. These are not conditions you handle. They are proof that
an invariant you assumed is false.
An error return says "this can fail, here is how." A panic says
"this should never happen, and it did, so stop." When you recover
a panic and continue as if nothing happened, you erase that
distinction. The goroutine that panicked was executing with state
you no longer understand. Maybe a lock is still held. Maybe a slice
is half-written. Continuing is a bet that none of that matters, and
you rarely get to see the losing side of that bet until much later.
So the bar is high. A recover earns its place only when it does
one of three specific jobs.
1. Package-boundary recover: don't let one request crash the process
This is the one legitimate "stop a panic from killing everything"
use, and it has strict conditions. You are at a trust boundary
where you run code you don't fully control, on behalf of many
independent callers, and one caller's panic must not take down the
others.
The standard library does exactly this. net/http wraps each
request handler so a panic in one handler returns a 500 and logs
the stack, instead of killing the whole server:
// paraphrased from net/http's serve loop
func (c *conn) serve() {
defer func() {
if err := recover(); err != nil &&
err != http.ErrAbortHandler {
buf := debug.Stack()
log.Printf("http: panic: %v\n%s", err, buf)
}
c.close()
}()
// ... read request, call handler ...
}
The conditions that make this correct:
- The recovered unit is isolated. One HTTP request shares no mutable state with the next. Killing the goroutine loses that request only.
- You re-report, loudly. The stack goes to logs, a metric increments, the caller gets a 500. You are not hiding the panic, you are converting it into a bounded failure.
- You release resources in the same deferred block. The connection closes. No half-held state survives.
The same pattern is correct for a worker pool draining a queue, or
a gRPC interceptor. Here's a job worker done right:
func (w *Worker) runOne(job Job) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf(
"panic in job %s: %v\n%s",
job.ID, r, debug.Stack(),
)
}
}()
return w.handle(job)
}
Note the named return err. The deferred closure assigns to it, so
the caller sees a normal error and decides what to do: retry,
dead-letter, alert. The panic became a value at the boundary, and
the goroutine that carried the broken state is gone.
What makes the bad example from the intro bad is that it violated
every condition: it didn't re-report beyond a log line, it didn't
isolate anything, and it sat at a function boundary with no trust
gap. It caught panics from its own code, which is where you want
the crash.
2. Truly unrecoverable state: panic when continuing is a lie
The second case is the inverse. Sometimes you are deep in code where
returning an error is technically possible but continuing is not
honest, because the invariant that failed means the whole program
is in a state you can't reason about.
Package initialization is the clearest example. If a required
config value is missing at startup, or a regexp that must compile
doesn't, there is no sensible degraded mode. The standard library
ships regexp.MustCompile for exactly this:
var slugPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
MustCompile panics if the pattern is invalid. That is correct.
The pattern is a compile-time constant in practice. A bad one is a
bug you want to discover the instant the program starts, not an
error you thread through every caller of a function that can never
actually fail in production.
The convention has a name. Functions prefixed Must panic instead
of returning an error, and they are meant for package-level
variables and init, where the only alternatives are "start
correctly" and "refuse to start." You can write your own:
func mustEnv(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
panic(fmt.Sprintf("missing required env %q", key))
}
return v
}
var dbURL = mustEnv("DATABASE_URL")
If DATABASE_URL is absent, the process refuses to start with a
clear message. There is no goroutine to keep alive, no request to
degrade. Crashing at startup is the friendliest failure you can
give an operator, because it happens before the service claims to
be healthy.
The rule for this case: panic when the failure means no correct
continuation exists, and the failure is a programmer or deployment
error, not a runtime condition a user can trigger. A malformed HTTP
body is a runtime condition. Return an error. A malformed regexp
you hardcoded is your bug. Panic.
3. Parser-style unwinding: panic across your own deep call stack
The third case is the one people forget, and it's the most
interesting. Inside a single package, you can use panic and recover
as a controlled non-local jump to escape a deep recursion, as long
as the panic never crosses the package boundary.
A recursive-descent parser is the textbook example. When you're
forty frames deep parsing nested expressions and you hit a syntax
error, threading an error return through every recursive call is
noise. The standard library's own encoding/json and text/template
packages use this technique internally. Here's the shape:
type parseError struct{ msg string }
func (p *parser) fail(msg string) {
panic(parseError{msg})
}
func (p *parser) parseExpr() node {
// deep recursion; any level can call p.fail(...)
if p.tok != tokLParen {
p.fail("expected (")
}
// ...
return n
}
Every level of the parser calls p.fail and panics. The recursion
unwinds instantly. Then, at the one public entry point, you recover
and turn the panic back into an ordinary error:
func Parse(src []byte) (n node, err error) {
p := &parser{input: src}
defer func() {
if r := recover(); r != nil {
pe, ok := r.(parseError)
if !ok {
panic(r) // not ours: re-panic
}
err = fmt.Errorf("parse: %s", pe.msg)
}
}()
return p.parseExpr(), nil
}
Two details make this safe rather than reckless:
- The panic value is a private type (
parseError). The recover checks the type, and if the panic is something else (a real nil-pointer bug in your parser), it re-panics. You catch only what you threw. You never swallow a genuine runtime panic. - The panic never escapes the package. Callers of
Parsesee a boringerror. They have no idea a panic happened. The technique is an implementation detail, not part of the API contract.
Go's own maintainers describe this as acceptable precisely because
it's local and typed. The moment your recovered panic value could
be anything, or the panic can leak to a caller, you've left the
safe zone and you're back to hiding bugs.
The anti-pattern that ties them together
Every misuse of recover shares one property: it catches panics it
didn't throw, at a boundary that isn't a trust boundary, and then
continues instead of re-reporting. The blanket defer recover()
around business logic is the canonical version. It turns a loud
crash in a test into a silent corruption in production.
The three correct uses invert that property:
- Package boundary: catches at a real isolation boundary, re-reports as a bounded failure, releases resources.
- Unrecoverable state: never recovers at all — it panics on purpose because no correct continuation exists.
- Parser unwinding: catches only its own private panic type, re-panics anything else, never leaks past the package.
If a recover you're about to write doesn't fit one of those, it's
almost certainly catching a bug that a crash would have shown you
for free. Delete it. Let the panic reach a test, or the one
package-boundary handler that logs the stack and moves on.
What to grep for on Monday
Open your codebase and search for recover(. For each hit, ask
three questions:
- Is this at a genuine isolation boundary — one request, one job, one plugin — where a panic must not kill unrelated work? If yes, confirm it re-reports (log + metric + stack) and releases resources. If no, it's probably swallowing bugs.
- Does it check the recovered value's type and re-panic anything it
didn't throw? A bare
if r := recover(); r != nilthat continues for anyris the tell. - Could the code it wraps just return an error instead? If the
failure is a runtime condition a user can trigger, it should be
an
error, not a recovered panic.
Then search for panic(. Most of yours should be Must-style
startup guards and "impossible" default branches in switches. Any
panic on a path a user's input can reach is a latent 500 waiting
to happen — or a crash, if nothing recovers it.
panic and recover are not banned. They are sharp. Three cuts
are clean. The rest draw blood.
Getting this boundary right is really a question of where a failure
is allowed to stop the program, and Go's design has strong opinions
about that. The Complete Guide to Go Programming digs into the
runtime mechanics — how panic unwinds the stack, how deferred calls
run, and why the standard library panics where it does. Hexagonal
Architecture in Go is the companion for keeping these decisions at
the right layer, so the one place you recover is a real boundary and
not a function that happened to look risky.

Top comments (0)