I’ve been writing Go professionally for three years now, and error handling is still one of the most debated topics in the community. The if err != nil pattern is verbose, but I’ve come to appreciate its explicitness.

This post covers the error handling patterns I use in production. These aren’t theoretical - they’re battle-tested in microservices handling millions of requests per day.

Table of Contents

The Basics: Don’t Ignore Errors

This seems obvious, but I see it violated constantly:

// Bad
data, _ := ioutil.ReadFile("config.json")

// Good
data, err := ioutil.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("failed to read config: %w", err)
}

The blank identifier _ is tempting when you’re prototyping, but it’s dangerous in production. I’ve debugged too many issues caused by ignored errors.

Our team uses a linter that flags ignored errors. It’s saved us multiple times.

Pattern 1: Wrap Errors with Context

Go 1.13 (released a few months ago) added error wrapping with %w:

func LoadUser(id string) (*User, error) {
    data, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("failed to query user %s: %w", id, err)
    }
    // ...
}

The %w verb wraps the error, preserving the original error for inspection:

err := LoadUser("123")
if err != nil {
    // Check if it's a specific error
    if errors.Is(err, sql.ErrNoRows) {
        // Handle not found
    }
    
    // Or check error type
    var netErr *net.OpError
    if errors.As(err, &netErr) {
        // Handle network error
    }
}

Before Go 1.13, we used the github.com/pkg/errors package. The standard library approach is similar but built-in.

Pattern 2: Custom Error Types

For domain-specific errors, I create custom types:

type ValidationError struct {
    Field   string
    Message string
}

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

func ValidateUser(user *User) error {
    if user.Email == "" {
        return &ValidationError{
            Field:   "email",
            Message: "email is required",
        }
    }
    if !strings.Contains(user.Email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "invalid email format",
        }
    }
    return nil
}

Then check for it:

err := ValidateUser(user)
if err != nil {
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        // Return 400 Bad Request with field-specific error
        return &APIError{
            Status:  400,
            Message: validationErr.Message,
            Field:   validationErr.Field,
        }
    }
    // Other errors return 500
    return &APIError{Status: 500, Message: "internal error"}
}

This pattern lets me handle different error types differently in HTTP handlers.

Pattern 3: Sentinel Errors

For well-known error conditions, I use sentinel errors:

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

func GetUser(id string) (*User, error) {
    user, err := db.FindUser(id)
    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("database error: %w", err)
    }
    return user, nil
}

Check with errors.Is:

user, err := GetUser("123")
if errors.Is(err, ErrNotFound) {
    return 404
}
if err != nil {
    return 500
}

The advantage: callers can check for specific conditions without parsing error strings.

Pattern 4: Error Aggregation

When validating multiple fields, I collect all errors instead of returning on first failure:

type ErrorList []error

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

func ValidateUser(user *User) error {
    var errs ErrorList
    
    if user.Email == "" {
        errs = append(errs, &ValidationError{
            Field:   "email",
            Message: "required",
        })
    }
    
    if user.Name == "" {
        errs = append(errs, &ValidationError{
            Field:   "name",
            Message: "required",
        })
    }
    
    if len(user.Password) < 8 {
        errs = append(errs, &ValidationError{
            Field:   "password",
            Message: "must be at least 8 characters",
        })
    }
    
    if len(errs) > 0 {
        return errs
    }
    return nil
}

This gives users all validation errors at once, not one at a time.

Pattern 5: Retry with Exponential Backoff

For transient errors (network issues, rate limits), I retry with backoff:

func FetchWithRetry(url string, maxRetries int) (*Response, error) {
    var lastErr error
    
    for i := 0; i < maxRetries; i++ {
        resp, err := http.Get(url)
        if err == nil {
            return resp, nil
        }
        
        lastErr = err
        
        // Don't retry on client errors
        if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 {
            return nil, fmt.Errorf("client error: %w", err)
        }
        
        // Exponential backoff: 1s, 2s, 4s, 8s...
        backoff := time.Duration(1<<uint(i)) * time.Second
        time.Sleep(backoff)
    }
    
    return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}

I use this pattern for external API calls. It’s saved us from cascading failures when dependencies have brief outages.

Pattern 6: Panic for Programmer Errors

I use panic only for programmer errors that should never happen:

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

// Call during initialization
func init() {
    config = MustLoadConfig("config.json")
}

The Must prefix is a convention that signals “this will panic on error.”

I never use panic for runtime errors that can be handled. Network failures, invalid user input, etc. should always return errors.

Pattern 7: Defer for Cleanup

Use defer to ensure cleanup happens even if errors occur:

func ProcessFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close() // Always closes, even if error occurs below
    
    data, err := ioutil.ReadAll(file)
    if err != nil {
        return fmt.Errorf("failed to read file: %w", err)
    }
    
    return process(data)
}

Be careful with defer in loops - it doesn’t execute until function returns:

// Bad: file handles leak until function returns
for _, path := range paths {
    file, _ := os.Open(path)
    defer file.Close() // Doesn't close until loop finishes!
    process(file)
}

