BLOG POSTS
Creating Custom Errors in Go – Best Practices

Creating Custom Errors in Go – Best Practices

Error handling in Go is one of those topics that seems straightforward at first but reveals layers of complexity as your applications grow. While Go’s explicit error handling approach is refreshing compared to exception-heavy languages, creating meaningful custom errors that provide context, debugging information, and proper handling can make or break your application’s maintainability. This post will walk you through the best practices for crafting custom errors in Go, covering everything from basic implementations to advanced patterns that’ll save you hours of debugging in production.

Understanding Go’s Error Interface

Before diving into custom errors, let’s revisit Go’s built-in error interface. It’s deceptively simple:

type error interface {
    Error() string
}

This simplicity is both a blessing and a curse. Any type that implements an `Error()` method satisfies the interface, giving you incredible flexibility. However, this also means you need to think carefully about how to structure your custom errors to provide maximum value.

The standard library provides some basic error creation functions, but they’re quite limited:

// Basic error creation
err1 := errors.New("something went wrong")
err2 := fmt.Errorf("user %s not found", username)

These work fine for simple cases, but real-world applications need more context, better categorization, and structured information that can be programmatically handled.

Step-by-Step Implementation Guide

Let’s build custom errors from the ground up, starting with simple implementations and moving to more sophisticated patterns.

Basic Custom Error Types

The simplest custom error is a string-based type:

type ValidationError string

func (e ValidationError) Error() string {
    return string(e)
}

// Usage
func validateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return ValidationError("invalid email format")
    }
    return nil
}

This approach works well for simple categorization, but lacks context and flexibility.

Structured Custom Errors

For more complex scenarios, use struct-based errors that can carry additional context:

type DatabaseError struct {
    Operation string
    Table     string
    Err       error
    Code      int
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("database error: %s on table %s (code: %d): %v", 
        e.Operation, e.Table, e.Code, e.Err)
}

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

// Usage
func getUserByID(id int) (*User, error) {
    // Simulate database operation
    if id < 0 {
        return nil, &DatabaseError{
            Operation: "SELECT",
            Table:     "users",
            Code:      1001,
            Err:       errors.New("negative ID not allowed"),
        }
    }
    return nil, nil
}

Notice the `Unwrap()` method - this is crucial for error chain handling introduced in Go 1.13.

Error Wrapping and Chains

Go 1.13 introduced error wrapping, which is essential for maintaining error context through call stacks:

type ServiceError struct {
    Service string
    Err     error
}

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

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

// Layered error handling
func processUser(userID int) error {
    user, err := getUserByID(userID)
    if err != nil {
        return &ServiceError{
            Service: "UserProcessor",
            Err:     fmt.Errorf("failed to fetch user %d: %w", userID, err),
        }
    }
    // Process user...
    return nil
}

Advanced Error Types with Type Assertions

For sophisticated error handling, create types that can be programmatically inspected:

type APIError struct {
    StatusCode int
    Message    string
    Details    map[string]interface{}
    Timestamp  time.Time
}

func (e *APIError) Error() string {
    return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}

func (e *APIError) IsRetryable() bool {
    return e.StatusCode >= 500
}

func (e *APIError) IsClientError() bool {
    return e.StatusCode >= 400 && e.StatusCode < 500
}

// Smart error handling
func handleAPIError(err error) {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        if apiErr.IsRetryable() {
            // Implement retry logic
            log.Printf("Retryable error: %v", apiErr)
        } else {
            log.Printf("Permanent error: %v", apiErr)
        }
    }
}

Real-World Examples and Use Cases

HTTP Service Error Handling

Here's a practical example for a REST API service:

type HTTPError struct {
    StatusCode int    `json:"status_code"`
    Message    string `json:"message"`
    RequestID  string `json:"request_id"`
    Timestamp  int64  `json:"timestamp"`
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s (request: %s)", 
        e.StatusCode, e.Message, e.RequestID)
}

func NewHTTPError(code int, message, requestID string) *HTTPError {
    return &HTTPError{
        StatusCode: code,
        Message:    message,
        RequestID:  requestID,
        Timestamp:  time.Now().Unix(),
    }
}

// Middleware for error handling
func errorMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                httpErr := NewHTTPError(500, "Internal server error", 
                    r.Header.Get("X-Request-ID"))
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(httpErr.StatusCode)
                json.NewEncoder(w).Encode(httpErr)
            }
        }()
        next(w, r)
    }
}

Database Connection Pool Error Handling

For database operations, structured errors help with connection management:

type DBConnectionError struct {
    Host        string
    Port        int
    Database    string
    Timeout     time.Duration
    Attempts    int
    LastAttempt time.Time
}

func (e *DBConnectionError) Error() string {
    return fmt.Sprintf("failed to connect to %s:%d/%s after %d attempts (last: %v)", 
        e.Host, e.Port, e.Database, e.Attempts, e.LastAttempt)
}

func (e *DBConnectionError) ShouldRetry() bool {
    return e.Attempts < 3 && time.Since(e.LastAttempt) > 5*time.Second
}

func connectWithRetry(host string, port int, database string) (*sql.DB, error) {
    var attempts int
    var lastErr error
    
    for attempts < 3 {
        attempts++
        db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%d dbname=%s", 
            host, port, database))
        
        if err == nil {
            return db, nil
        }
        
        lastErr = err
        time.Sleep(time.Duration(attempts) * time.Second)
    }
    
    return nil, &DBConnectionError{
        Host:        host,
        Port:        port,
        Database:    database,
        Attempts:    attempts,
        LastAttempt: time.Now(),
    }
}

