Back to Blog
Go Backend

Advanced Error Handling Strategies in Go

Wang Yinneng
11 min read
golangerror-handlingbest-practices

Advanced Error Handling Strategies in Go

Error handling in Go isn't just about if err != nil - here's how to do it right.

🎯 The Problem with Basic Error Handling

Most Go developers start with this pattern:

// ❌ Basic but limited error handling
func processUser(id string) error {
    user, err := getUserFromDB(id)
    if err != nil {
        return err
    }
    
    err = validateUser(user)
    if err != nil {
        return err
    }
    
    err = sendWelcomeEmail(user)
    if err != nil {
        return err
    }
    
    return nil
}

Problems:

  • ❌ Lost context about where error occurred
  • ❌ No distinction between error types
  • ❌ Difficult to handle errors differently
  • ❌ Poor debugging experience

✅ Advanced Error Handling Patterns

1. Custom Error Types

Create meaningful error types that carry context:

// Define error types for different categories
type ValidationError struct {
    Field string
    Value interface{}
    Rule  string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("validation failed: field '%s' with value '%v' violates rule '%s'", 
        e.Field, e.Value, e.Rule)
}

type DatabaseError struct {
    Operation string
    Table     string
    Cause     error
}

func (e DatabaseError) Error() string {
    return fmt.Sprintf("database error during %s on table %s: %v", 
        e.Operation, e.Table, e.Cause)
}

func (e DatabaseError) Unwrap() error {
    return e.Cause
}

type ExternalServiceError struct {
    Service    string
    StatusCode int
    Message    string
}

func (e ExternalServiceError) Error() string {
    return fmt.Sprintf("external service '%s' failed (HTTP %d): %s", 
        e.Service, e.StatusCode, e.Message)
}

// Check if error is retryable
func (e ExternalServiceError) IsRetryable() bool {
    return e.StatusCode >= 500 || e.StatusCode == 429
}

2. Error Wrapping with Context

Use fmt.Errorf with %w verb for error chains:

func processUser(id string) error {
    user, err := getUserFromDB(id)
    if err != nil {
        return fmt.Errorf("failed to process user %s: %w", id, err)
    }
    
    if err := validateUser(user); err != nil {
        return fmt.Errorf("user validation failed for %s: %w", id, err)
    }
    
    if err := sendWelcomeEmail(user); err != nil {
        return fmt.Errorf("failed to send welcome email to %s: %w", user.Email, err)
    }
    
    return nil
}

func getUserFromDB(id string) error {
    // Simulate database error
    if rand.Float32() < 0.1 {
        return DatabaseError{
            Operation: "SELECT",
            Table:     "users",
            Cause:     errors.New("connection timeout"),
        }
    }
    return nil
}

3. Sentinel Errors for Known Conditions

Define package-level errors for expected conditions:

package userservice

import "errors"

// Sentinel errors
var (
    ErrUserNotFound     = errors.New("user not found")
    ErrInvalidEmail     = errors.New("invalid email format")
    ErrEmailAlreadyUsed = errors.New("email already in use")
    ErrAccountLocked    = errors.New("account is locked")
    ErrInsufficientFunds = errors.New("insufficient funds")
)

func GetUser(id string) (*User, error) {
    user, err := db.QueryUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("database query failed: %w", err)
    }
    return user, nil
}

// Usage with errors.Is()
func handleGetUser(id string) {
    user, err := GetUser(id)
    if err != nil {
        switch {
        case errors.Is(err, ErrUserNotFound):
            log.Printf("User %s not found, creating new record", id)
            // Handle not found case
        case errors.Is(err, ErrAccountLocked):
            log.Printf("User %s account is locked", id)
            // Handle locked account
        default:
            log.Printf("Unexpected error: %v", err)
            // Handle other errors
        }
        return
    }
    // Process user...
}

4. Error Interfaces for Behavior

Define interfaces to classify error behavior:

// Retryable interface
type Retryable interface {
    IsRetryable() bool
}

// Temporary interface (from net package)
type Temporary interface {
    Temporary() bool
}

