Go Error Handling: Patterns That Actually Work for Me
Go's error handling is one of those topics where you can spend a lot of time reading heated opinions online and come away with no clear guidance. After writing three production services in Go over about three years, here's what I actually do, without the ideological baggage.
Wrap errors at package boundaries, not everywhere
Early on I wrapped every error with context: fmt.Errorf("getUserByID: %w", err), fmt.Errorf("db.Query: %w", err), fmt.Errorf("rows.Scan: %w", err). The error messages became long chains that were hard to read and rarely added useful information.
Now I wrap at package boundaries — when an error crosses from one layer of my application to another. A database error crossing into the service layer gets wrapped with what the service was trying to do. An error from an external HTTP call gets wrapped with the operation name. Errors within a package, passed between private functions, usually don't need wrapping because the call stack from the log output already tells me where they came from.
// at the repository layer → service layer boundary
user, err := r.db.getUserByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("UserRepository.Get id=%d: %w", id, err)
}
Sentinel errors for things callers need to act on
I define sentinel errors (var ErrNotFound = errors.New("not found")) only when I expect callers to check for them specifically and do something different. Not every error needs a sentinel — most errors at the service layer just need to be logged and returned as a 500. The ones that need sentinels are things like "resource not found" (→ 404), "conflict" (→ 409), or "unauthorized" (→ 401).
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
// in the HTTP handler
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
I don't define custom error types with fields unless I need to return structured data to the client. errors.New is almost always enough.
Log once, at the top
The pattern I see most often in code I inherit: errors logged at every layer as they bubble up. You get five log lines for a single error. The useful information is usually in the first one; the rest are noise.
I log errors exactly once, at the outermost point that handles them — usually the HTTP handler or the job runner. Everything below that just wraps and returns. The wrapped error chain carries the context I need when I read the log line.
Don't ignore errors, but know which ones to silence
defer rows.Close() returns an error that most code ignores. So does defer resp.Body.Close(). I silence these deliberately with a comment when the error genuinely doesn't matter in context, rather than using _ silently. It documents intent:
defer func() {
if err := rows.Close(); err != nil {
// rows.Close error is non-actionable after iteration completes
_ = err
}
}()
Pedantic, but useful when reading code six months later.
The thing I stopped arguing about
if err != nil { return err } is repetitive and that's fine. The proposals to reduce this verbosity (try built-in, various generics approaches) solve a real aesthetic problem and create a readability tradeoff I don't want to make. Error handling in Go is explicit in a way that makes code easier to follow, even if it's more text to read. I write the boilerplate and move on.