Skip to content
· 10 min read · 0 views

Error Handling Patterns in Go

Production-ready error handling patterns in Go — wrapping, sentinel errors, custom error types, error groups, and structured logging for backend services.

// table of contents (10 sections)

Error handling in Go is intentionally explicit. No exceptions, no try-catch — just errors as values. This simplicity is powerful, but it requires discipline to avoid repetitive error handling code and to provide useful context when things go wrong.

After years building production Go services, I have settled on patterns that keep error handling clean, informative, and maintainable. This post covers those patterns: when to wrap, when to create custom types, and how to structure errors that help you debug production issues.

The Golden Rule: Always Handle Errors

Go does not force you to handle errors, but you always should:

// BAD: Ignoring errors
file, _ := os.Open("config.json")
json.NewDecoder(file).Decode(&config)

// GOOD: Handling every error
file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("failed to open config: %w", err)
}

if err := json.NewDecoder(file).Decode(&config); err != nil {
    return fmt.Errorf("failed to decode config: %w", err)
}

The only time you should intentionally ignore an error is when you have documented why:

// Close errors from ioutil.ReadAll are usually not worth handling
// because we already have the data we need
data, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()

Sentinel Errors

Sentinel errors are predefined errors that callers can check for specific behavior:

// Package-level sentinel errors
var (
    ErrUserNotFound    = errors.New("user not found")
    ErrInvalidPassword = errors.New("invalid password")
    ErrEmailTaken      = errors.New("email already taken")
)

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE email = $1", email)

    var user User
    if err := row.Scan(&user.ID, &user.Email, &user.Name); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("failed to query user: %w", err)
    }

    return &user, nil
}

// Caller can check for specific error
user, err := repo.FindByEmail(ctx, email)
if err != nil {
    if errors.Is(err, ErrUserNotFound) {
        return nil, ErrInvalidCredentials
    }
    return nil, err
}

When to use sentinel errors:

  • Public APIs where callers need to distinguish specific error cases
  • Errors that trigger different handling logic
  • Errors that should not be wrapped (they are the terminal error)

When to avoid:

  • Errors that need additional context (line numbers, request IDs)
  • Errors that occur in many places with different meanings

Error Wrapping with Context

Since Go 1.13, fmt.Errorf with %w wraps errors while preserving the original:

func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
    order, err := s.repo.GetOrder(ctx, orderID)
    if err != nil {
        // Wrap with context about what we were trying to do
        return fmt.Errorf("failed to get order %s: %w", orderID, err)
    }

    if err := s.payment.Charge(ctx, order.Total); err != nil {
        return fmt.Errorf("failed to charge payment for order %s: %w", orderID, err)
    }

    return nil
}

The key insight: wrap at layer boundaries. Each layer adds its own context:

// HTTP handler layer
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    if err := h.service.ProcessOrder(r.Context(), orderID); err != nil {
        h.Error(w, fmt.Errorf("create order failed: %w", err))
        return
    }
}

// Service layer
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
    order, err := s.repo.GetOrder(ctx, orderID)
    if err != nil {
        return fmt.Errorf("get order: %w", err)
    }
    // ...
}

// Repository layer
func (r *Repository) GetOrder(ctx context.Context, id string) (*Order, error) {
    row := r.db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)
    // ...
    if err := row.Scan(...); err != nil {
        return nil, fmt.Errorf("scan order: %w", err)
    }
}

When you read the error stack, you get the full story:

create order failed: get order: scan order: sql: Scan error on column index 2

Custom Error Types

Sometimes you need more than a string — attach structured data to your errors:

// ValidationError holds field-level validation errors
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %s: %s", e.Field, e.Message)
}

// APIError is the base type for all API errors
type APIError struct {
    Code    string
    Message string
    Status  int
    Err     error
}

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

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

// HTTP returns the HTTP status code
func (e *APIError) HTTP() int {
    if e.Status > 0 {
        return e.Status
    }
    return http.StatusInternalServerError
}

