Error Handling Strategies Beyond if err != nil
Go's error handling gets criticized as verbose, but the real problem isn't if err != nil—it's that most codebases handle errors without any strategy. Errors get swallowed, wrapped inconsistently, or logged multiple times. When something breaks in production, you're left guessing.
Here's how to build error handling that actually helps you debug.
The Baseline Problem
Typical error handling in the wild:
func ProcessOrder(ctx context.Context, orderID string) error {
order, err := db.GetOrder(ctx, orderID)
if err != nil {
log.Printf("failed to get order: %v", err)
return err
}
if err := validateOrder(order); err != nil {
log.Printf("validation failed: %v", err)
return err
}
if err := chargePayment(ctx, order); err != nil {
log.Printf("payment failed: %v", err)
return err
}
return nil
}
Problems:
- Error logged at every level (log spam)
- Context lost (which order? what user?)
- No way to distinguish "order not found" from "database down"
- Caller can't make decisions based on error type
Strategy 1: Wrap Once, Log Once
Errors should be wrapped with context where they happen, then logged once at the top level.
// WRONG: Log at every level
func getUser(ctx context.Context, id string) (*User, error) {
user, err := db.Query(ctx, id)
if err != nil {
log.Printf("db query failed: %v", err) // Logged here
return nil, err
}
return user, nil
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r.Context(), userID)
if err != nil {
log.Printf("getUser failed: %v", err) // And here (duplicate)
http.Error(w, "error", 500)
return
}
}
// RIGHT: Wrap at each level, log at the top
func getUser(ctx context.Context, id string) (*User, error) {
user, err := db.Query(ctx, id)
if err != nil {
return nil, fmt.Errorf("query user %s: %w", id, err)
}
return user, nil
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r.Context(), userID)
if err != nil {
log.Printf("handleRequest failed: %v", err) // Log once with full context
http.Error(w, "error", 500)
return
}
}
// Log output: "handleRequest failed: query user abc123: connection refused"
Strategy 2: Sentinel Errors vs. Behavior Checking
Sentinel Errors (When to Use)
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
func GetUser(ctx context.Context, id string) (*User, error) {
user, err := db.Query(ctx, id)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
// Caller can check
user, err := GetUser(ctx, id)
if errors.Is(err, ErrNotFound) {
// Handle not found (maybe return 404)
}
Behavior Checking (Often Better)
Sentinel errors create coupling. Behavior checking is more flexible:
// Define behavior interface
type NotFoundError interface {
NotFound() bool
}
type userNotFoundError struct {
userID string
}
func (e *userNotFoundError) Error() string {
return fmt.Sprintf("user %s not found", e.userID)
}
func (e *userNotFoundError) NotFound() bool {
return true
}
// Check behavior, not type
func IsNotFound(err error) bool {
var nf NotFoundError
return errors.As(err, &nf) && nf.NotFound()
}
// Usage
if IsNotFound(err) {
w.WriteHeader(http.StatusNotFound)
return
}
Why this is better:
- Different packages can return their own "not found" errors
- No import dependency on error definitions
- Works across package boundaries
Strategy 3: Structured Errors
For complex systems, errors need structure:
type AppError struct {
Code string // Machine-readable code
Message string // Human-readable message
Op string // Operation that failed
Err error // Underlying error
Meta map[string]string // Additional context
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Op, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Constructor helpers
func NewAppError(op, code, message string) *AppError {
return &AppError{Op: op, Code: code, Message: message, Meta: make(map[string]string)}
}
func (e *AppError) WithError(err error) *AppError {
e.Err = err
return e
}
func (e *AppError) WithMeta(key, value string) *AppError {
e.Meta[key] = value
return e
}
// Usage
func ProcessPayment(ctx context.Context, orderID string, amount int) error {
result, err := paymentGateway.Charge(ctx, amount)
if err != nil {
return NewAppError("ProcessPayment", "PAYMENT_FAILED", "charge failed").
WithError(err).
WithMeta("order_id", orderID).
WithMeta("amount", strconv.Itoa(amount))
}
return nil
}
Extracting Structured Data
func handleError(w http.ResponseWriter, err error) {
var appErr *AppError
if errors.As(err, &appErr) {
// Structured error - can extract details
log.Printf("op=%s code=%s meta=%v err=%v",
appErr.Op, appErr.Code, appErr.Meta, appErr.Err)
status := mapCodeToHTTPStatus(appErr.Code)
writeJSONError(w, status, appErr.Code, appErr.Message)
return
}
// Unknown error - log and return generic message
log.Printf("unhandled error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
func mapCodeToHTTPStatus(code string) int {
switch code {
case "NOT_FOUND":
return http.StatusNotFound
case "UNAUTHORIZED":
return http.StatusUnauthorized
case "VALIDATION_FAILED":
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
Strategy 4: Error Wrapping Done Right
The %w Verb
Go 1.13+ introduced %w for error wrapping:
// Creates a chain that errors.Is and errors.As can traverse
if err != nil {
return fmt.Errorf("process order %s: %w", orderID, err)
}
When NOT to Wrap
Sometimes you want to hide implementation details:
// WRONG: Leaks internal error types
func GetUser(id string) (*User, error) {
user, err := redis.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("get user: %w", err) // Exposes redis errors
}
return user, nil
}
// Caller can now do errors.Is(err, redis.ErrNil) - tight coupling!
// RIGHT: Translate to domain errors
func GetUser(id string) (*User, error) {
user, err := redis.Get(ctx, key)
if errors.Is(err, redis.ErrNil) {
return nil, ErrUserNotFound // Domain error
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}
Error Chain Inspection
func analyzeError(err error) {
// errors.Is - checks if any error in chain matches
if errors.Is(err, sql.ErrNoRows) {
// Handle not found
}
// errors.As - extracts typed error from chain
var appErr *AppError
if errors.As(err, &appErr) {
// Can access appErr.Code, appErr.Meta, etc.
}
// Unwrap - gets the next error in chain
inner := errors.Unwrap(err)
}
Strategy 5: Domain-Specific Error Types
Group errors by domain:
// errors/payment.go
package errors
type PaymentError struct {
Code string
Message string
TransactionID string
Retryable bool
Err error
}
func (e *PaymentError) Error() string {
return fmt.Sprintf("payment error [%s]: %s", e.Code, e.Message)
}
func (e *PaymentError) Unwrap() error {
return e.Err
}
// Behavior methods
func (e *PaymentError) IsRetryable() bool {
return e.Retryable
}
// Common payment errors
func PaymentDeclined(txnID, reason string) *PaymentError {
return &PaymentError{
Code: "DECLINED",
Message: reason,
TransactionID: txnID,
Retryable: false,
}
}
func PaymentTimeout(txnID string, err error) *PaymentError {
return &PaymentError{
Code: "TIMEOUT",
Message: "payment gateway timeout",
TransactionID: txnID,
Retryable: true,
Err: err,
}
}
Usage:
func processPayment(ctx context.Context, order Order) error {
result, err := gateway.Charge(ctx, order.Amount)
if err != nil {
if isTimeout(err) {
return errors.PaymentTimeout(order.ID, err)
}
return fmt.Errorf("charge failed: %w", err)
}
if !result.Approved {
return errors.PaymentDeclined(result.TransactionID, result.Reason)
}
return nil
}
// Caller can make smart decisions
err := processPayment(ctx, order)
if err != nil {
var payErr *errors.PaymentError
if errors.As(err, &payErr) && payErr.IsRetryable() {
return retryWithBackoff(ctx, func() error {
return processPayment(ctx, order)
})
}
return err
}
Strategy 6: Error Aggregation
Sometimes you need to collect multiple errors:
type MultiError struct {
errors []error
}
func (m *MultiError) Add(err error) {
if err != nil {
m.errors = append(m.errors, err)
}
}
func (m *MultiError) Error() string {
if len(m.errors) == 0 {
return ""
}
if len(m.errors) == 1 {
return m.errors[0].Error()
}
var b strings.Builder
fmt.Fprintf(&b, "%d errors occurred:\n", len(m.errors))
for i, err := range m.errors {
fmt.Fprintf(&b, " %d: %v\n", i+1, err)
}
return b.String()
}
func (m *MultiError) ErrorOrNil() error {
if len(m.errors) == 0 {
return nil
}
return m
}
// Go 1.20+ has errors.Join for this
func validateOrder(order Order) error {
var errs []error
if order.CustomerID == "" {
errs = append(errs, fmt.Errorf("customer ID required"))
}
if order.Amount <= 0 {
errs = append(errs, fmt.Errorf("amount must be positive"))
}
if len(order.Items) == 0 {
errs = append(errs, fmt.Errorf("order must have items"))
}
return errors.Join(errs...) // nil if errs is empty
}
Production Checklist
-
Wrap errors with context using
fmt.Errorf("operation: %w", err) -
Log once at the top level, not at every layer
-
Use sentinel errors sparingly. Behavior checking is often more flexible.
-
Don't expose implementation details through error wrapping unless intentional
-
Make errors actionable. Include enough context to debug without requiring code access.
-
Consider error types for complex domains. Structured errors enable smart handling.
-
Test error paths. Your tests should verify error messages and types.
Key Takeaways
-
Error handling is not just
if err != nil. It's about building a strategy that helps you debug production issues. -
Wrap once, log once. Duplicate logging wastes time and obscures the real problem.
-
Use
%wfor wrapping, but know when NOT to wrap (hiding implementation details). -
Behavior checking > type checking.
errors.Aswith interfaces is more flexible than sentinel errors. -
Structure enables automation. Structured errors can drive HTTP status codes, metrics, and alerting.
-
Domain errors clarify intent.
PaymentDeclinedis clearer thanerrors.New("payment failed").
The goal isn't perfect error handling—it's error handling that helps you figure out what went wrong at 3 AM when production is down.