Learn how to handle errors effectively in Go with best practices, custom error types, error wrapping, and practical patterns for production code.

Error Handling Best Practices in Go


Error handling in Go is explicit and straightforward, but mastering it requires understanding the idioms and patterns that make Go code robust and maintainable. In this post, we’ll explore error handling best practices that will help you write production-quality Go code.

The Basics of Error Handling in Go

Go doesn’t have exceptions. Instead, functions return an error type as their last return value. This explicit approach forces you to think about error cases at every step.

func DoSomething() (string, error) {
    result, err := someOperation()
    if err != nil {
        return "", err
    }
    return result, nil
}

Always Check Errors

The most important rule: never ignore errors. The blank identifier _ should rarely be used with errors.

// Bad - error is ignored
result, _ := DoSomething()

// Good - error is handled
result, err := DoSomething()
if err != nil {
    return fmt.Errorf("failed to do something: %w", err)
}

Creating Custom Error Types

For complex applications, custom error types provide more context and enable better error handling.

type ValidationError struct {
    Field   string
    Message string
}

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

func ValidateUser(name string) error {
    if name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "cannot be empty",
        }
    }
    return nil
}

Checking Error Types

Use errors.As to check for specific error types:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
    fmt.Printf("Validation error on field: %s\n", validationErr.Field)
}

Error Wrapping with Context

Go 1.13 introduced error wrapping with %w, which preserves the original error while adding context.

func GetUser(id int) (*User, error) {
    user, err := db.FindUser(id)
    if err != nil {
        return nil, fmt.Errorf("GetUser(%d): %w", id, err)
    }
    return user, nil
}

Unwrapping Errors

Use errors.Is to check if an error matches a specific value in the chain:

if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}

Sentinel Errors

Define package-level error variables for common error conditions:

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

func GetResource(id string) (*Resource, error) {
    if id == "" {
        return nil, ErrInvalidInput
    }
    // ...
}

Best Practices

1. Add Context to Errors

Always add meaningful context when propagating errors up the call stack.

// Poor context
return err

// Better context
return fmt.Errorf("failed to process order %s: %w", orderID, err)

2. Handle Errors Once

Don’t log an error and then return it. Either handle it (log, recover) or return it, not both.

// Bad - error is handled twice
if err != nil {
    log.Printf("error: %v", err)
    return err
}

// Good - error is returned with context, logged at top level
if err != nil {
    return fmt.Errorf("processing failed: %w", err)
}

3. Use defer for Cleanup

When you need to perform cleanup regardless of errors, use defer:

func ProcessFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("opening file: %w", err)
    }
    defer f.Close()

    // Process the file...
    return nil
}

4. Return Early

Check errors and return early to keep the happy path left-aligned:

func Process(data []byte) error {
    if len(data) == 0 {
        return ErrEmptyData
    }

    result, err := parse(data)
    if err != nil {
        return fmt.Errorf("parsing data: %w", err)
    }

    if err := validate(result); err != nil {
        return fmt.Errorf("validating result: %w", err)
    }

    return save(result)
}

5. Consider Error Behavior, Not Type

When designing APIs, think about what callers need to do with errors:

// Allows callers to check if they should retry
type TemporaryError interface {
    Temporary() bool
}

func IsTemporary(err error) bool {
    var temp TemporaryError
    return errors.As(err, &temp) && temp.Temporary()
}

Testing Error Handling

Write tests that verify your error handling works correctly:

func TestGetUser_NotFound(t *testing.T) {
    _, err := GetUser(999)
    if !errors.Is(err, ErrUserNotFound) {
        t.Errorf("expected ErrUserNotFound, got %v", err)
    }
}

func TestValidateUser_EmptyName(t *testing.T) {
    err := ValidateUser("")
    var validationErr *ValidationError
    if !errors.As(err, &validationErr) {
        t.Fatal("expected ValidationError")
    }
    if validationErr.Field != "name" {
        t.Errorf("expected field 'name', got %s", validationErr.Field)
    }
}

Wrapping Up

Effective error handling in Go comes down to a few key principles:

  1. Always check errors - never ignore them
  2. Add meaningful context when wrapping errors
  3. Use errors.Is and errors.As for error inspection
  4. Define sentinel errors for common conditions
  5. Handle errors once, either by logging/recovering or returning

By following these practices, you’ll write Go code that’s easier to debug, maintain, and extend. For more on Go’s error handling philosophy, check out the official Go Blog post on errors.