Go Error Handling Best Practices: 6 Production Patterns from Wrapping to Recovery

编程语言

When Lost Error Context Meets Panic Cascades: The Darkest Hour of Go Error Handling

2 AM, payment service error rate spikes to 30%. Logs are filled with "database error: sql: no rows in result set", but there's no clue which endpoint, which SQL query, or what business scenario triggered it. Worse, an uncaught panic in a goroutine takes down the entire HTTP service with 502s. After 4 hours of investigation, the root cause emerges: errors were swallowed layer by layer, context information was completely lost, panic had no recovery, and monitoring alerts were useless.

This isn't an isolated case. Go's if err != nil is simple — just check after every function call — but production error handling is far more than checking for nil. You need to preserve error chains, implement typed errors, gracefully recover from panics, build error middleware chains, and integrate observability. This article covers 6 production-grade error handling patterns to help you build a robust Go error handling system.


Key Takeaways

  • Error Wrapping: Use fmt.Errorf("%w", err) to preserve complete error chains, not string concatenation that loses context
  • Custom Error Types: Use sentinel errors + custom structs to implement business-semantic error classification
  • errors.Is/As: Replace string matching with type-safe error inspection for nested error chains
  • Panic Recovery: Recover panics in HTTP middleware and goroutine entry points to prevent cascading failures
  • Error Middleware Chain: Weave logging, metrics, and tracing into a unified error handling pipeline
  • Production Observability: Integrate errors into distributed tracing and alerting with OpenTelemetry

Table of Contents

  1. Error Handling Architecture Overview
  2. Pattern 1: Error Wrapping with fmt.Errorf and %w verb
  3. Pattern 2: Custom Error Types with Sentinel Errors
  4. Pattern 3: errors.Is / errors.As for Error Inspection
  5. Pattern 4: Panic Recovery Middleware
  6. Pattern 5: Error Middleware Chain
  7. Pattern 6: Production Error Observability with OpenTelemetry
  8. 5 Common Pitfalls and Solutions
  9. 10 Common Error Troubleshooting
  10. Advanced Optimization Techniques
  11. Comparison Analysis
  12. Recommended Online Tools
  13. Summary

Error Handling Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    HTTP Request                              │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Middleware Chain                                             │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ Recovery │→│  Logging │→│ Metrics  │→│ Tracing  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Business Layer                                              │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ fmt.Errorf  │  │ Custom Error │  │ errors.Is/As    │   │
│  │  (%w wrap)  │  │  (sentinel)  │  │  (inspection)   │   │
│  └─────────────┘  └──────────────┘  └──────────────────┘   │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Observability Layer                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │  OpenTelemetry│  │   Alerting   │  │   Dashboard     │  │
│  │    Tracing    │  │   (PagerDuty)│  │   (Grafana)     │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Pattern 1: Error Wrapping with fmt.Errorf and %w verb

Error wrapping is the cornerstone of Go error handling. Core idea: use the %w verb to wrap errors and preserve the complete error chain, rather than string concatenation that loses the original error information.

Anti-Pattern: String Concatenation Loses Context

func getUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("scan user failed: %v", err)
    }
    return &u, nil
}

%v converts the error to a string — the original sql: no rows in result set becomes uninspectable plain text. Callers cannot use errors.Is or errors.As to check the error type.

Correct Approach: Use %w to Preserve Error Chain

package repository

import (
    "database/sql"
    "fmt"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func GetUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("get user id=%d: %w", id, err)
    }
    return &u, nil
}

func HandleGetUser(id int) {
    user, err := GetUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            log.Printf("user not found: id=%d", id)
            return
        }
        log.Printf("unexpected error: %v", err)
    }
    _ = user
}

Multi-Layer Wrapping Error Chain

package service

import (
    "fmt"
    "myapp/repository"
)

func ProcessOrder(orderID int) error {
    user, err := repository.GetUser(orderID)
    if err != nil {
        return fmt.Errorf("process order %d: %w", orderID, err)
    }
    _ = user
    return nil
}

