Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Mastering Error Handling in Go for Robust Applications

Error handling is a fundamental aspect of writing reliable and maintainable software. Go's approach to error handling, which emphasizes explicit error returns, provides developers with fine-grained control over how errors are propagated and handled. However, as applications grow in complexity, naive error handling can lead to verbose, hard-to-maintain code.

This article delves into advanced error handling patterns in Go, focusing on techniques that enhance code clarity, debuggability, and robustness. We'll explore sentinel errors, error wrapping, custom error types, integration with context.Context, and best practices for testing error paths.

1. The Evolution of Go Error Handling

Go's error handling has evolved significantly since its inception. Understanding this evolution helps appreciate the current best practices.

1.1. Traditional Error Handling

The traditional approach involves checking errors immediately after they occur and propagating them up the call stack.

go
func doSomething() error {
    // ... some operation ...
    if err != nil {
        return err // Propagate the error
    }
    // ... continue if no error ...
    return nil
}

func caller() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("caller failed: %v", err) // Wrap and propagate
    }
    return nil
}

While explicit, this pattern can lead to repetitive if err != nil checks and loss of error context.

1.2. Error Wrapping (Go 1.13+)

Go 1.13 introduced fmt.Errorf with the %w verb and the errors.Unwrap, errors.Is, and errors.As functions, enabling error wrapping and inspection.

go
func doSomething() error {
    // ... some operation ...
    if err != nil {
        return fmt.Errorf("failed to do something: %w", err) // Wrap the error
    }
    return nil
}

func caller() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("caller failed: %w", err) // Wrap again
    }
    return nil
}

// Inspecting wrapped errors
func main() {
    err := caller()
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        
        // Check if a specific error is in the chain
        var someSpecificError *MyError // Assume MyError is a custom error type
        if errors.As(err, &someSpecificError) {
            fmt.Printf("Found MyError: %+v\n", someSpecificError)
        }
        
        // Check for a sentinel error
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("File does not exist")
        }
    }
}

1.3. The errors.Join Function (Go 1.20+)

Go 1.20 introduced errors.Join, which allows combining multiple errors into a single error value.

go
func validateUser(u User) error {
    var errs []error
    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    if !isValidEmail(u.Email) {
        errs = append(errs, errors.New("email is invalid"))
    }
    // Returns nil if errs is empty, otherwise a joined error
    return errors.Join(errs...)
}

func main() {
    u := User{} // Invalid user
    err := validateUser(u)
    if err != nil {
        fmt.Printf("Validation failed: %v\n", err)
        // The error string will contain all individual errors, separated by newlines
    }
}

2. Sentinel Errors vs. Error Types

Choosing between sentinel errors and custom error types is a common decision point.

2.1. Sentinel Errors

Sentinel errors are predefined error values, often defined as package-level variables. They are simple and effective for representing specific, well-known error conditions.

go
package mydb

import "errors"

// Sentinel errors
var (
    ErrNotFound   = errors.New("record not found")
    ErrDuplicate  = errors.New("record already exists")
    ErrInvalidID  = errors.New("invalid record ID")
)

func GetRecord(id string) (*Record, error) {
    // ... database logic ...
    if notFound {
        return nil, ErrNotFound
    }
    // ... 
}

Pros:

  • Simple to define and use.
  • Easy to compare with errors.Is.

Cons:

  • No additional context or data.
  • Can be less descriptive for complex error conditions.

2.2. Custom Error Types

Custom error types are structs that implement the error interface. They allow for richer error information.

go
package mydb

import (
    "fmt"
)

// Custom error type
type DatabaseError struct {
    Op    string // Operation that failed
    Err   error  // Underlying error
    Key   string // Key or ID related to the error
}

func (e *DatabaseError) Error() string {
    if e.Key != "" {
        return fmt.Sprintf("database operation %q failed for key %q: %v", e.Op, e.Key, e.Err)
    }
    return fmt.Sprintf("database operation %q failed: %v", e.Op, e.Err)
}

// Implement Unwrap to enable errors.Unwrap
func (e *DatabaseError) Unwrap() error {
    return e.Err
}

// Implement Is for comparison with errors.Is (optional but useful)
func (e *DatabaseError) Is(target error) bool {
    _, ok := target.(*DatabaseError)
    return ok
}