// UserFacing interface for client errors
type UserFacing interface {
    UserMessage() string
}

// Implement interfaces on custom errors
type PaymentError struct {
    Code    string
    Message string
    Retry   bool
}

func (e PaymentError) Error() string {
    return fmt.Sprintf("payment failed [%s]: %s", e.Code, e.Message)
}

func (e PaymentError) IsRetryable() bool {
    return e.Retry
}

func (e PaymentError) UserMessage() string {
    switch e.Code {
    case "INSUFFICIENT_FUNDS":
        return "You don't have enough funds for this transaction"
    case "CARD_DECLINED":
        return "Your card was declined. Please try a different payment method"
    default:
        return "Payment processing failed. Please try again later"
    }
}

// Smart retry logic
func processPaymentWithRetry(amount int) error {
    var lastErr error
    
    for attempt := 1; attempt <= 3; attempt++ {
        err := processPayment(amount)
        if err == nil {
            return nil
        }
        
        lastErr = err
        
        // Check if error is retryable
        var retryable Retryable
        if errors.As(err, &retryable) && retryable.IsRetryable() {
            log.Printf("Payment attempt %d failed (retryable): %v", attempt, err)
            time.Sleep(time.Duration(attempt) * time.Second)
            continue
        }
        
        // Non-retryable error
        return err
    }
    
    return fmt.Errorf("payment failed after 3 attempts: %w", lastErr)
}

5. Result Type Pattern (Go 1.18+)

For functions that commonly fail, consider a Result type:

// Generic Result type
type Result[T any] struct {
    Value T
    Error error
}

func (r Result[T]) IsOK() bool {
    return r.Error == nil
}

func (r Result[T]) Unwrap() (T, error) {
    return r.Value, r.Error
}

func Ok[T any](value T) Result[T] {
    return Result[T]{Value: value}
}

func Err[T any](err error) Result[T] {
    var zero T
    return Result[T]{Value: zero, Error: err}
}

// Usage
func GetUserResult(id string) Result[*User] {
    user, err := getUserFromDB(id)
    if err != nil {
        return Err[*User](err)
    }
    return Ok(user)
}

// Chain operations
func ProcessUserChain(id string) Result[string] {
    return GetUserResult(id).
        AndThen(validateUserResult).
        AndThen(sendEmailResult).
        Map(func(user *User) string {
            return fmt.Sprintf("Processed user: %s", user.Email)
        })
}

// Result methods
func (r Result[T]) AndThen(f func(T) Result[U]) Result[U] {
    if r.Error != nil {
        return Err[U](r.Error)
    }
    return f(r.Value)
}

func (r Result[T]) Map(f func(T) U) Result[U] {
    if r.Error != nil {
        return Err[U](r.Error)
    }
    return Ok(f(r.Value))
}

6. Structured Error Context

Add structured context to errors for better observability:

type ErrorContext struct {
    Operation string
    UserID    string
    RequestID string
    Component string
    Metadata  map[string]interface{}
}

type ContextualError struct {
    Context ErrorContext
    Cause   error
}

func (e ContextualError) Error() string {
    return fmt.Sprintf("[%s:%s] %s failed for user %s: %v", 
        e.Context.Component, e.Context.RequestID, e.Context.Operation, e.Context.UserID, e.Cause)
}

func (e ContextualError) Unwrap() error {
    return e.Cause
}

// Helper function to wrap errors with context
func WithContext(ctx ErrorContext, err error) error {
    if err == nil {
        return nil
    }
    return ContextualError{Context: ctx, Cause: err}
}

// Usage
func processOrderWithContext(orderID, userID, requestID string) error {
    ctx := ErrorContext{
        Operation: "process_order",
        UserID:    userID,
        RequestID: requestID,
        Component: "order-service",
        Metadata: map[string]interface{}{
            "order_id": orderID,
        },
    }
    
    order, err := getOrder(orderID)
    if err != nil {
        return WithContext(ctx, fmt.Errorf("failed to fetch order: %w", err))
    }
    
    err = validateOrder(order)
    if err != nil {
        ctx.Metadata["validation_step"] = "order_validation"
        return WithContext(ctx, fmt.Errorf("order validation failed: %w", err))
    }
    
    err = chargePayment(order.PaymentMethod, order.Total)
    if err != nil {
        ctx.Metadata["payment_amount"] = order.Total
        return WithContext(ctx, fmt.Errorf("payment processing failed: %w", err))
    }
    
    return nil
}