func HandleOrder(orderID int) {
    if err := ProcessOrder(orderID); err != nil {
        var sqlErr *sqlite.Error
        if errors.As(err, &sqlErr) {
            log.Printf("database error: %v", sqlErr)
        }
    }
}

Key Rules:

  • Always use %w instead of %v to wrap errors
  • Add business context when wrapping (function name, parameters, operation description)
  • Error chains should be at most 3-4 layers deep; more indicates abstraction issues
  • Wrap only once at boundaries to avoid redundant wrapping at multiple layers

Pattern 2: Custom Error Types with Sentinel Errors

Custom error types give errors business semantics. Core idea: use sentinel errors for predictable error states, and custom structs to carry structured context.

Sentinel Errors: Predictable Error Constants

package apperr

import "errors"

var (
    ErrNotFound       = errors.New("resource not found")
    ErrUnauthorized   = errors.New("unauthorized access")
    ErrForbidden      = errors.New("forbidden operation")
    ErrConflict       = errors.New("resource conflict")
    ErrRateLimited    = errors.New("rate limit exceeded")
    ErrValidation     = errors.New("validation failed")
    ErrInternal       = errors.New("internal server error")
)

Custom Error Type: Carrying Structured Context

package apperr

import (
    "fmt"
    "time"
)

type DomainError struct {
    Code      string
    Message   string
    Domain    string
    Timestamp time.Time
    Err       error
}

func (e *DomainError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %s: %v", e.Domain, e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s: %s", e.Domain, e.Code, e.Message)
}

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

func NewDomainError(domain, code, message string, err error) *DomainError {
    return &DomainError{
        Code:      code,
        Message:   message,
        Domain:    domain,
        Timestamp: time.Now(),
        Err:       err,
    }
}

Using Custom Errors in Business Layer

package order

import (
    "apperr"
    "fmt"
)

type OrderService struct {
    repo OrderRepository
}

func (s *OrderService) CreateOrder(req CreateOrderRequest) (*Order, error) {
    if err := validateOrder(req); err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "VALIDATION_ERROR",
            fmt.Sprintf("invalid order request: user_id=%d", req.UserID),
            err,
        )
    }

    existing, err := s.repo.FindByUserAndProduct(req.UserID, req.ProductID)
    if err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "DB_ERROR",
            "failed to check existing order",
            err,
        )
    }
    if existing != nil {
        return nil, apperr.NewDomainError(
            "order",
            "CONFLICT",
            fmt.Sprintf("duplicate order: user=%d product=%d", req.UserID, req.ProductID),
            apperr.ErrConflict,
        )
    }

    order := &Order{
        UserID:    req.UserID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
    }
    if err := s.repo.Save(order); err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "DB_ERROR",
            "failed to save order",
            err,
        )
    }
    return order, nil
}

Key Rules:

  • Define sentinel errors with errors.New, naming convention starts with Err
  • Custom error types must implement Error() and Unwrap() methods
  • Error codes use UPPER_SNAKE_CASE (VALIDATION_ERROR) for easy log searching
  • Each business domain defines its own error code namespace to avoid collisions

Pattern 3: errors.Is / errors.As for Error Inspection

Error inspection is the core operation of error handling. Core idea: use errors.Is to check error values and errors.As to extract error types, replacing fragile string matching.

errors.Is: Check for Specific Error Values in the Chain

package handler

import (
    "apperr"
    "database/sql"
    "errors"
    "net/http"
)

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")

    user, err := userService.GetUser(id)
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            http.Error(w, "user not found", http.StatusNotFound)
        case errors.Is(err, apperr.ErrUnauthorized):
            http.Error(w, "unauthorized", http.StatusUnauthorized)
        case errors.Is(err, apperr.ErrRateLimited):
            http.Error(w, "rate limited", http.StatusTooManyRequests)
        default:
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }

    writeJSON(w, http.StatusOK, user)
}

errors.As: Extract Specific Types from the Error Chain

package handler

