Golang Error Handling Best Practices

Error handling is fundamental to writing reliable Go applications. Unlike exceptions in other languages, Go treats errors as explicit values, requiring developers to handle them intentionally. This post covers production-ready patterns and idiomatic approaches to managing errors effectively in Go.

The Idiomatic Error Pattern

Go’s philosophy emphasizes explicit error handling through the error interface. Every function that can fail should return an error as its last return value. This convention makes error paths visible and forces developers to acknowledge failures.

func ReadFile(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return data, nil
}

The pattern is simple: check the error immediately after the operation. Return early if an error occurs, allowing the happy path to flow naturally through the function.

Error Wrapping and Context

Since Go 1.13, the %w verb in fmt.Errorf enables error wrapping. This preserves the original error while adding contextual information, essential for debugging production issues.

if err != nil {
    return nil, fmt.Errorf("failed to process user data: %w", err)
}

Wrapped errors maintain the error chain, allowing callers to use errors.Is() and errors.As() to inspect the underlying error. Never lose error context by discarding the original error.

Custom Error Types for Production

Define custom error types for specific failure scenarios. This enables callers to handle different errors distinctly, improving system resilience.

type ValidationError struct {
    Field   string
    Message string
}

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

Using errors.As(), callers can type-assert and respond accordingly. This pattern is superior to checking error message strings, which is brittle and unmaintainable.

Avoiding Silent Failures

Never ignore errors silently. Even if you decide not to act on an error, explicitly acknowledge it with a blank identifier to signal intentionality.

// Bad: silent error
os.WriteFile("log.txt", data, 0644)

// Good: explicit ignore
_ = os.WriteFile("log.txt", data, 0644)

Better yet, log or return the error. Silently dropping errors masks production bugs and complicates debugging. Linters like errcheck help catch these mistakes.

Error Logging and Monitoring

Structure error logging for observability. Include relevant context: request IDs, timestamps, user information, and the full error chain. Use structured logging libraries like slog or zap in production.

if err != nil {
    log.Error("database query failed", 
        slog.String("query", query),
        slog.String("error", err.Error()),
        slog.String("request_id", requestID),
    )
    return nil, err
}

Structured logs enable efficient searching, alerting, and correlation across distributed systems. Avoid unstructured string concatenation for error messages.

Panic vs Error Returns

Distinguish between recoverable and unrecoverable errors. Use panic only for truly exceptional conditions like initialization failures or programmer errors. Return errors for operational failures like network timeouts or invalid input.

// Unrecoverable: panic
if config == nil {
    panic("config must be initialized before startup")
}

// Recoverable: return error
if user == nil {
    return nil, fmt.Errorf("user not found: %w", ErrUserNotFound)
}

Mixing panics with error returns creates unpredictable behavior. Establish clear conventions within your team about when each approach applies.

Testing Error Paths

Write tests specifically for error conditions. Use dependency injection and mocks to simulate failures reliably.

func TestUserServiceError(t *testing.T) {
    mockDB := &MockDatabase{
        QueryError: sql.ErrNoRows,
    }
    service := NewUserService(mockDB)
    _, err := service.GetUser("123")
    if !errors.Is(err, sql.ErrNoRows) {
        t.Fatalf("expected ErrNoRows, got %v", err)
    }
}

Error handling code is just as important as happy path logic. Ensure error scenarios receive test coverage equal to success paths.

Frequently Asked Questions

Should I always wrap errors in Go?

Yes, wrapping errors with context is a best practice. Use fmt.Errorf("context: %w", err) to preserve the error chain for debugging and error inspection. Never lose the original error in production code.

What’s the difference between errors.Is() and errors.As()?

errors.Is() checks if a specific error appears anywhere in the error chain using == comparison. errors.As() retrieves the first error of a specific type in the chain, enabling type assertion and accessing custom fields.

Can I use defer to handle errors?

Defer is useful for cleanup operations like closing files or releasing locks, but it’s not ideal for error handling logic. Check errors immediately after operations occur for clearer, more maintainable code flow.

How do I handle errors in goroutines?

Return errors through channels or use a sync.WaitGroup with error collection. Never ignore goroutine errors; send them back to the main goroutine for proper handling and logging to prevent silent failures.

Is it bad practice to log and return the same error?

It depends on context. Log and return at the system boundary (HTTP handlers, CLI tools) to ensure observability. Avoid logging and returning at every layer to prevent duplicate logs, which pollute output and hinder debugging.



Comments

Leave a Reply

Your email address will not be published. Required fields are marked *