7. Error Aggregation

Handle multiple errors elegantly:

type MultiError struct {
    Errors []error
}

func (me MultiError) Error() string {
    if len(me.Errors) == 0 {
        return "no errors"
    }
    if len(me.Errors) == 1 {
        return me.Errors[0].Error()
    }
    
    var msgs []string
    for _, err := range me.Errors {
        msgs = append(msgs, err.Error())
    }
    return fmt.Sprintf("multiple errors: %s", strings.Join(msgs, "; "))
}

func (me *MultiError) Add(err error) {
    if err != nil {
        me.Errors = append(me.Errors, err)
    }
}

func (me MultiError) HasErrors() bool {
    return len(me.Errors) > 0
}

// Usage: Validate multiple fields
func validateUser(user *User) error {
    var errors MultiError
    
    if user.Email == "" {
        errors.Add(ValidationError{Field: "email", Value: user.Email, Rule: "required"})
    } else if !isValidEmail(user.Email) {
        errors.Add(ValidationError{Field: "email", Value: user.Email, Rule: "format"})
    }
    
    if user.Age < 0 {
        errors.Add(ValidationError{Field: "age", Value: user.Age, Rule: "positive"})
    }
    
    if len(user.Name) < 2 {
        errors.Add(ValidationError{Field: "name", Value: user.Name, Rule: "min_length"})
    }
    
    if errors.HasErrors() {
        return errors
    }
    return nil
}

8. Error Recovery Patterns

Implement graceful degradation:

// Circuit breaker pattern for error recovery
type CircuitBreaker struct {
    failures    int
    threshold   int
    timeout     time.Duration
    lastFailure time.Time
    state       string // "closed", "open", "half-open"
    mu          sync.Mutex
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    
    if cb.state == "open" {
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = "half-open"
        } else {
            return errors.New("circuit breaker is open")
        }
    }
    
    err := fn()
    
    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()
        
        if cb.failures >= cb.threshold {
            cb.state = "open"
        }
        return err
    }
    
    // Success - reset circuit breaker
    cb.failures = 0
    cb.state = "closed"
    return nil
}

// Fallback pattern
func getUserWithFallback(id string) (*User, error) {
    // Try primary database
    user, err := getUserFromPrimaryDB(id)
    if err == nil {
        return user, nil
    }
    
    log.Printf("Primary DB failed: %v, trying cache", err)
    
    // Fallback to cache
    user, err = getUserFromCache(id)
    if err == nil {
        return user, nil
    }
    
    log.Printf("Cache failed: %v, trying replica", err)
    
    // Fallback to read replica
    user, err = getUserFromReplica(id)
    if err == nil {
        return user, nil
    }
    
    return nil, fmt.Errorf("all data sources failed for user %s: %w", id, err)
}

9. Error Monitoring and Observability

Integrate with monitoring systems:

// Error severity levels
type Severity int

const (
    SeverityInfo Severity = iota
    SeverityWarning
    SeverityError
    SeverityCritical
)

type MonitoredError struct {
    error
    Severity   Severity
    Tags       map[string]string
    Timestamp  time.Time
    StackTrace string
}

func NewMonitoredError(err error, severity Severity, tags map[string]string) *MonitoredError {
    return &MonitoredError{
        error:      err,
        Severity:   severity,
        Tags:       tags,
        Timestamp:  time.Now(),
        StackTrace: getStackTrace(),
    }
}

// Error middleware for HTTP handlers
func ErrorMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // Log panic as critical error
                logError(fmt.Errorf("panic: %v", err), SeverityCritical, map[string]string{
                    "path":   r.URL.Path,
                    "method": r.Method,
                })
                
                http.Error(w, "Internal Server Error", 500)
            }
        }()
        
        next(w, r)
    }
}

