Skip to main content

Command Palette

Search for a command to run...

Go Error Handling Done Right: Patterns for Production Code

Published
9 min read
E

Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.

Introduction

I'll be honest—when I first saw Go's error handling, I hated it.

result, err := doSomething()
if err != nil {
    return err
}

"Are you kidding me? I have to check errors after EVERY function call?"

Coming from languages with try-catch blocks, this felt primitive. But after shipping production Go code for years, I get it now. Go's error handling is explicit, predictable, and forces you to think about failure cases.

In this post, I'll show you Go error patterns that actually work in production. Not academic examples—real code that handles errors properly.

The Basics: Errors Are Values

In Go, errors are just values:

type error interface {
    Error() string
}

Any type with an Error() string method is an error. That's it.

result, err := os.Open("file.txt")
if err != nil {
    log.Fatal(err)
}

Simple. Explicit. No hidden control flow.

Creating Errors

import "errors"

// Simple error
err := errors.New("something went wrong")

// Formatted error
err := fmt.Errorf("user %s not found", userID)

Error Wrapping and Unwrapping (Go 1.13+)

This changed everything. Before Go 1.13, we lost context when returning errors.

The Problem

func GetUser(id string) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, err // Lost context!
    }
    return user, nil
}

When this fails, you get: record not found

But WHERE did it fail? Which function? Which user?

The Solution: Error Wrapping

func GetUser(id string) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user %s: %w", id, err)
    }
    return user, nil
}

Now you get: failed to get user 12345: failed to query database: record not found

Beautiful. Clear error chain.

%w vs %v

// %w wraps the error (preserves error chain)
fmt.Errorf("context: %w", err)

// %v converts to string (breaks error chain)
fmt.Errorf("context: %v", err)

Always use %w unless you explicitly want to hide the original error.

Unwrapping Errors

import "errors"

originalErr := errors.New("connection failed")
wrappedErr := fmt.Errorf("database error: %w", originalErr)

// Unwrap to get original error
err := errors.Unwrap(wrappedErr)
fmt.Println(err) // "connection failed"

// Check if error is specific type
if errors.Is(wrappedErr, originalErr) {
    fmt.Println("It's the original error!")
}

Custom Error Types

For complex errors, create custom types:

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)
}

func ValidateUser(user *User) error {
    if user.Email == "" {
        return &ValidationError{
            Field:   "email",
            Message: "email is required",
        }
    }
    if user.Age < 18 {
        return &ValidationError{
            Field:   "age",
            Message: "must be 18 or older",
        }
    }
    return nil
}

Type Assertions with errors.As

err := ValidateUser(user)
if err != nil {
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        fmt.Printf("Validation failed: %s on %s\n", 
            validationErr.Message, validationErr.Field)
    }
}

Sentinel Errors

Predefined errors for common cases:

var (
    ErrNotFound      = errors.New("resource not found")
    ErrUnauthorized  = errors.New("unauthorized")
    ErrInvalidInput  = errors.New("invalid input")
)

func GetOrder(id string) (*Order, error) {
    order, err := db.FindOrder(id)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("database error: %w", err)
    }
    return order, nil
}

// Caller can check specific errors
order, err := GetOrder("123")
if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

Standard library uses this pattern:

if errors.Is(err, io.EOF) {
    // End of file
}

if errors.Is(err, context.DeadlineExceeded) {
    // Timeout
}

Real-World Pattern: Multi-Layer Error Handling

Here's how I structure errors in production:

// Domain errors (business logic layer)
var (
    ErrUserNotFound     = errors.New("user not found")
    ErrInvalidPassword  = errors.New("invalid password")
    ErrAccountLocked    = errors.New("account locked")
)

// Repository layer
type UserRepository struct {
    db *mongo.Database
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
    var user User
    err := r.db.Collection("users").
        FindOne(ctx, bson.M{"email": email}).
        Decode(&user)

    if err != nil {
        if err == mongo.ErrNoDocuments {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("database query failed: %w", err)
    }

    return &user, nil
}

// Service layer
type AuthService struct {
    repo *UserRepository
}

func (s *AuthService) Login(ctx context.Context, email, password string) (*Session, error) {
    user, err := s.repo.FindByEmail(ctx, email)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return nil, ErrInvalidPassword // Don't reveal user existence
        }
        return nil, fmt.Errorf("failed to find user: %w", err)
    }

    if user.Locked {
        return nil, ErrAccountLocked
    }

    if !user.CheckPassword(password) {
        return nil, ErrInvalidPassword
    }

    session, err := s.createSession(ctx, user)
    if err != nil {
        return nil, fmt.Errorf("failed to create session: %w", err)
    }

    return session, nil
}

// HTTP handler layer
func (h *AuthHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.respondError(w, http.StatusBadRequest, "Invalid request")
        return
    }

    session, err := h.authService.Login(r.Context(), req.Email, req.Password)
    if err != nil {
        switch {
        case errors.Is(err, ErrInvalidPassword):
            h.respondError(w, http.StatusUnauthorized, "Invalid credentials")
        case errors.Is(err, ErrAccountLocked):
            h.respondError(w, http.StatusForbidden, "Account locked")
        default:
            h.logger.Error("login failed", zap.Error(err))
            h.respondError(w, http.StatusInternalServerError, "Internal server error")
        }
        return
    }

    h.respondJSON(w, http.StatusOK, session)
}

Each layer:

  1. Repository: Converts DB errors to domain errors

  2. Service: Adds business logic context

  3. Handler: Converts to HTTP responses

Handling Multiple Errors

Pattern 1: Return First Error

func ProcessOrder(order *Order) error {
    if err := ValidateOrder(order); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }

    if err := CheckInventory(order); err != nil {
        return fmt.Errorf("inventory check failed: %w", err)
    }

    if err := ChargePayment(order); err != nil {
        return fmt.Errorf("payment failed: %w", err)
    }

    return nil
}

Pattern 2: Collect All Errors

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    var msgs []string
    for _, err := range m.Errors {
        msgs = append(msgs, err.Error())
    }
    return strings.Join(msgs, "; ")
}

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

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

func ValidateOrder(order *Order) error {
    var errs MultiError

    if order.CustomerID == "" {
        errs.Add(errors.New("customer ID required"))
    }

    if order.Amount <= 0 {
        errs.Add(errors.New("amount must be positive"))
    }

    if len(order.Items) == 0 {
        errs.Add(errors.New("order must have items"))
    }

    if errs.HasErrors() {
        return &errs
    }

    return nil
}

Pattern 3: Error Group (golang.org/x/sync/errgroup)

For concurrent operations:

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

func FetchUserData(ctx context.Context, userID string) (*UserData, error) {
    var (
        profile  *Profile
        orders   []Order
        payments []Payment
    )

    g, ctx := errgroup.WithContext(ctx)

    // Fetch profile
    g.Go(func() error {
        var err error
        profile, err = fetchProfile(ctx, userID)
        return err
    })

    // Fetch orders
    g.Go(func() error {
        var err error
        orders, err = fetchOrders(ctx, userID)
        return err
    })

    // Fetch payments
    g.Go(func() error {
        var err error
        payments, err = fetchPayments(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{
        Profile:  profile,
        Orders:   orders,
        Payments: payments,
    }, nil
}

Panic and Recover

Use panic only for unrecoverable errors:

// Bad: Using panic for normal errors
func GetUser(id string) *User {
    user, err := db.FindUser(id)
    if err != nil {
        panic(err) // NO!
    }
    return user
}

// Good: Return error
func GetUser(id string) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

When to Panic

  • Program initialization fails (can't load config, can't connect to required DB)

  • Programmer errors (nil pointer, impossible state)

  • Unrecoverable runtime errors

func LoadConfig(path string) *Config {
    config, err := parseConfig(path)
    if err != nil {
        panic(fmt.Sprintf("failed to load config: %v", err))
    }
    return config
}

Recovering from Panics

Use recover in HTTP handlers to prevent crashes:

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Error("panic recovered",
                    zap.Any("error", err),
                    zap.String("url", r.URL.Path),
                )

                http.Error(w, "Internal server error", http.StatusInternalServerError)
            }
        }()

        next.ServeHTTP(w, r)
    })
}

