← Back to Cecep Azhar
Cecep Azhar #golang#error-handling#production#best-practices#architecture

Go Error Handling Patterns That Scale in Production

Cecep Saeful Azhar • 16 Oct 2026
Go Error Handling Patterns That Scale in Production
Go's error handling is often criticized. But with the right patterns, it becomes one of the most reliable error handling systems. Here are battle-tested patterns from production systems.

The Error Handling Debate is Over

For years, people complained about Go's if err != nil pattern. In 2026, the Go team introduced sentinel errors, error wrapping, and the errors.Is/As patterns. The debate is over — these patterns work.

The key insight: Go's error handling forces you to deal with errors immediately, at the call site. That's not a bug — it's the feature that makes Go services remarkably reliable in production.

Pattern 1: Typed Errors with Custom Error Types

Stop using generic errors. Define specific error types for your domain:

type AppError struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Status  int    `json:"-"`
    Err     error  `json:"-"`
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

func (e *AppError) Unwrap() error {
    return e.Err
}

// Sentinel errors for your domain
var (
    ErrNotFound      = &AppError{Code: "NOT_FOUND", Message: "Resource not found", Status: 404}
    ErrUnauthorized  = &AppError{Code: "UNAUTHORIZED", Message: "Authentication required", Status: 401}
    ErrForbidden     = &AppError{Code: "FORBIDDEN", Message: "Insufficient permissions", Status: 403}
    ErrConflict      = &AppError{Code: "CONFLICT", Message: "Resource already exists", Status: 409}
    ErrValidation    = &AppError{Code: "VALIDATION_ERROR", Message: "Invalid input", Status: 422}
)

func NewNotFoundError(resource string, id interface{}) *AppError {
    return &AppError{
        Code:    "NOT_FOUND",
        Message: fmt.Sprintf("%s with ID %v not found", resource, id),
        Status:  404,
    }
}

Pattern 2: Error Wrapping with Context

Always wrap errors with context. A bare error in production logs is useless:

// BAD: No context
func GetUser(ctx context.Context, db *sqlx.DB, id string) (*User, error) {
    var user User
    err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
    return &user, err  // What failed? Which query? What ID?
}

// GOOD: Rich context
func GetUser(ctx context.Context, db *sqlx.DB, id string) (*User, error) {
    var user User
    err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, NewNotFoundError("user", id)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return &user, nil
}

Pattern 3: Error Propagation with Error Groups

For concurrent operations, use errgroup to collect errors from multiple goroutines:

import "golang.org/x/sync/errgroup"

func ProcessOrder(ctx context.Context, order Order) error {
    g, ctx := errgroup.WithContext(ctx)
    
    // Parallel independent operations
    g.Go(func() error {
        return updateInventory(ctx, order.Items)
    })
    
    g.Go(func() error {
        return processPayment(ctx, order.Payment)
    })
    
    g.Go(func() error {
        return sendNotification(ctx, order.Customer)
    })
    
    if err := g.Wait(); err != nil {
        return fmt.Errorf("process order %s: %w", order.ID, err)
    }
    
    return nil
}

Pattern 4: The Error Handler Chain

In production, you need a centralized error handler. Here's the pattern I use with Fiber:

func ErrorHandler(c *fiber.Ctx, err error) error {
    // Application errors
    var appErr *AppError
    if errors.As(err, &appErr) {
        return c.Status(appErr.Status).JSON(fiber.Map{
            "error": fiber.Map{
                "code":    appErr.Code,
                "message": appErr.Message,
            },
        })
    }
    
    // Database errors
    var pqErr *pq.Error
    if errors.As(err, &pqErr) {
        log.Error().Err(err).Str("code", pqErr.Code.Name()).Msg("database error")
        return c.Status(500).JSON(fiber.Map{
            "error": fiber.Map{
                "code":    "INTERNAL_ERROR",
                "message": "An unexpected error occurred",
            },
        })
    }
    
    // Unknown errors - log everything
    log.Error().Err(err).Msg("unhandled error")
    return c.Status(500).JSON(fiber.Map{
        "error": fiber.Map{
            "code":    "INTERNAL_ERROR",
            "message": "An unexpected error occurred",
        },
    })
}

Pattern 5: The Retry Pattern

For transient failures (network, database connections), implement retries with exponential backoff:

func WithRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    var lastErr error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        if err := fn(); err != nil {
            lastErr = err
            
            // Don't retry on permanent errors
            var appErr *AppError
            if errors.As(err, &appErr) && appErr.Status < 500 {
                return err
            }
            
            // Exponential backoff
            delay := time.Duration(attempt+1) * 100 * time.Millisecond
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        return nil
    }
    return fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)
}

Testing Error Paths

The most overlooked part of Go testing is testing error paths. Here's how to do it right:

func TestGetUser_NotFound(t *testing.T) {
    db := setupTestDB(t)
    defer db.Close()
    
    _, err := GetUser(context.Background(), db, "nonexistent-id")
    
    if err == nil {
        t.Fatal("expected error, got nil")
    }
    
    var appErr *AppError
    if !errors.As(err, &appErr) {
        t.Fatalf("expected AppError, got %T", err)
    }
    
    if appErr.Code != "NOT_FOUND" {
        t.Errorf("expected code NOT_FOUND, got %s", appErr.Code)
    }
    if appErr.Status != 404 {
        t.Errorf("expected status 404, got %d", appErr.Status)
    }
}

The Bottom Line

Good error handling in Go isn't about writing less code. It's about writing the right code at the right place. Typed errors, context wrapping, centralized handling, and proper testing — that's the recipe for production-grade Go services.

← More articles from Cecep Azhar Central Content Hub Home →