import (
    "apperr"
    "errors"
    "net/http"
)

func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
    if err := decodeJSON(r, &req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    order, err := orderService.CreateOrder(req)
    if err != nil {
        var domainErr *apperr.DomainError
        if errors.As(err, &domainErr) {
            switch domainErr.Code {
            case "VALIDATION_ERROR":
                http.Error(w, domainErr.Message, http.StatusBadRequest)
            case "CONFLICT":
                http.Error(w, domainErr.Message, http.StatusConflict)
            case "DB_ERROR":
                log.Printf("database error: %v", err)
                http.Error(w, "internal error", http.StatusInternalServerError)
            default:
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
            return
        }

        log.Printf("unhandled error: %v", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusCreated, order)
}

Common Mistake: Using String Matching for Error Inspection

// ❌ Wrong: String matching is fragile and unreliable
if strings.Contains(err.Error(), "not found") {
    // ...
}

// ✅ Correct: Use errors.Is to check error values
if errors.Is(err, apperr.ErrNotFound) {
    // ...
}

// ❌ Wrong: Type assertion doesn't traverse the error chain
if de, ok := err.(*apperr.DomainError); ok {
    // If err is wrapped, ok will be false
}

// ✅ Correct: Use errors.As to traverse the error chain
var de *apperr.DomainError
if errors.As(err, &de) {
    // Works even when err is wrapped multiple layers
}

Key Rules:

  • Never use err.Error() for string matching
  • errors.Is for checking sentinel errors, errors.As for extracting custom error types
  • The second argument to errors.As must be a pointer to the target type
  • Map errors to HTTP status codes uniformly in the handler layer; business layers only return errors

Pattern 4: Panic Recovery Middleware

Panics are Go's "nuclear option" — use them sparingly, but always have defenses. Core idea: recover panics at HTTP middleware and goroutine entry points to prevent cascading failures.

HTTP Server Panic Recovery

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"
)

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf(
                    "[PANIC] path=%s method=%s error=%v\n%s",
                    r.URL.Path, r.Method, rec, stack,
                )
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Goroutine Panic Recovery

package goroutine

import (
    "log"
    "runtime/debug"
)

func SafeGo(fn func(), panicHandler func(recover any, stack []byte)) {
    go func() {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf("[GOROUTINE PANIC] recovered=%v\n%s", rec, stack)
                if panicHandler != nil {
                    panicHandler(rec, stack)
                }
            }
        }()
        fn()
    }()
}

Goroutine Recovery with Error Channel

package worker

import (
    "runtime/debug"
)

type PanicError struct {
    Recover any
    Stack   []byte
}

func (e *PanicError) Error() string {
    return "goroutine panic recovered"
}

func SafeGoWithErr(fn func() error, errCh chan<- error) {
    go func() {
        defer func() {
            if rec := recover(); rec != nil {
                errCh <- &PanicError{
                    Recover: rec,
                    Stack:   debug.Stack(),
                }
            }
        }()
        if err := fn(); err != nil {
            errCh <- err
        }
    }()
}

func ProcessBatch(items []Item) []error {
    var errs []error
    errCh := make(chan error, len(items))

    for _, item := range items {
        SafeGoWithErr(func() error {
            return processItem(item)
        }, errCh)
    }

    for i := 0; i < len(items); i++ {
        if err := <-errCh; err != nil {
            errs = append(errs, err)
        }
    }
    return errs
}

Gin Framework Recovery Middleware

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"

    "github.com/gin-gonic/gin"
)

func GinRecovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf(
                    "[PANIC] path=%s method=%s client_ip=%s error=%v\n%s",
                    c.Request.URL.Path,
                    c.Request.Method,
                    c.ClientIP(),
                    rec,
                    stack,
                )
                c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "error": "internal server error",
                })
            }
        }()
        c.Next()
    }
}