// Usage
func (s *Service) CreateUser(ctx context.Context, req *CreateUserRequest) error {
    if !isValidEmail(req.Email) {
        return &ValidationError{
            Field:   "email",
            Message: "must be a valid email address",
        }
    }

    if err := s.repo.CreateUser(ctx, req); err != nil {
        return &APIError{
            Code:    "USER_CREATE_FAILED",
            Message: "failed to create user",
            Status:  http.StatusInternalServerError,
            Err:     err,
        }
    }

    return nil
}

Check for custom errors with type assertions:

user, err := s.CreateUser(ctx, req)
if err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        return ValidationErrorResponse{Field: ve.Field, Message: ve.Message}
    }

    var apiErr *APIError
    if errors.As(err, &apiErr) {
        return ErrorResponse{Code: apiErr.Code, Message: apiErr.Message}
    }

    return ErrorResponse{Code: "INTERNAL_ERROR", Message: "something went wrong"}
}

Error Groups for Concurrent Operations

When running multiple operations concurrently, use errgroup to collect errors:

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

func (s *Service) FetchUserData(ctx context.Context, userID string) (*UserData, error) {
    g, ctx := errgroup.WithContext(ctx)

    var user *User
    var orders []Order
    var payments []Payment

    // Fetch user concurrently with orders and payments
    g.Go(func() error {
        var err error
        user, err = s.repo.GetUser(ctx, userID)
        return err
    })

    g.Go(func() error {
        var err error
        orders, err = s.repo.GetOrders(ctx, userID)
        return err
    })

    g.Go(func() error {
        var err error
        payments, err = s.repo.GetPayments(ctx, userID)
        return err
    })

    // Wait for all goroutines
    if err := g.Wait(); err != nil {
        return nil, fmt.Errorf("failed to fetch user data: %w", err)
    }

    return &UserData{
        User:     user,
        Orders:   orders,
        Payments: payments,
    }, nil
}

For collecting multiple errors without early exit:

type MultiError []error

func (m MultiError) Error() string {
    var sb strings.Builder
    sb.WriteString("multiple errors occurred:")
    for i, err := range m {
        sb.WriteString(fmt.Sprintf("\n%d. %s", i+1, err.Error()))
    }
    return sb.String()
}

func (m MultiError) HasErrors() bool {
    return len(m) > 0
}

func (s *Service) ValidateBatch(ctx context.Context, items []Item) MultiError {
    var errs MultiError

    for _, item := range items {
        if err := s.ValidateItem(ctx, item); err != nil {
            errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
        }
    }

    return errs
}

Structured Logging With Errors

Errors should be logged with context. Use structured logging:

import "log/slog"

func (s *Service) ProcessPayment(ctx context.Context, payment *Payment) error {
    if err := s.gateway.Charge(ctx, payment); err != nil {
        // Log error with structured context
        slog.ErrorContext(ctx, "payment gateway charge failed",
            "payment_id", payment.ID,
            "amount", payment.Amount,
            "currency", payment.Currency,
            "user_id", payment.UserID,
            "error", err,
        )
        return fmt.Errorf("charge payment %s: %w", payment.ID, err)
    }

    slog.InfoContext(ctx, "payment charged successfully",
        "payment_id", payment.ID,
        "amount", payment.Amount,
    )

    return nil
}

For logs that might be queried or analyzed, include error codes:

type ErrorCode string

const (
    ErrCodePaymentDeclined  ErrorCode = "PAYMENT_DECLINED"
    ErrCodePaymentTimeout   ErrorCode = "PAYMENT_TIMEOUT"
    ErrCodeInvalidRequest   ErrorCode = "INVALID_REQUEST"
    ErrCodeInternalError    ErrorCode = "INTERNAL_ERROR"
)

type CodedError struct {
    Code ErrorCode
    Err  error
}

func (e *CodedError) Error() string {
    return fmt.Sprintf("[%s] %v", e.Code, e.Err)
}

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

// Usage
func (s *Service) ProcessPayment(ctx context.Context, payment *Payment) error {
    if err := s.gateway.Charge(ctx, payment); err != nil {
        return &CodedError{
            Code: ErrCodePaymentDeclined,
            Err:  err,
        }
    }
    return nil
}

Error Handling for External Services

External service calls need special care — timeouts, retries, and graceful degradation:

type ExternalServiceClient struct {
    client  *http.Client
    baseURL string
    logger  *slog.Logger
}

