A pragmatic take on error handling in Go
People love to complain about if err != nil. I used to be one of them.
After a few years of shipping Go services, I've made my peace with it — and I've come
to think the explicitness is a feature, not a tax. Here's the small set of habits that
made the difference for me.
Wrap with context, once
An error that says connection refused is useless three layers up. An
error that says fetch user 42: dial tcp: connection refused tells you
exactly where to look. The %w verb makes this cheap:
if err != nil {
return fmt.Errorf("fetch user %d: %w", id, err)
}
The rule I follow: add context when crossing a meaningful boundary, not at every return. Wrapping the same error five times just gives you a noisy sentence.
Check kinds with errors.Is and errors.As
Because %w preserves the chain, the caller can still ask precise
questions without string matching:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
This is the part that pays off the wrapping discipline. Sentinel errors and typed errors stay reachable no matter how many layers added context on the way up.
Decide who logs
The most common mess I see is the same error logged at every level. Pick one place — usually the outermost handler — to log, and let everything below it simply return. Your logs go from a wall of duplicates to one clear line per failure.
Don't be afraid to panic at the edges
Returning an error is right for anything a caller might reasonably handle. But a programming mistake — a nil map you should have made, an invariant you violated — is not a runtime condition to recover from. A panic during startup that stops the process is better than a service that limps along in an impossible state.
The shape that works
Wrap with context at boundaries, inspect with errors.Is/As,
log once at the top, and reserve panics for genuine bugs. None of it is clever, and
that's the point. After a while the if err != nil blocks stop reading like
boilerplate and start reading like the actual control flow — which is what they are.