Key Rules:

  • HTTP servers must have recovery in the outermost middleware
  • Every goroutine entry point must have recovery; otherwise panic kills the entire process
  • Always log the full stack trace after recovery, or you can't locate the problem
  • Don't continue processing requests after recovery; return 500
  • Only recover panics from your own code; don't recover standard library panics (e.g., concurrent map writes)

Pattern 5: Error Middleware Chain

The error middleware chain unifies cross-cutting concerns into error handling. Core idea: logging, metrics, and tracing are not scattered in business code, but handled uniformly through a middleware chain.

Error Middleware Architecture

Request → Recovery → Logging → Metrics → Tracing → Handler → Response
              │          │         │          │
              ▼          ▼         ▼          ▼
          log panic  log error  emit counter  span error

Complete Error Middleware Chain Implementation

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"
    "time"
)

type ResponseRecorder struct {
    http.ResponseWriter
    StatusCode int
    Body       []byte
}

func (r *ResponseRecorder) WriteHeader(code int) {
    r.StatusCode = code
    r.ResponseWriter.WriteHeader(code)
}

func (r *ResponseRecorder) Write(b []byte) (int, error) {
    r.Body = append(r.Body, b...)
    return r.ResponseWriter.Write(b)
}

func ErrorChain(next http.Handler) http.Handler {
    return Recovery(
        Logging(
            Metrics(
                Tracing(next),
            ),
        ),
    )
}

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("[PANIC] path=%s error=%v\n%s", r.URL.Path, rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}

        next.ServeHTTP(rec, r)

        duration := time.Since(start)
        if rec.StatusCode >= 400 {
            log.Printf(
                "[ERROR] method=%s path=%s status=%d duration=%s",
                r.Method, r.URL.Path, rec.StatusCode, duration,
            )
        } else {
            log.Printf(
                "[INFO] method=%s path=%s status=%d duration=%s",
                r.Method, r.URL.Path, rec.StatusCode, duration,
            )
        }
    })
}

func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
        next.ServeHTTP(rec, r)

        statusCode := rec.StatusCode
        if statusCode >= 500 {
            errorCounter.WithLabelValues(r.URL.Path, "5xx").Inc()
        } else if statusCode >= 400 {
            errorCounter.WithLabelValues(r.URL.Path, "4xx").Inc()
        }
        requestDuration.WithLabelValues(r.URL.Path).Observe(float64(statusCode))
    })
}

func Tracing(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, span := tracer.Start(r.Context(), r.URL.Path)
        defer span.End()

        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
        next.ServeHTTP(rec, r.WithContext(ctx))

        if rec.StatusCode >= 400 {
            span.SetAttributes(attribute.Int("http.status_code", rec.StatusCode))
            span.SetStatus(codes.Error, http.StatusText(rec.StatusCode))
        }
    })
}

Functional Options Error Middleware

package middleware

import (
    "log"
    "net/http"
)

type ErrorMiddlewareOption struct {
    LogErrors   bool
    EmitMetrics bool
    TraceErrors bool
    OnPanic     func(recover any, stack []byte)
}

func ErrorMiddleware(opts ErrorMiddlewareOption) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if rec := recover(); rec != nil {
                    stack := debug.Stack()
                    if opts.OnPanic != nil {
                        opts.OnPanic(rec, stack)
                    }
                    log.Printf("[PANIC] %v\n%s", rec, stack)
                    http.Error(w, "internal server error", http.StatusInternalServerError)
                }
            }()

            rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
            next.ServeHTTP(rec, r)

            if rec.StatusCode >= 400 {
                if opts.LogErrors {
                    log.Printf("[ERROR] path=%s status=%d", r.URL.Path, rec.StatusCode)
                }
                if opts.EmitMetrics {
                    errorCounter.WithLabelValues(r.URL.Path).Inc()
                }
                if opts.TraceErrors {
                    span := trace.SpanFromContext(r.Context())
                    span.SetStatus(codes.Error, http.StatusText(rec.StatusCode))
                }
            }
        })
    }
}

Key Rules:

  • Recovery must be the outermost layer to catch all panics
  • Logging records request context (path, method, status code, duration)
  • Metrics categorize error rates by path and status code
  • Tracing writes error status to spans for distributed tracing
  • Middleware order: Recovery → Logging → Metrics → Tracing → Handler

Pattern 6: Production Error Observability with OpenTelemetry

Production observability is the last mile of error handling. Core idea: integrate errors into distributed tracing, metrics, and logging — the three pillars of observability — using OpenTelemetry for full-chain visibility from detection to diagnosis.

OpenTelemetry Error Tracing Integration

package telemetry

import (
    "context"
    "fmt"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

func RecordError(ctx context.Context, err error, attrs ...attribute.KeyValue) {
    span := trace.SpanFromContext(ctx)
    if !span.IsRecording() {
        return
    }

    span.SetStatus(codes.Error, err.Error())
    span.SetAttributes(attrs...)
    span.RecordError(err, trace.WithAttributes(attrs...))
}

func WrapWithSpan(ctx context.Context, operation string, fn func(ctx context.Context) error) error {
    ctx, span := otel.Tracer("app").Start(ctx, operation)
    defer span.End()

    if err := fn(ctx); err != nil {
        RecordError(ctx, err,
            attribute.String("operation", operation),
        )
        return err
    }
    return nil
}

Repository Layer with Error Tracing

package repository

import (
    "context"
    "database/sql"
    "fmt"

    "apperr"
    "telemetry"

    "go.opentelemetry.io/otel/attribute"
)

type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id int) (*User, error) {
    ctx, span := otel.Tracer("repository").Start(ctx, "UserRepository.GetUser")
    defer span.End()

    span.SetAttributes(attribute.Int("user.id", id))

    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        if err == sql.ErrNoRows {
            span.SetAttributes(attribute.String("error.type", "not_found"))
            return nil, fmt.Errorf("get user id=%d: %w", id, apperr.ErrNotFound)
        }
        telemetry.RecordError(ctx, err,
            attribute.String("db.operation", "SELECT"),
            attribute.Int("user.id", id),
        )
        return nil, fmt.Errorf("get user id=%d: %w", id, err)
    }

    span.SetAttributes(attribute.String("user.name", u.Name))
    return &u, nil
}

Error Metrics Collection

package metrics

import (
    "go.opentelemetry.io/otel/metric"
)

var (
    errorCounter metric.Int64Counter
    errorLatency metric.Float64Histogram
)

func InitMetrics(meter metric.Meter) {
    errorCounter, _ = meter.Int64Counter(
        "app.errors.total",
        metric.WithDescription("Total number of errors"),
    )
    errorLatency, _ = meter.Float64Histogram(
        "app.errors.latency_seconds",
        metric.WithDescription("Error handling latency"),
    )
}

func RecordErrorMetric(domain, code string) {
    errorCounter.Add(context.Background(), 1,
        metric.WithAttributes(
            attribute.String("domain", domain),
            attribute.String("code", code),
        ),
    )
}

Error Alerting Rules

groups:
  - name: error_alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(app_errors_total{code=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m]))
          > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Error rate exceeds 5%"
          description: "5xx error rate is {{ $value | humanizePercentage }}"

      - alert: PanicDetected
        expr: increase(app_errors_total{code="PANIC"}[1m]) > 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "Panic recovered in production"
          description: "{{ $value }} panic(s) detected in the last minute"

Key Rules:

  • Every error must be recorded to at least one tracing span
  • Error metrics are categorized by domain and code for aggregation analysis
  • Alert rules distinguish 4xx (client errors) from 5xx (server errors)
  • Panic alerts must fire immediately (for: 0m), no accumulation period
  • Logs, metrics, and traces must be correlated by the same trace_id

5 Common Pitfalls and Solutions

Pitfall 1: Swallowing Errors

// ❌ Wrong: Ignoring return values
_, _ = io.Copy(dst, src)

// ✅ Correct: Check errors
written, err := io.Copy(dst, src)
if err != nil {
    return fmt.Errorf("copy data: %w", err)
}

