Go Error Handling Patterns That Scale
Practical error handling in Go beyond the basics of if err != nil.
Key Insights
- Use sentinel errors for expected failure cases and wrap errors with fmt.Errorf %w for debugging context
- Custom error types carry structured information that enables precise handling at system boundaries
- Map domain errors to HTTP status codes at the handler layer — keep business logic unaware of transport concerns
Go’s explicit error handling gets criticized, but it forces you to think about failure modes. Here are patterns that work well in larger codebases.
Sentinel Errors for Expected Cases
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*User, error) {
// ...
if rows == 0 {
return nil, ErrNotFound
}
}
Wrapping for Context
func (s *OrderService) Process(ctx context.Context, id string) error {
order, err := s.repo.Get(ctx, id)
if err != nil {
return fmt.Errorf("get order %s: %w", id, err)
}
// ...
}
Error Types for Rich Information
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s %s", e.Field, e.Message)
}
The Handler Pattern
At your HTTP boundary, map domain errors to status codes:
func handleError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", 404)
case errors.As(err, &ValidationError{}):
http.Error(w, err.Error(), 400)
default:
http.Error(w, "internal error", 500)
}
}
The key: errors are values. Treat them as part of your API contract.