func NewExternalServiceClient(baseURL string) *ExternalServiceClient {
    return &ExternalServiceClient{
        client: &http.Client{
            Timeout: 10 * time.Second,
        },
        baseURL: baseURL,
    }
}

func (c *ExternalServiceClient) CallAPI(ctx context.Context, endpoint string, payload []byte) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+endpoint, bytes.NewReader(payload))
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("User-Agent", "MyService/1.0")

    resp, err := c.client.Do(req)
    if err != nil {
        // Check for timeout
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("external service timeout: %w", err)
        }
        return nil, fmt.Errorf("external service request failed: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("read response body: %w", err)
    }

    // Handle non-2xx responses
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return nil, fmt.Errorf("external service returned status %d: %s", resp.StatusCode, string(body))
    }

    return body, nil
}

Add retry logic for transient failures:

func (c *ExternalServiceClient) CallWithRetry(ctx context.Context, endpoint string, payload []byte, maxRetries int) ([]byte, error) {
    var lastErr error

    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            // Exponential backoff
            backoff := time.Duration(1<<uint(attempt)) * time.Second
            select {
            case <-time.After(backoff):
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }

        body, err := c.CallAPI(ctx, endpoint, payload)
        if err == nil {
            return body, nil
        }

        lastErr = err

        // Don't retry client errors (4xx)
        var apiErr *APIError
        if errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500 {
            return nil, err
        }
    }

    return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}

Error Handling in Database Operations

Database errors need translation to domain errors:

func (r *Repository) CreateUser(ctx context.Context, user *User) error {
    query := `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`

    _, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Name)
    if err != nil {
        // Check for unique constraint violation
        if isDuplicateKeyError(err) {
            return &APIError{
                Code:    "EMAIL_ALREADY_EXISTS",
                Message: "a user with this email already exists",
                Status:  http.StatusConflict,
                Err:     err,
            }
        }

        return fmt.Errorf("failed to create user: %w", err)
    }

    return nil
}

// Database-specific error checking
func isDuplicateKeyError(err error) bool {
    if err == nil {
        return false
    }

    // PostgreSQL
    if strings.Contains(err.Error(), "duplicate key") {
        return true
    }

    // MySQL
    if strings.Contains(err.Error(), "Duplicate entry") {
        return true
    }

    // SQLite
    if strings.Contains(err.Error(), "UNIQUE constraint failed") {
        return true
    }

    return false
}

Testing Error Paths

Test both the error and non-error paths:

func TestCreateUser_DuplicateEmail(t *testing.T) {
    db := setupTestDB(t)
    repo := NewRepository(db)

    // Create first user
    user1 := &User{ID: "1", Email: "test@example.com", Name: "Test"}
    if err := repo.CreateUser(context.Background(), user1); err != nil {
        t.Fatalf("first create should succeed: %v", err)
    }

    // Try to create duplicate
    user2 := &User{ID: "2", Email: "test@example.com", Name: "Test2"}
    err := repo.CreateUser(context.Background(), user2)

    if err == nil {
        t.Fatal("expected error for duplicate email, got nil")
    }

    var apiErr *APIError
    if !errors.As(err, &apiErr) {
        t.Fatalf("expected APIError, got %T", err)
    }

    if apiErr.Code != "EMAIL_ALREADY_EXISTS" {
        t.Errorf("expected code EMAIL_ALREADY_EXISTS, got %s", apiErr.Code)
    }

    if apiErr.Status != http.StatusConflict {
        t.Errorf("expected status 409, got %d", apiErr.Status)
    }
}

Key Takeaways

  1. Always handle errors — never ignore them without documentation
  2. Use sentinel errors for public APIs — let callers distinguish cases
  3. Wrap errors at layer boundaries — add context about what was being attempted
  4. Create custom error types for structured data — validation, API errors
  5. Use errgroup for concurrent operations — collect and handle multiple errors
  6. Log errors with structured context — include IDs, amounts, and relevant metadata
  7. Translate database and external errors — map to domain errors
  8. Test error paths — verify error types, codes, and messages

Good error handling is about making failures debuggable. When production fails at 3 AM, you want errors that tell you what went wrong, where it happened, and what context led to the failure. The patterns in this post will help you build Go services that fail informatively.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion