Go treats errors as values. A function returns an error when its caller can make a meaningful decision about failure. When cleanup must happen on every return path, Kelly’s guide to defer shows how to keep that cleanup beside the resource it protects.
Add context while preserving the cause
user, err := store.Find(ctx, id)
if err != nil {
return User{}, fmt.Errorf("find user %d: %w", id, err)
}
%w preserves the chain for errors.Is and errors.As. Avoid repeating context already obvious at the next layer.
Classify expected failures
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) {
// return a 404 or another domain-specific result
}
Sentinel errors work for a small stable category. Use a typed error when callers need structured details.
type ValidationError struct {
Field string
Issue string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Issue
}
Extract it with errors.As instead of a direct type assertion so wrapped errors still match.
Map errors at boundaries
Database errors should not leak into HTTP handlers. A repository maps driver-specific failures to domain errors; the handler maps domain errors to status codes and safe response bodies.
Log once, at the responsible layer
Returning and logging the same error at every layer produces duplicate noise. Add context while returning it, then log at the boundary that decides the operation has failed.
Panic is not ordinary error handling
Use panic for violated invariants or startup conditions the program cannot continue through. Invalid user input, unavailable dependencies, and missing records are ordinary errors.
Good errors answer: what operation failed, which stable category applies, and what the caller can do next, without exposing secrets or internal implementation details.