// Usage in a function
func UpdateRecord(id string, data RecordData) error {
    // ... database logic ...
    if err != nil {
        return &DatabaseError{
            Op:  "update",
            Err: err,
            Key: id,
        }
    }
    return nil
}

Pros:

  • Can carry additional context and data.
  • Enable type-specific error handling with errors.As.
  • More descriptive and structured.

Cons:

  • Slightly more complex to define.
  • API becomes part of the public contract (changes can break compatibility).

3. Error Wrapping Best Practices

Properly wrapping errors is crucial for debugging and maintainability.

3.1. When to Wrap

Wrap an error when adding context that would be valuable for debugging or when the error crosses a significant architectural boundary (e.g., from a repository layer to a service layer).

go
// Repository layer
func (r *UserRepository) GetUserByID(ctx context.Context, id string) (*User, error) {
    query := "SELECT * FROM users WHERE id = ?"
    row := r.db.QueryRowContext(ctx, query, id)
    var u User
    err := row.Scan(&u.ID, &u.Name, &u.Email)
    if err != nil {
        if err == sql.ErrNoRows {
            return nil, fmt.Errorf("user with ID %q: %w", id, ErrNotFound) // Wrap sentinel
        }
        return nil, fmt.Errorf("failed to scan user row: %w", err) // Wrap unexpected error
    }
    return &u, nil
}

// Service layer
func (s *UserService) GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
    user, err := s.repo.GetUserByID(ctx, userID)
    if err != nil {
        // Add service-layer context
        return nil, fmt.Errorf("UserService.GetUserProfile: failed to get user: %w", err)
    }
    // ... build profile ...
    return profile, nil
}

3.2. What Context to Add

Context should be concise but descriptive. Include information like:

  • The operation that failed.
  • Identifiers or keys relevant to the error.
  • Any relevant state or parameters.

Avoid including sensitive information like passwords or personal data.

3.3. Avoiding Over-Wrapping

Don't wrap an error multiple times with the same context. This leads to verbose and unhelpful error messages.

go
// DON'T do this
func doThing() error {
    err := doOtherThing()
    if err != nil {
        return fmt.Errorf("doThing: %w", err) // First wrap
    }
    return nil
}

func doThingWrapper() error {
    err := doThing()
    if err != nil {
        return fmt.Errorf("doThingWrapper: %w", err) // Second wrap, same context
    }
    return nil
}

// DO this instead
func doThing() error {
    // ... doOtherThing logic ...
    // Return the error from doOtherThing directly, or wrap it once with meaningful context
    return fmt.Errorf("doThing: failed to do other thing: %w", err)
}

4. Context Integration

Integrating errors with context.Context is essential for handling cancellations and timeouts gracefully.

4.1. Checking for Context Errors

Always check for context cancellation or timeout errors, especially in long-running operations or loops.

go
func processData(ctx context.Context, data []Item) error {
    for i, item := range data {
        // Check context periodically, especially in loops
        if i%100 == 0 {
            select {
            case <-ctx.Done():
                // Context was cancelled or timed out
                return ctx.Err() // This will be context.Canceled or context.DeadlineExceeded
            default:
            }
        }
        
        // Process the item
        if err := processItem(item); err != nil {
            return fmt.Errorf("failed to process item %d: %w", i, err)
        }
    }
    return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    // Create a context with timeout
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()
    
    data := fetchData() // Assume this gets data to process
    err := processData(ctx, data)
    if err != nil {
        // Handle context errors specifically
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "Request timeout", http.StatusRequestTimeout)
            return
        }
        if errors.Is(err, context.Canceled) {
            // Client disconnected, no need to send a response
            log.Println("Request canceled by client")
            return
        }
        // Handle other errors
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        log.Printf("Error processing data: %v", err)
        return
    }
    // Success response
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "Data processed successfully")
}

4.2. Propagating Context

Ensure that context is passed down through all layers of your application, especially to functions that perform I/O operations.

