BLOG POSTS
How to Add Extra Information to Errors in Go

How to Add Extra Information to Errors in Go

Error handling in Go can quickly become verbose and unhelpful when you’re dealing with complex applications spanning multiple layers of abstraction. While Go’s built-in error interface is simple and elegant, it often leaves developers wanting more context about where things went wrong and why. This post will walk you through proven techniques for enriching your errors with stack traces, custom context, and structured information that will save you hours of debugging time and make your applications more maintainable.

How Error Wrapping Works in Go

Go 1.13 introduced the errors package with built-in support for error wrapping, fundamentally changing how we handle error context. The key concept revolves around creating error chains where each layer can add its own context while preserving the original error.

package main

import (
    "errors"
    "fmt"
)

func connectDatabase() error {
    return errors.New("connection refused")
}

func initializeApp() error {
    if err := connectDatabase(); err != nil {
        return fmt.Errorf("failed to initialize database: %w", err)
    }
    return nil
}

func main() {
    if err := initializeApp(); err != nil {
        fmt.Printf("Error: %v\n", err)
        
        // Unwrap to get the original error
        if originalErr := errors.Unwrap(err); originalErr != nil {
            fmt.Printf("Original: %v\n", originalErr)
        }
    }
}

The magic happens with the %w verb in fmt.Errorf, which creates a wrapped error that implements the Unwrap() method. This allows you to traverse the error chain while maintaining type information for error checking.

Building Custom Error Types with Rich Context

While error wrapping is useful, custom error types give you complete control over the information you capture. Here’s a practical implementation that includes HTTP status codes, error categories, and debugging context:

package errors

import (
    "fmt"
    "runtime"
    "time"
)

type DetailedError struct {
    Code      string
    Message   string
    HTTPCode  int
    Category  string
    Timestamp time.Time
    File      string
    Line      int
    Function  string
    Cause     error
    Context   map[string]interface{}
}

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

func (e *DetailedError) Unwrap() error {
    return e.Cause
}

func New(code, message string) *DetailedError {
    pc, file, line, _ := runtime.Caller(1)
    fn := runtime.FuncForPC(pc)
    
    return &DetailedError{
        Code:      code,
        Message:   message,
        Timestamp: time.Now(),
        File:      file,
        Line:      line,
        Function:  fn.Name(),
        Context:   make(map[string]interface{}),
    }
}

func (e *DetailedError) WithHTTPCode(code int) *DetailedError {
    e.HTTPCode = code
    return e
}

func (e *DetailedError) WithCategory(category string) *DetailedError {
    e.Category = category
    return e
}

func (e *DetailedError) WithContext(key string, value interface{}) *DetailedError {
    e.Context[key] = value
    return e
}

func (e *DetailedError) Wrap(cause error) *DetailedError {
    e.Cause = cause
    return e
}

This approach gives you a fluent interface for building rich error objects:

func processUserData(userID string) error {
    if userID == "" {
        return New("INVALID_USER_ID", "User ID cannot be empty").
            WithHTTPCode(400).
            WithCategory("validation").
            WithContext("userID", userID)
    }
    
    if err := validateUser(userID); err != nil {
        return New("USER_VALIDATION_FAILED", "Failed to validate user").
            WithHTTPCode(422).
            WithCategory("business_logic").
            WithContext("userID", userID).
            Wrap(err)
    }
    
    return nil
}

Implementing Stack Traces for Better Debugging

Stack traces are invaluable for tracking down issues in production environments. Here’s how to implement automatic stack trace capture:

package errors

import (
    "fmt"
    "runtime"
    "strings"
)

type StackFrame struct {
    Function string
    File     string
    Line     int
}

type ErrorWithStack struct {
    Err    error
    Stack  []StackFrame
    Message string
}

func (e *ErrorWithStack) Error() string {
    if e.Message != "" {
        return e.Message
    }
    return e.Err.Error()
}

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

func (e *ErrorWithStack) StackTrace() string {
    var builder strings.Builder
    for i, frame := range e.Stack {
        builder.WriteString(fmt.Sprintf("%d. %s\n", i+1, frame.Function))
        builder.WriteString(fmt.Sprintf("   %s:%d\n", frame.File, frame.Line))
    }
    return builder.String()
}

func WithStack(err error) *ErrorWithStack {
    if err == nil {
        return nil
    }
    
    const depth = 32
    var pcs [depth]uintptr
    n := runtime.Callers(2, pcs[:])
    
    var stack []StackFrame
    frames := runtime.CallersFrames(pcs[:n])
    
    for {
        frame, more := frames.Next()
        stack = append(stack, StackFrame{
            Function: frame.Function,
            File:     frame.File,
            Line:     frame.Line,
        })
        if !more {
            break
        }
    }
    
    return &ErrorWithStack{
        Err:   err,
        Stack: stack,
    }
}

func Errorf(format string, args ...interface{}) *ErrorWithStack {
    return WithStack(fmt.Errorf(format, args...))
}

Integration with Popular Error Handling Libraries

Several third-party libraries can supercharge your error handling. Here’s a comparison of the most popular options:

Library Stack Traces Error Wrapping Structured Data Performance Impact Best Use Case
pkg/errors Yes Yes No Low Simple stack traces
emperror/errors Yes Yes Yes Medium Enterprise applications
hashicorp/go-multierror No No No Very Low Aggregating multiple errors
cockroachdb/errors Yes Yes Yes Medium Production systems

The pkg/errors library remains popular for its simplicity:

import "github.com/pkg/errors"

func readConfigFile(filename string) (*Config, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return nil, errors.Wrapf(err, "failed to read config file %s", filename)
    }
    
    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, errors.Wrap(err, "failed to parse config JSON")
    }
    
    return &config, nil
}

Real-World Use Cases and Examples

Let’s examine how enhanced error handling works in common scenarios like HTTP APIs, database operations, and microservice communication.

HTTP API Error Handling

package api

type APIError struct {
    Code    string      `json:"code"`
    Message string      `json:"message"`
    Details interface{} `json:"details,omitempty"`
    TraceID string      `json:"trace_id"`
}

func (e APIError) Error() string {
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
}

func handleUserRegistration(w http.ResponseWriter, r *http.Request) {
    traceID := r.Header.Get("X-Trace-ID")
    
    var req RegisterRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        apiErr := APIError{
            Code:    "INVALID_REQUEST_BODY",
            Message: "Failed to parse request body",
            Details: map[string]string{"error": err.Error()},
            TraceID: traceID,
        }
        writeErrorResponse(w, http.StatusBadRequest, apiErr)
        return
    }
    
    if err := validateRegistration(req); err != nil {
        if validationErr, ok := err.(*ValidationError); ok {
            apiErr := APIError{
                Code:    "VALIDATION_FAILED",
                Message: "Request validation failed",
                Details: validationErr.Fields,
                TraceID: traceID,
            }
            writeErrorResponse(w, http.StatusUnprocessableEntity, apiErr)
            return
        }
    }
}

Database Operation Error Context

package database

type DBError struct {
    Operation string
    Table     string
    Query     string
    Args      []interface{}
    Cause     error
    Duration  time.Duration
}

func (e *DBError) Error() string {
    return fmt.Sprintf("database %s on table %s failed: %v", e.Operation, e.Table, e.Cause)
}

func (db *Database) CreateUser(user *User) error {
    start := time.Now()
    query := "INSERT INTO users (email, name, created_at) VALUES ($1, $2, $3)"
    args := []interface{}{user.Email, user.Name, time.Now()}
    
    _, err := db.conn.Exec(query, args...)
    duration := time.Since(start)
    
    if err != nil {
        return &DBError{
            Operation: "INSERT",
            Table:     "users",
            Query:     query,
            Args:      args,
            Cause:     err,
            Duration:  duration,
        }
    }
    
    return nil
}

Best Practices and Common Pitfalls

Performance Considerations

Stack trace generation can be expensive. Here are some benchmarks comparing different approaches:

BenchmarkSimpleError-8           100000000    10.2 ns/op
BenchmarkWrappedError-8          50000000     28.4 ns/op
BenchmarkStackTrace-8            1000000      1847 ns/op
BenchmarkDetailedError-8         5000000      312 ns/op

Key performance guidelines:

  • Only capture stack traces for unexpected errors, not business logic errors
  • Use error codes instead of string matching for error type checking
  • Avoid deep error chains in hot paths
  • Consider lazy stack trace generation for frequently created errors

Error Handling Anti-Patterns

Avoid these common mistakes:

// DON'T: Log and return errors
func badExample() error {
    if err := someOperation(); err != nil {
        log.Printf("Error in badExample: %v", err) // Don't log here
        return err // Let the caller decide what to do
    }
    return nil
}

// DON'T: Lose error context
func anotherBadExample() error {
    if err := someOperation(); err != nil {
        return errors.New("something went wrong") // Original error is lost
    }
    return nil
}

// DO: Add context and let caller handle logging
func goodExample() error {
    if err := someOperation(); err != nil {
        return fmt.Errorf("failed to perform operation in goodExample: %w", err)
    }
    return nil
}

Structured Logging Integration

Combine rich errors with structured logging for maximum debugging power:

import (
    "github.com/sirupsen/logrus"
)

func logDetailedError(err error, logger *logrus.Logger) {
    entry := logger.WithField("error", err.Error())
    
    if detailedErr, ok := err.(*DetailedError); ok {
        entry = entry.WithFields(logrus.Fields{
            "error_code":     detailedErr.Code,
            "error_category": detailedErr.Category,
            "http_code":      detailedErr.HTTPCode,
            "file":           detailedErr.File,
            "line":           detailedErr.Line,
            "context":        detailedErr.Context,
        })
    }
    
    if stackErr, ok := err.(*ErrorWithStack); ok {
        entry = entry.WithField("stack_trace", stackErr.StackTrace())
    }
    
    entry.Error("Operation failed")
}

Advanced Techniques and Tools

For production systems, consider implementing error aggregation and monitoring:

package monitoring

import (
    "context"
    "sync"
    "time"
)

type ErrorAggregator struct {
    errors map[string]*ErrorStats
    mutex  sync.RWMutex
}

type ErrorStats struct {
    Code      string
    Count     int64
    LastSeen  time.Time
    FirstSeen time.Time
    Examples  []string
}

func (ea *ErrorAggregator) Record(err error) {
    ea.mutex.Lock()
    defer ea.mutex.Unlock()
    
    code := "UNKNOWN"
    if detailedErr, ok := err.(*DetailedError); ok {
        code = detailedErr.Code
    }
    
    stats, exists := ea.errors[code]
    if !exists {
        stats = &ErrorStats{
            Code:      code,
            FirstSeen: time.Now(),
            Examples:  make([]string, 0, 10),
        }
        ea.errors[code] = stats
    }
    
    stats.Count++
    stats.LastSeen = time.Now()
    
    if len(stats.Examples) < 10 {
        stats.Examples = append(stats.Examples, err.Error())
    }
}

The techniques covered here will transform your Go applications from black boxes that fail silently into transparent, debuggable systems. Start with error wrapping using the standard library, then gradually introduce custom error types and stack traces where they add the most value. Remember that good error handling is about finding the right balance between information richness and performance overhead.

For additional resources, check out the official Go documentation on error handling and the Go 1.13 errors blog post for deeper insights into the standard library's capabilities.



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