// Good: close immediately
for _, path := range paths {
    func() {
        file, _ := os.Open(path)
        defer file.Close() // Closes at end of anonymous function
        process(file)
    }()
}

Pattern 8: Logging Errors

I log errors at the point where they’re handled, not where they’re created:

// Bad: logs at every level
func LoadUser(id string) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        log.Printf("database error: %v", err) // Don't log here
        return nil, err
    }
    return user, nil
}

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    user, err := LoadUser("123")
    if err != nil {
        log.Printf("failed to load user: %v", err) // Log here
        http.Error(w, "Internal error", 500)
    }
}

If you log at every level, you get duplicate log entries for the same error. Log once, at the top level where you handle it.

Pattern 9: Structured Error Context

For complex errors, I include structured context:

type ServiceError struct {
    Op      string                 // Operation that failed
    Service string                 // Service name
    Err     error                  // Underlying error
    Context map[string]interface{} // Additional context
}

func (e *ServiceError) Error() string {
    return fmt.Sprintf("%s.%s failed: %v", e.Service, e.Op, e.Err)
}

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

func FetchUser(id string) (*User, error) {
    user, err := api.Get("/users/" + id)
    if err != nil {
        return nil, &ServiceError{
            Op:      "FetchUser",
            Service: "UserAPI",
            Err:     err,
            Context: map[string]interface{}{
                "user_id": id,
                "url":     "/users/" + id,
            },
        }
    }
    return user, nil
}

This makes debugging much easier. When an error occurs, I have all the context I need.

Pattern 10: HTTP Error Responses

For HTTP APIs, I map errors to status codes:

type APIError struct {
    Status  int
    Message string
    Details interface{}
}

func (e *APIError) Error() string {
    return e.Message
}

func HandleError(w http.ResponseWriter, err error) {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        w.WriteHeader(apiErr.Status)
        json.NewEncoder(w).Encode(map[string]interface{}{
            "error":   apiErr.Message,
            "details": apiErr.Details,
        })
        return
    }
    
    // Check for known error types
    if errors.Is(err, ErrNotFound) {
        w.WriteHeader(404)
        json.NewEncoder(w).Encode(map[string]string{
            "error": "not found",
        })
        return
    }
    
    if errors.Is(err, ErrUnauthorized) {
        w.WriteHeader(401)
        json.NewEncoder(w).Encode(map[string]string{
            "error": "unauthorized",
        })
        return
    }
    
    // Default to 500
    log.Printf("internal error: %v", err)
    w.WriteHeader(500)
    json.NewEncoder(w).Encode(map[string]string{
        "error": "internal server error",
    })
}

Then in handlers:

func HandleGetUser(w http.ResponseWriter, r *http.Request) {
    id := mux.Vars(r)["id"]
    
    user, err := GetUser(id)
    if err != nil {
        HandleError(w, err)
        return
    }
    
    json.NewEncoder(w).Encode(user)
}

This centralizes error handling and ensures consistent error responses.

Real-World Example

Here’s how these patterns come together in a real service:

package user

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
)

var (
    ErrNotFound     = errors.New("user not found")
    ErrInvalidInput = errors.New("invalid input")
)

type Service struct {
    db *sql.DB
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    if id == "" {
        return nil, fmt.Errorf("user id is required: %w", ErrInvalidInput)
    }
    
    query := "SELECT id, name, email FROM users WHERE id = ?"
    row := s.db.QueryRowContext(ctx, query, id)
    
    var user User
    err := row.Scan(&user.ID, &user.Name, &user.Email)
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("failed to query user %s: %w", id, err)
    }
    
    return &user, nil
}

func (s *Service) CreateUser(ctx context.Context, user *User) error {
    if err := validateUser(user); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    
    query := "INSERT INTO users (id, name, email) VALUES (?, ?, ?)"
    _, err := s.db.ExecContext(ctx, query, user.ID, user.Name, user.Email)
    if err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }
    
    return nil
}

func validateUser(user *User) error {
    var errs ErrorList
    
    if user.Name == "" {
        errs = append(errs, &ValidationError{Field: "name", Message: "required"})
    }
    if user.Email == "" {
        errs = append(errs, &ValidationError{Field: "email", Message: "required"})
    }
    
    if len(errs) > 0 {
        return errs
    }
    return nil
}

Conclusion

Go’s error handling is verbose, but it’s explicit and predictable. After three years, I appreciate that I can trace exactly where errors come from and how they’re handled.

Key takeaways:

  1. Always handle errors - use a linter to enforce this
  2. Wrap errors with context using %w
  3. Use custom error types for domain-specific errors
  4. Use sentinel errors for well-known conditions
  5. Log errors once, at the top level
  6. Use panic only for programmer errors
  7. Clean up resources with defer

The Go 2 error handling proposal might change some of this, but these patterns work well today. They’ve helped us build reliable services that handle errors gracefully.

Error handling isn’t glamorous, but it’s what separates toy projects from production systems. Invest time in getting it right.