Error Logging Best Practices

Structured Logging with Context

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    logger := s.logger.With(
        zap.String("customer_id", req.CustomerID),
        zap.String("order_type", req.Type),
    )

    order, err := s.repo.Create(ctx, req)
    if err != nil {
        logger.Error("failed to create order",
            zap.Error(err),
            zap.Duration("elapsed", time.Since(start)),
        )
        return nil, fmt.Errorf("failed to create order: %w", err)
    }

    logger.Info("order created successfully",
        zap.String("order_id", order.ID),
    )

    return order, nil
}

Log at the Right Level

if err != nil {
    switch {
    case errors.Is(err, ErrNotFound):
        // Don't log - expected behavior
        return nil, err

    case errors.Is(err, ErrInvalidInput):
        // Warning - client error
        logger.Warn("invalid input", zap.Error(err))
        return nil, err

    default:
        // Error - unexpected
        logger.Error("operation failed", zap.Error(err))
        return nil, err
    }
}

Testing Error Handling

func TestOrderService_CreateOrder_ValidationError(t *testing.T) {
    service := NewOrderService(mockRepo, logger)

    req := CreateOrderRequest{
        CustomerID: "", // Invalid
        Amount:     100,
    }

    _, err := service.CreateOrder(context.Background(), req)

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

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

    if validationErr.Field != "customer_id" {
        t.Errorf("expected field 'customer_id', got '%s'", validationErr.Field)
    }
}

func TestOrderService_CreateOrder_DatabaseError(t *testing.T) {
    mockRepo := &MockOrderRepository{
        CreateError: errors.New("connection failed"),
    }
    service := NewOrderService(mockRepo, logger)

    _, err := service.CreateOrder(context.Background(), validRequest)

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

    // Error should be wrapped
    if !strings.Contains(err.Error(), "connection failed") {
        t.Errorf("error should contain original error: %v", err)
    }
}

Production Checklist

✅ Always check returned errors ✅ Use %w to wrap errors with context ✅ Define sentinel errors for common cases ✅ Create custom error types for structured errors ✅ Log errors at appropriate levels ✅ Don't log the same error multiple times ✅ Return errors, don't panic (except initialization) ✅ Use errors.Is() and errors.As() for error checking ✅ Recover from panics in HTTP handlers ✅ Test error handling paths

Common Mistakes

Mistake 1: Ignoring Errors

// Bad
result, _ := doSomething()

// Good
result, err := doSomething()
if err != nil {
    return err
}

Mistake 2: Double Logging

// Bad: Logged at every level
func Service() error {
    err := Repository()
    if err != nil {
        log.Error(err) // Logged here
        return err
    }
}

func Handler() {
    err := Service()
    if err != nil {
        log.Error(err) // Logged again!
    }
}

// Good: Log once at top level
func Service() error {
    err := Repository()
    if err != nil {
        return fmt.Errorf("service failed: %w", err)
    }
}

func Handler() {
    err := Service()
    if err != nil {
        log.Error(err) // Logged once
    }
}

Mistake 3: Losing Error Context

// Bad
if err != nil {
    return errors.New("failed")
}

// Good
if err != nil {
    return fmt.Errorf("failed to process order %s: %w", orderID, err)
}

Conclusion

Go error handling is explicit by design. It forces you to think about failures.

Key principles:

  • Errors are values

  • Check every error

  • Wrap errors with context

  • Use custom types for structured errors

  • Log once, at the right level

It's verbose, yes. But you'll never wonder where an error came from or what state your program is in.

Questions? Let me know in the comments!