go
// Good: Context is passed through
func (s *Service) DoWork(ctx context.Context, req Request) error {
    // Pass context to repository call
    data, err := s.repo.FetchData(ctx, req.ID)
    if err != nil {
        return fmt.Errorf("Service.DoWork: failed to fetch data: %w", err)
    }
    
    // Pass context to external API call
    result, err := s.client.CallAPI(ctx, data)
    if err != nil {
        return fmt.Errorf("Service.DoWork: failed to call API: %w", err)
    }
    
    // ... process result ...
    return nil
}

5. Testing Error Paths

Thoroughly testing error paths is as important as testing the happy path. It ensures your error handling logic is correct and your application behaves gracefully under failure conditions.

5.1. Unit Testing Errors

Use table-driven tests to cover various error scenarios.

go
// user_service_test.go
package userservice

import (
    "context"
    "errors"
    "testing"
    
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// Mock repository for testing
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) GetUserByID(ctx context.Context, id string) (*User, error) {
    args := m.Called(ctx, id)
    // The mock might return (*User)(nil) and an error
    user, _ := args.Get(0).(*User)
    return user, args.Error(1)
}

func TestUserService_GetUserProfile_ErrorCases(t *testing.T) {
    tests := []struct {
        name           string
        repoError      error
        expectedError  string // Substring to check in error message
        expectNotFound bool
    }{
        {
            name:          "repository returns generic error",
            repoError:     errors.New("database connection failed"),
            expectedError: "failed to get user",
        },
        {
            name:           "repository returns not found",
            repoError:      ErrNotFound,
            expectedError:  "user not found",
            expectNotFound: true,
        },
        {
            name:          "repository returns context cancelled",
            repoError:     context.Canceled,
            expectedError: "context canceled",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockRepo := new(MockUserRepository)
            service := &UserService{repo: mockRepo}
            
            // Set up the mock to return the specific error
            mockRepo.On("GetUserByID", mock.Anything, "123").Return((*User)(nil), tt.repoError)
            
            _, err := service.GetUserProfile(context.Background(), "123")
            
            // Assertions
            assert.Error(t, err)
            assert.Contains(t, err.Error(), tt.expectedError)
            
            if tt.expectNotFound {
                assert.True(t, errors.Is(err, ErrUserNotFound))
            }
            
            mockRepo.AssertExpectations(t)
        })
    }
}

5.2. Testing Error Wrapping

Ensure that your error wrapping logic is tested, especially when using errors.As or errors.Is.

go
func TestUserService_GetUserProfile_ErrorWrapping(t *testing.T) {
    mockRepo := new(MockUserRepository)
    service := &UserService{repo: mockRepo}
    
    underlyingErr := errors.New("connection refused")
    mockRepo.On("GetUserByID", mock.Anything, "123").Return((*User)(nil), underlyingErr)
    
    _, err := service.GetUserProfile(context.Background(), "123")
    
    assert.Error(t, err)
    
    // Check that the underlying error is wrapped correctly
    assert.True(t, errors.Is(err, underlyingErr))
    
    // Check for custom error type if applicable
    var serviceErr *ServiceError
    if errors.As(err, &serviceErr) {
        assert.Equal(t, "GetUserProfile", serviceErr.Op)
        assert.Equal(t, underlyingErr, serviceErr.Err)
    } else {
        t.Errorf("Expected error to be of type *ServiceError")
    }
}

6. Error Handling in Concurrent Code

Handling errors in concurrent code requires special attention, as errors from goroutines need to be collected and handled by the main goroutine.

6.1. Using Error Channels

An error channel can be used to collect errors from multiple goroutines.

go
func processItemsConcurrently(ctx context.Context, items []Item) error {
    type result struct {
        index int
        err   error
    }
    
    // Channel to collect results (including errors)
    results := make(chan result, len(items))
    
    // Start a goroutine for each item
    for i, item := range items {
        go func(index int, item Item) {
            // Process the item
            err := processItem(item)
            // Send the result (error or nil) to the channel
            results <- result{index: index, err: err}
        }(i, item)
    }
    
    // Collect results
    var errs []error
    for i := 0; i < len(items); i++ {
        res := <-results
        if res.err != nil {
            errs = append(errs, fmt.Errorf("item %d failed: %w", res.index, res.err))
        }
    }
    
    // Return a combined error if any failed
    if len(errs) > 0 {
        return errors.Join(errs...)
    }
    return nil
}

6.2. Using errgroup

For more complex scenarios involving context cancellation, the golang.org/x/sync/errgroup package is very useful.

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

func processItemsWithErrGroup(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    
    for _, item := range items {
        // Capture the loop variable
        item := item
        g.Go(func() error {
            // Check if context was cancelled before starting work
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
            }
            
            // Process the item
            if err := processItem(item); err != nil {
                // Returning an error will cancel the context for other goroutines
                return fmt.Errorf("failed to process item %+v: %w", item, err)
            }
            return nil
        })
    }
    
    // Wait for all goroutines to complete or for one to return an error
    if err := g.Wait(); err != nil {
        return fmt.Errorf("processing items concurrently failed: %w", err)
    }
    return nil
}

7. Domain-Specific Error Handling

In larger applications, it's beneficial to distinguish between different types of errors and handle them appropriately.

7.1. User-Facing vs. Internal Errors

Separate internal technical errors from user-facing messages.

go
// Internal error type
type ValidationError struct {
    Field string
    Msg   string
}

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

// User-facing error type
type APIError struct {
    Code    string // Machine-readable code
    Message string // Human-readable message
    Details map[string]interface{} // Optional extra context
}

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

func (e *APIError) StatusCode() int {
    switch e.Code {
    case "INVALID_INPUT":
        return http.StatusBadRequest
    case "NOT_FOUND":
        return http.StatusNotFound
    case "INTERNAL_ERROR":
        return http.StatusInternalServerError
    default:
        return http.StatusInternalServerError
    }
}

// Business logic function
func CreateUser(ctx context.Context, input CreateUserInput) (*User, error) {
    // Validation
    if input.Email == "" {
        // Return internal error
        internalErr := &ValidationError{Field: "email", Msg: "email is required"}
        // Wrap with user-facing error
        return nil, fmt.Errorf("CreateUser: %w", &APIError{
            Code:    "INVALID_INPUT",
            Message: "Please provide a valid email address.",
            Details: map[string]interface{}{"field": "email"},
        })
    }
    
    // ... rest of creation logic ...
    return user, nil
}

// HTTP handler
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    var input CreateUserInput
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    user, err := CreateUser(r.Context(), input)
    if err != nil {
        var apiErr *APIError
        if errors.As(err, &apiErr) {
            // Send user-friendly error response
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(apiErr.StatusCode())
            json.NewEncoder(w).Encode(map[string]interface{}{
                "error": apiErr.Message,
                "code":  apiErr.Code,
                "details": apiErr.Details,
            })
            return
        }
        
        // Log internal error and send generic message
        log.Printf("Internal error in CreateUserHandler: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
        return
    }
    
    // Success response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

8. Best Practices Summary

  1. Be Explicit: Always handle errors explicitly. Don't ignore them with _.
  2. Wrap with Context: Use fmt.Errorf with %w to wrap errors and add context when propagating them.
  3. Choose the Right Error Type: Use sentinel errors for simple, well-known conditions and custom error types for complex errors that need to carry data.
  4. Don't Over-Wrap: Add context only when it's genuinely helpful and avoid redundant wrapping.
  5. Respect Context: Always check for ctx.Err() in long-running or cancellable operations.
  6. Test Error Paths: Write tests for your error handling logic, just as you would for the happy path.
  7. Log Effectively: Use structured logging to capture full error chains. Avoid logging errors multiple times as they propagate up the stack.
  8. Distinguish Error Types: Separate internal errors from user-facing messages for better security and user experience.
  9. Handle Concurrent Errors: Use error channels or errgroup to manage errors from goroutines.
  10. Keep It Simple: The primary goal of error handling is to make your program correct and robust. Don't over-engineer it.

Conclusion

Mastering error handling in Go is essential for building robust, maintainable, and debuggable applications. By leveraging modern Go features like error wrapping, errors.Join, and context integration, and by following best practices for error design and testing, you can create error handling code that is both powerful and clear.

Remember that error handling is not just about preventing crashes; it's about providing a good experience for users and operators of your software. Well-handled errors turn potential failures into informative feedback, making your applications more resilient and easier to support.

As you continue to develop in Go, keep these patterns and practices in mind, and always strive to make your error handling as clear and helpful as the rest of your code.

Last updated: