Web Analytics

Error Wrapping & Best Practices

Intermediate ~35 min read

Error wrapping adds context while preserving the original error. This creates an error chain that helps with debugging and allows checking for specific errors. In this lesson, you'll master error wrapping and learn professional error handling practices.

Error Wrapping with %w

The %w verb in fmt.Errorf wraps an error:

Output
Click Run to execute your code
%w vs %v:
  • %w - Wraps the error (preserves it for errors.Is/As)
  • %v - Converts to string (loses the original error)
// Wrapping (preserves original)
err := fmt.Errorf("failed to process: %w", originalErr)

// Not wrapping (just formatting)
err := fmt.Errorf("failed to process: %v", originalErr)
Best Practice: Use %w when you want to preserve the error for checking with errors.Is() or errors.As(). Use %v when you want to hide implementation details.

Error Chains

Wrapping creates a chain of errors:

Output
Click Run to execute your code

Unwrapping Errors

import "errors"

// Unwrap returns the wrapped error
err := fmt.Errorf("outer: %w", innerErr)
unwrapped := errors.Unwrap(err)  // Returns innerErr

// Unwrap returns nil if not wrapped
simple := errors.New("simple error")
unwrapped := errors.Unwrap(simple)  // nil

Working with Wrapped Errors

errors.Is() - Check Error Type

var ErrNotFound = errors.New("not found")

func findUser(id int) error {
    // ... some code ...
    return fmt.Errorf("user lookup failed: %w", ErrNotFound)
}

err := findUser(123)

// errors.Is() works through the chain
if errors.Is(err, ErrNotFound) {
    fmt.Println("User not found!")  // This works!
}

// Direct comparison doesn't work
if err == ErrNotFound {
    fmt.Println("Won't print")  // err is wrapped
}

errors.As() - Extract Error Type

type ValidationError struct {
    Field string
    Value interface{}
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid %s: %v", e.Field, e.Value)
}

func validate(age int) error {
    if age < 0 {
        return fmt.Errorf("validation failed: %w", 
            &ValidationError{Field: "age", Value: age})
    }
    return nil
}

err := validate(-5)

// errors.As() extracts the ValidationError
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("Field: %s, Value: %v\n", ve.Field, ve.Value)
}

Error Handling Best Practices

1. Add Context at Each Layer

// Database layer
func (db *DB) GetUser(id int) (*User, error) {
    user, err := db.query(id)
    if err != nil {
        return nil, fmt.Errorf("database query failed: %w", err)
    }
    return user, nil
}

// Service layer
func (s *Service) LoadUser(id int) (*User, error) {
    user, err := s.db.GetUser(id)
    if err != nil {
        return nil, fmt.Errorf("failed to load user %d: %w", id, err)
    }
    return user, nil
}

// Handler layer
func (h *Handler) HandleRequest(id int) error {
    user, err := h.service.LoadUser(id)
    if err != nil {
        return fmt.Errorf("request handling failed: %w", err)
    }
    // ...
}

2. Don't Wrap Errors Unnecessarily

// ❌ Too much wrapping
func process() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("error: %w", err)  // Not adding value
    }
    return nil
}

// βœ… Add meaningful context
func process() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("failed to process data: %w", err)
    }
    return nil
}

3. Handle Errors at the Right Level

// ❌ Wrong - handling too early
func readConfig() (*Config, error) {
    data, err := os.ReadFile("config.json")
    if err != nil {
        log.Fatal(err)  // Don't log.Fatal in library code!
    }
    // ...
}

// βœ… Correct - return error, let caller decide
func readConfig() (*Config, error) {
    data, err := os.ReadFile("config.json")
    if err != nil {
        return nil, fmt.Errorf("failed to read config: %w", err)
    }
    // ...
}

// Caller decides how to handle
func main() {
    config, err := readConfig()
    if err != nil {
        log.Fatal(err)  // OK in main
    }
}

4. Use Sentinel Errors for Expected Conditions

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

func getUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrInvalidInput
    }
    
    user := findInDB(id)
    if user == nil {
        return nil, ErrNotFound
    }
    
    return user, nil
}

// Caller can check specific errors
user, err := getUser(123)
if errors.Is(err, ErrNotFound) {
    // Handle not found
} else if errors.Is(err, ErrInvalidInput) {
    // Handle invalid input
}

Logging Errors

Log Once, Return Once

// ❌ Wrong - logging at every level
func layer1() error {
    err := layer2()
    if err != nil {
        log.Printf("layer1 error: %v", err)  // Logged
        return err
    }
    return nil
}

func layer2() error {
    err := layer3()
    if err != nil {
        log.Printf("layer2 error: %v", err)  // Logged again!
        return err
    }
    return nil
}

// βœ… Correct - log at top level only
func layer1() error {
    err := layer2()
    if err != nil {
        return fmt.Errorf("layer1: %w", err)  // Add context, don't log
    }
    return nil
}

func main() {
    err := layer1()
    if err != nil {
        log.Printf("Error: %v", err)  // Log once at top
    }
}

Common Mistakes

1. Using %v instead of %w

// ❌ Wrong - loses original error
err := fmt.Errorf("failed: %v", originalErr)
if errors.Is(err, ErrNotFound) {  // Won't work!
    // ...
}

// βœ… Correct - preserves original
err := fmt.Errorf("failed: %w", originalErr)
if errors.Is(err, ErrNotFound) {  // Works!
    // ...
}

2. Wrapping nil errors

// ❌ Wrong - wrapping nil
func process() error {
    err := doSomething()
    return fmt.Errorf("process failed: %w", err)  // err might be nil!
}

// βœ… Correct - check before wrapping
func process() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("process failed: %w", err)
    }
    return nil
}

3. Exposing internal errors

// ❌ Wrong - exposing database details to API
func (api *API) GetUser(id int) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    return user, err  // Exposes SQL errors!
}

// βœ… Correct - wrap with generic message
func (api *API) GetUser(id int) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user: %w", ErrInternal)
    }
    return user, nil
}

Exercise: File Processor with Error Handling

Task: Create a file processor with proper error handling.

Requirements:

  • Create functions: readFile, processData, writeFile
  • Wrap errors with context at each layer
  • Use sentinel errors for specific conditions
  • Handle errors appropriately in main
Show Solution
package main

import (
    "errors"
    "fmt"
    "os"
    "strings"
)

// Sentinel errors
var (
    ErrEmptyFile = errors.New("file is empty")
    ErrInvalidData = errors.New("invalid data format")
)

// readFile reads a file
func readFile(filename string) (string, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return "", fmt.Errorf("failed to read file %s: %w", filename, err)
    }
    
    if len(data) == 0 {
        return "", fmt.Errorf("file %s: %w", filename, ErrEmptyFile)
    }
    
    return string(data), nil
}

// processData processes the data
func processData(data string) (string, error) {
    if !strings.Contains(data, ":") {
        return "", fmt.Errorf("data processing: %w", ErrInvalidData)
    }
    
    // Simple processing: convert to uppercase
    processed := strings.ToUpper(data)
    return processed, nil
}

// writeFile writes data to a file
func writeFile(filename, data string) error {
    err := os.WriteFile(filename, []byte(data), 0644)
    if err != nil {
        return fmt.Errorf("failed to write file %s: %w", filename, err)
    }
    return nil
}

// processFile orchestrates the entire process
func processFile(inputFile, outputFile string) error {
    // Read
    data, err := readFile(inputFile)
    if err != nil {
        return fmt.Errorf("processFile: %w", err)
    }
    
    // Process
    processed, err := processData(data)
    if err != nil {
        return fmt.Errorf("processFile: %w", err)
    }
    
    // Write
    err = writeFile(outputFile, processed)
    if err != nil {
        return fmt.Errorf("processFile: %w", err)
    }
    
    return nil
}

func main() {
    err := processFile("input.txt", "output.txt")
    if err != nil {
        // Check for specific errors
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Error: Input file does not exist")
        } else if errors.Is(err, ErrEmptyFile) {
            fmt.Println("Error: Input file is empty")
        } else if errors.Is(err, ErrInvalidData) {
            fmt.Println("Error: Data format is invalid")
        } else {
            fmt.Printf("Error: %v\n", err)
        }
        os.Exit(1)
    }
    
    fmt.Println("File processed successfully!")
}

Summary

  • %w verb wraps errors, preserving them for Is/As
  • %v verb formats errors as strings (doesn't wrap)
  • Error chains provide context at each layer
  • errors.Is() checks wrapped errors
  • errors.As() extracts custom error types
  • errors.Unwrap() returns the wrapped error
  • Add context at each layer, but don't over-wrap
  • Log once at the top level, return errors below

What's Next?

Congratulations on completing the Error Handling module! You now understand Go's error philosophy and best practices. Next, you'll dive into Concurrencyβ€”one of Go's most powerful features with goroutines and channels!