Pitfall 2: Losing Original Information When Wrapping

// ❌ Wrong: Using %v loses the error chain
return fmt.Errorf("query failed: %v", err)

// ✅ Correct: Using %w preserves the error chain
return fmt.Errorf("query failed: %w", err)

Pitfall 3: Unrecovered Panic in Goroutines

// ❌ Wrong: Goroutine panic kills the entire process
go func() {
    result := doSomething()
    ch <- result
}()

// ✅ Correct: Recover at goroutine entry point
go func() {
    defer func() {
        if rec := recover(); rec != nil {
            log.Printf("goroutine panic: %v\n%s", rec, debug.Stack())
            ch <- nil
        }
    }()
    result := doSomething()
    ch <- result
}()

Pitfall 4: Using String Matching for Error Inspection

// ❌ Wrong: String matching is fragile
if strings.Contains(err.Error(), "timeout") {
    // ...
}

// ✅ Correct: Use errors.Is for checking
if errors.Is(err, context.DeadlineExceeded) {
    // ...
}

Pitfall 5: Redundant Wrapping of the Same Error

// ❌ Wrong: Multi-layer redundant wrapping creates verbose error messages
func a() error {
    return fmt.Errorf("a: %w", err)
}
func b() error {
    return fmt.Errorf("b: %w", a())  // "b: a: original error"
}
func c() error {
    return fmt.Errorf("c: %w", b())  // "c: b: a: original error"
}

// ✅ Correct: Wrap only at boundary layers, pass through internally
func a() error {
    return err  // Pass through internally
}
func b() error {
    return a()
}
func c() error {
    return fmt.Errorf("process order: %w", b())  // Wrap once at boundary
}

10 Common Error Troubleshooting

Error Symptom Possible Cause Investigation Method Solution
sql: no rows in result set QueryRow returns no results Check SQL WHERE conditions Use errors.Is(err, sql.ErrNoRows) to check
context deadline exceeded Operation timeout Check context timeout settings Increase timeout or optimize query performance
panic: concurrent map writes Concurrent map writes Check goroutine shared map Use sync.Map or add mutex
panic: send on closed channel Send to closed channel Check channel close timing Use sync.Once or directional channels
connection refused Service not running or wrong port Check service status and port Confirm service is running, check network config
i/o timeout Network timeout Check network connectivity Increase timeout, check firewall rules
record not found Data doesn't exist Check query conditions Distinguish "not found" from "query failed"
duplicate key value Unique constraint violation Check inserted data Use errors.As to extract PG error code
panic: nil pointer dereference Nil pointer dereference Check pointer initialization Add nil checks, use Recovery middleware
too many open files File descriptor exhaustion Check connection pools and file operations Increase ulimit, check for connection leaks

Advanced Optimization Techniques

Technique 1: Error Grouping and Aggregation

package apperr

import "strings"

type ErrorGroup struct {
    Errors []error
}

func (g *ErrorGroup) Add(err error) {
    if err != nil {
        g.Errors = append(g.Errors, err)
    }
}

func (g *ErrorGroup) Err() error {
    if len(g.Errors) == 0 {
        return nil
    }
    return g
}

func (g *ErrorGroup) Error() string {
    msgs := make([]string, len(g.Errors))
    for i, err := range g.Errors {
        msgs[i] = err.Error()
    }
    return strings.Join(msgs, "; ")
}

func (g *ErrorGroup) Unwrap() []error {
    return g.Errors
}

Technique 2: Error Retry with Backoff

package retry

import (
    "context"
    "fmt"
    "math"
    "time"
)

type Config struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
    Retryable   func(error) bool
}

func Do(ctx context.Context, cfg Config, fn func() error) error {
    var lastErr error
    for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
        if err := fn(); err != nil {
            if !cfg.Retryable(err) {
                return fmt.Errorf("non-retryable error: %w", err)
            }
            lastErr = err

            delay := time.Duration(
                float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)),
            )
            if delay > cfg.MaxDelay {
                delay = cfg.MaxDelay
            }

            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        return nil
    }
    return fmt.Errorf("max attempts (%d) exceeded: %w", cfg.MaxAttempts, lastErr)
}

Technique 3: Error Code to HTTP Status Mapping

package handler

import (
    "apperr"
    "errors"
    "net/http"
)

var codeToStatus = map[string]int{
    "NOT_FOUND":       http.StatusNotFound,
    "VALIDATION_ERROR": http.StatusBadRequest,
    "UNAUTHORIZED":    http.StatusUnauthorized,
    "FORBIDDEN":       http.StatusForbidden,
    "CONFLICT":        http.StatusConflict,
    "RATE_LIMITED":    http.StatusTooManyRequests,
    "DB_ERROR":        http.StatusInternalServerError,
}

func MapErrorToHTTP(err error) (int, string) {
    var domainErr *apperr.DomainError
    if errors.As(err, &domainErr) {
        if status, ok := codeToStatus[domainErr.Code]; ok {
            return status, domainErr.Message
        }
    }

    if errors.Is(err, apperr.ErrNotFound) {
        return http.StatusNotFound, "resource not found"
    }
    if errors.Is(err, apperr.ErrUnauthorized) {
        return http.StatusUnauthorized, "unauthorized"
    }

    return http.StatusInternalServerError, "internal server error"
}

Technique 4: Structured Error Logging

package logger

import (
    "log/slog"
    "apperr"
    "errors"
)

func LogError(err error, context ...slog.Attr) {
    attrs := []slog.Attr{
        slog.String("error.message", err.Error()),
    }

    var domainErr *apperr.DomainError
    if errors.As(err, &domainErr) {
        attrs = append(attrs,
            slog.String("error.domain", domainErr.Domain),
            slog.String("error.code", domainErr.Code),
            slog.Time("error.timestamp", domainErr.Timestamp),
        )
    }

    attrs = append(attrs, context...)
    slog.LogAttrs(nil, slog.LevelError, "error occurred", attrs...)
}

Technique 5: Error Assertion Helper Functions

package apperr

import "errors"

func IsNotFound(err error) bool {
    return errors.Is(err, ErrNotFound)
}

func IsConflict(err error) bool {
    return errors.Is(err, ErrConflict)
}

func IsRateLimited(err error) bool {
    return errors.Is(err, ErrRateLimited)
}

func GetDomainError(err error) (*DomainError, bool) {
    var de *DomainError
    return de, errors.As(err, &de)
}

func GetCode(err error) string {
    var de *DomainError
    if errors.As(err, &de) {
        return de.Code
    }
    return "UNKNOWN"
}

Comparison Analysis

Feature fmt.Errorf %w Custom Error Types errors.Is/As Panic Recovery Error Middleware Chain OpenTelemetry
Preserves error chain
Carries business semantics
Type-safe inspection
Prevents process crash
Unified cross-cutting handling
Distributed tracing Partial
Metrics collection
Implementation complexity Low Medium Low Low Medium High
Production necessity ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
Applicable layer All Business Inspection Runtime HTTP Observability

  • JSON Formatter — Format error response JSON structures to quickly troubleshoot API error messages
  • Hash Calculator — Calculate request signatures and data checksums to ensure error log data integrity
  • cURL to Code Converter — Convert cURL commands to Go code for quickly reproducing and debugging HTTP errors

Summary

Go error handling isn't just about adding if err != nil — it's about answering five questions: Where did the error come from? What type is it? How does it propagate? How do you recover? How do you observe it? fmt.Errorf("%w", err) answers "where from", custom error types answer "what type", errors.Is/As answers "how to inspect", Recovery middleware answers "how to recover", and OpenTelemetry answers "how to observe". Master these 6 patterns, and you've mastered the core methodology of production-grade Go error handling.


Further Reading

Try these browser-local tools — no sign-up required →

#Go错误处理#错误包装#自定义错误#panic恢复#Go 1.24#2026#编程语言