Error wrapping conventions I settled on
Go’s error handling has been the subject of approximately ten thousand blog posts, most of them full of handwringing. I’ve written my share. But at this point my team and I have converged on a style that works, it’s been stable for about a year, and I want to write down the rules so we stop re-litigating them in code review.
Rule 1: Wrap with %w when you’re crossing a layer boundary, not otherwise.
func (s *Service) CreateOrder(ctx context.Context, o *Order) error {
if err := s.db.Insert(ctx, o); err != nil {
return fmt.Errorf("service: insert order: %w", err)
}
return nil
}
The wrap adds context (“this happened while trying to insert an order in the service layer”) and preserves the original error for errors.Is and errors.As checking. Don’t wrap if you’re just re-returning within the same package:
// unnecessary
func (s *Service) createOrderInternal(ctx context.Context, o *Order) error {
if err := s.validateOrder(o); err != nil {
return fmt.Errorf("create internal: %w", err) // no new info!
}
}
If the wrap doesn’t add information, skip it. return err is fine.
Rule 2: Use sentinels for expected errors, use typed errors for structured data.
// Sentinel - use when "yes" or "no" is all callers need
var ErrNotFound = errors.New("not found")
func (s *Store) Get(k string) (Value, error) {
// ...
return Value{}, ErrNotFound
}
// caller:
if errors.Is(err, store.ErrNotFound) {
// ...
}
// Typed - use when callers need the error details
type ValidationError struct {
Field string
Reason string
}
func (v *ValidationError) Error() string { return v.Field + ": " + v.Reason }
func (s *Service) validateOrder(o *Order) error {
if o.Amount <= 0 {
return &ValidationError{Field: "amount", Reason: "must be positive"}
}
}
// caller:
var v *ValidationError
if errors.As(err, &v) {
// v.Field is available
}
Don’t create typed errors that only have an Error() string method — those should be sentinels.
Rule 3: errors.Is and errors.As are your friends. Type assertions on errors are not.
// old style, bad
if rnf, ok := err.(*store.ErrNotFound); ok { ... }
// new style, good
if errors.Is(err, store.ErrNotFound) { ... }
The difference: errors.Is walks the wrapped chain. If your error is wrapped, the type assertion at the outer layer won’t find the inner typed error, but errors.Is will. Same for errors.As vs direct casts.
Rule 4: Log the error once, at the edge, with context.
In a service, that’s usually in the HTTP handler or RPC handler. Everything below that just wraps and returns. The handler logs:
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
if err := h.svc.CreateOrder(r.Context(), order); err != nil {
slog.Error("create order failed",
"err", err,
"user_id", userID,
"order_id", order.ID)
http.Error(w, "internal error", 500)
return
}
}
Logging every layer re-logs the same error N times. Logging only at the edge keeps the log tidy.
Rule 5: No panics for expected errors. Panic is for impossible states.
// BAD
func GetConfig() *Config {
if cfg == nil {
panic("config not loaded")
}
return cfg
}
// GOOD
func GetConfig() (*Config, error) {
if cfg == nil {
return nil, errors.New("config not loaded")
}
return cfg, nil
}
The only panics I write are for programming errors — “this index should always be in range because I already bounds-checked.”
Rule 6: When in doubt, use errors.Join to return multiple errors.
Go 1.20 added errors.Join(errs...), which wraps multiple errors into one. Useful in batch operations:
func (s *Service) DeleteAll(ids []string) error {
var errs []error
for _, id := range ids {
if err := s.Delete(id); err != nil {
errs = append(errs, fmt.Errorf("id=%s: %w", id, err))
}
}
return errors.Join(errs...)
}
errors.Is and errors.As both work against joined errors, walking all branches.
Rule 7: No stack traces in production error logs.
This is controversial but I’ll defend it. A Go error with good wrapping (“service: insert order: db: deadlock detected”) tells you what happened. A stack trace tells you where. The where is rarely useful — if you’ve got the wrap chain, you know which code path ran. Stack traces are huge in logs and expensive to capture. I use them in dev/test (via errors.WithStack from pkg/errors or similar) but strip them in production.
Rule 8: Define sentinel errors close to their producers.
// store/store.go
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
// service/service.go
var (
ErrValidation = errors.New("validation failed")
)
Don’t cram all errors into a single errors.go package. Keep them near the code that returns them, so call sites don’t have to import a whole separate package just to check errors.
Rule 9: For user-facing APIs, map internal errors to stable types.
Your HTTP API shouldn’t leak “db: deadlock detected” to clients. Have a mapping layer:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
http.Error(w, "not found", 404)
case errors.As(err, new(*service.ValidationError)):
http.Error(w, err.Error(), 400)
default:
slog.Error("unhandled error", "err", err)
http.Error(w, "internal server error", 500)
}
}
That mapping is the boundary where errors become responses. It’s also where you hide details you don’t want to leak.
These rules are not revolutionary. They’re boringly sensible, which is the point. Error handling is one of those areas where being boringly sensible beats being clever every time.
More on one specific anti-pattern in my post on context.Context — shoving errors into context values is not the move.