func logError(err error, severity Severity, tags map[string]string) {
    monitoredErr := NewMonitoredError(err, severity, tags)
    
    // Send to logging system
    log.Printf("[%s] %v", severityString(severity), err)
    
    // Send metrics to monitoring system (Prometheus, DataDog, etc.)
    incrementErrorCounter(tags)
    
    // For critical errors, send alerts
    if severity == SeverityCritical {
        sendAlert(monitoredErr)
    }
}

🎯 Best Practices Summary

✅ DO:

  1. Use custom error types for different error categories
  2. Wrap errors with context using fmt.Errorf and %w
  3. Use sentinel errors for expected conditions
  4. Implement error interfaces for behavior classification
  5. Add structured context to errors for debugging
  6. Handle multiple errors when validating multiple fields
  7. Implement fallbacks for graceful degradation
  8. Monitor and alert on critical errors

❌ DON'T:

  1. Ignore errors - always handle them appropriately
  2. Use generic error messages - be specific about what failed
  3. Lose error context - preserve the error chain
  4. Handle all errors the same way - different errors need different handling
  5. Log and return the same error (choose one)
  6. Use panics for expected errors - panics are for programmer errors
  7. Create deep error wrapping - keep error chains manageable

🚀 Real-World Example

Here's how to apply these patterns in a user registration service:

// registration_service.go
package main

import (
    "fmt"
    "errors"
    "context"
)

type RegistrationService struct {
    userRepo    UserRepository
    emailSender EmailSender
    validator   UserValidator
}

func (s *RegistrationService) RegisterUser(ctx context.Context, req RegisterRequest) (*User, error) {
    // Error context for this operation
    errCtx := ErrorContext{
        Operation: "register_user",
        RequestID: getRequestID(ctx),
        Component: "registration-service",
        Metadata: map[string]interface{}{
            "email": req.Email,
        },
    }
    
    // Validate input
    if err := s.validator.Validate(req); err != nil {
        return nil, WithContext(errCtx, fmt.Errorf("validation failed: %w", err))
    }
    
    // Check if user already exists
    existingUser, err := s.userRepo.GetByEmail(req.Email)
    if err != nil && !errors.Is(err, ErrUserNotFound) {
        return nil, WithContext(errCtx, fmt.Errorf("failed to check existing user: %w", err))
    }
    
    if existingUser != nil {
        return nil, WithContext(errCtx, ErrEmailAlreadyUsed)
    }
    
    // Create user
    user := &User{
        Email:    req.Email,
        Name:     req.Name,
        Password: hashPassword(req.Password),
    }
    
    if err := s.userRepo.Create(user); err != nil {
        return nil, WithContext(errCtx, fmt.Errorf("failed to create user: %w", err))
    }
    
    // Send welcome email (non-critical - log error but don't fail)
    if err := s.emailSender.SendWelcome(user.Email); err != nil {
        log.Printf("Failed to send welcome email to %s: %v", user.Email, err)
        // Continue - email failure shouldn't fail registration
    }
    
    return user, nil
}

// Error handling in HTTP handler
func (h *Handler) RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
    var req RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    user, err := h.service.RegisterUser(r.Context(), req)
    if err != nil {
        // Check error type and respond appropriately
        var validationErr MultiError
        if errors.As(err, &validationErr) {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]interface{}{
                "error": "Validation failed",
                "details": validationErr.Errors,
            })
            return
        }
        
        if errors.Is(err, ErrEmailAlreadyUsed) {
            w.WriteHeader(http.StatusConflict)
            json.NewEncoder(w).Encode(map[string]string{
                "error": "Email already registered",
            })
            return
        }
        
        // Log unexpected errors
        log.Printf("Registration failed: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

The key insight: Good error handling isn't just about catching errors - it's about providing context, enabling recovery, and making your systems more observable and maintainable.

Master these patterns and your Go code will be more robust, debuggable, and production-ready!

WY

Wang Yinneng

Senior Golang Backend & Web3 Developer with 10+ years of experience building scalable systems and blockchain solutions.

View Full Profile →