Comparison with Error Handling Approaches

Approach Pros Cons Best Use Case
Basic errors.New() Simple, fast, minimal memory No context, hard to debug Simple validation, internal functions
fmt.Errorf() with %w Error wrapping, contextual info String-based, limited structure Adding context to existing errors
Custom struct errors Rich context, type-safe handling More memory, complex setup API services, database operations
Error interfaces Flexible, behavior-based handling Complex design, potential coupling Library design, pluggable systems

Best Practices and Common Pitfalls

Error Creation Best Practices

  • Use pointer receivers for error methods: This prevents copying large error structs and allows for nil checks
  • Include enough context for debugging: User ID, request ID, timestamp, operation details
  • Make errors comparable when possible: Use value types for simple errors that need equality checks
  • Implement Unwrap() for error chains: Essential for errors.Is() and errors.As() to work properly
  • Don't expose internal implementation details: Keep database-specific error codes internal
// Good: Rich context, proper wrapping
type UserServiceError struct {
    UserID    int64     `json:"user_id"`
    Operation string    `json:"operation"`
    Timestamp time.Time `json:"timestamp"`
    Err       error     `json:"-"` // Don't serialize wrapped errors
}

func (e *UserServiceError) Error() string {
    return fmt.Sprintf("user service error: %s for user %d: %v", 
        e.Operation, e.UserID, e.Err)
}

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

// Bad: Exposing internal details
type BadDatabaseError struct {
    SQLQuery string // Don't expose this to clients
    InternalConnectionID string // Internal implementation detail
}

Common Pitfalls to Avoid

  • Don't ignore error wrapping: Always use %w verb when wrapping errors for proper chain handling
  • Avoid overly generic error types: "Error" as a type name provides no useful information
  • Don't create errors in hot paths unnecessarily: Error creation has overhead; validate inputs early
  • Be careful with error comparison: Use errors.Is() and errors.As() instead of direct comparison
// Common mistake: Breaking error chains
func badWrapping(err error) error {
    return fmt.Errorf("operation failed: %v", err) // Wrong: breaks chain
}

func goodWrapping(err error) error {
    return fmt.Errorf("operation failed: %w", err) // Correct: preserves chain
}

// Common mistake: Direct error comparison
func badComparison(err error) bool {
    return err == sql.ErrNoRows // Fragile, breaks with wrapping
}

func goodComparison(err error) bool {
    return errors.Is(err, sql.ErrNoRows) // Correct: works with chains
}

Performance Considerations

Error creation and handling can impact performance, especially in high-throughput services:

// Benchmark results for different error types
// BenchmarkBasicError-8           100000000    10.5 ns/op    0 B/op
// BenchmarkStructError-8          50000000     35.2 ns/op    48 B/op
// BenchmarkWrappedError-8         30000000     45.8 ns/op    64 B/op

// Pre-allocate common errors to avoid allocation overhead
var (
    ErrUserNotFound = errors.New("user not found")
    ErrInvalidInput = errors.New("invalid input")
    ErrUnauthorized = errors.New("unauthorized")
)

// Use error variables for frequent, context-free errors
func validateUser(id int64) error {
    if id <= 0 {
        return ErrInvalidInput // Reuse pre-allocated error
    }
    return nil
}

Integration with Third-Party Libraries

Many popular Go libraries provide their own error types that you can wrap or extend:

// Working with gorm errors
import "gorm.io/gorm"

type RepositoryError struct {
    Repository string
    Operation  string
    EntityID   interface{}
    Err        error
}

func (e *RepositoryError) Error() string {
    return fmt.Sprintf("%s repository error in %s for entity %v: %v",
        e.Repository, e.Operation, e.EntityID, e.Err)
}

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

func getUserByID(db *gorm.DB, id uint) (*User, error) {
    var user User
    err := db.First(&user, id).Error
    
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, &RepositoryError{
                Repository: "User",
                Operation:  "GetByID",
                EntityID:   id,
                Err:        err,
            }
        }
        return nil, fmt.Errorf("database query failed: %w", err)
    }
    
    return &user, nil
}

Error Logging and Monitoring Integration

Structure your custom errors to work well with logging and monitoring systems:

type MonitorableError struct {
    Code       string                 `json:"code"`
    Message    string                 `json:"message"`
    Severity   string                 `json:"severity"`
    Tags       map[string]string      `json:"tags"`
    Metadata   map[string]interface{} `json:"metadata"`
    Timestamp  time.Time              `json:"timestamp"`
    StackTrace string                 `json:"stack_trace,omitempty"`
}

func (e *MonitorableError) Error() string {
    return fmt.Sprintf("[%s] %s: %s", e.Severity, e.Code, e.Message)
}

func (e *MonitorableError) LogFields() map[string]interface{} {
    return map[string]interface{}{
        "error_code": e.Code,
        "severity":   e.Severity,
        "tags":       e.Tags,
        "metadata":   e.Metadata,
    }
}

// Usage with structured logging
func logError(err error) {
    var monErr *MonitorableError
    if errors.As(err, &monErr) {
        log.WithFields(monErr.LogFields()).Error(monErr.Message)
    } else {
        log.Error(err.Error())
    }
}

Creating effective custom errors in Go requires balancing simplicity with functionality. Start with basic implementations for simple use cases, but don't hesitate to create rich, structured error types for complex applications where debugging and error handling are critical. The key is to provide enough context for debugging while maintaining clean, testable code that integrates well with Go's error handling idioms.

For more detailed information about Go's error handling, check out the official Go documentation on error handling and the Go 1.13 error wrapping blog post.



This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.

This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.

Leave a reply

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