Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go (Golang) Error Handling Best Practices 2025: Complete Guide

Error handling is one of the most important parts of Go programming – and also one of the easiest to get wrong. In 2025, with Go 1.21+ and an increasingly mature ecosystem, we finally have a set of battle‑tested patterns for building production‑grade error handling in Go services.

In this guide you’ll learn:

  • ✅ Modern Go error handling patterns (beyond just if err != nil)
  • ✅ When to use sentinel errors vs wrapped errors vs custom types
  • ✅ How to design error stacks that are debuggable in production
  • ✅ How to connect errors with logging, metrics, and tracing
  • ✅ Common anti‑patterns that still appear in real projects

Before diving in, if you haven't yet hardened your overall security posture, you should also read 10 Golang Security Gotchas — And the Fixes That Actually Work – proper error handling and security are tightly coupled in production systems.


1. Philosophy: Errors Are Values, Not Exceptions

Go’s design deliberately avoids exceptions. Instead:

  • Functions return errors explicitly
  • Callers must decide how to handle each error
  • Errors are just values that can be:
    • compared
    • wrapped
    • logged
    • transported

The core principle for 2025 remains:

“Handle errors as close as possible to where you have enough context to make a decision.”

  • At low levels: annotate / wrap and bubble up
  • At boundaries (HTTP handler / RPC / CLI): convert to:
    • response codes
    • user‑facing messages
    • metrics / logs

2. Basic Pattern – Still the Foundation

The canonical way is still:

go
func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config %q: %w", path, err)
    }
    return data, nil
}

Key points:

  • Always return error as the last return value
  • Don’t hide the error – bubble it up with context (%w for wrapping)
  • Use fmt.Errorf("operation: %w", err) to build a useful error stack

In 2025, we strongly recommend you wrap every non‑trivial error going up a boundary:

go
func loadConfig(path string) (*Config, error) {
    data, err := readConfig(path)
    if err != nil {
        return nil, fmt.Errorf("load config: %w", err)
    }
    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }
    return &cfg, nil
}

3. Sentinel Errors vs Wrapped Errors vs Custom Types

3.1 Sentinel Errors – Use Sparingly

Sentinel errors are package‑level variables:

go
var ErrNotFound = errors.New("not found")

Use them when:

  • The error has global semantic meaning for your domain
  • Callers need to do branching logic based on it
go
func GetUser(id string) (*User, error) {
    u, err := repo.FindByID(id)
    if errors.Is(err, repo.ErrNotFound) {
        return nil, ErrNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return u, nil
}

Don’t overuse sentinel errors – scattered globals become hard to manage.

3.2 Wrapped Errors – The Default Choice

For most code in 2025:

  • Use fmt.Errorf("context: %w", err) to add layers
  • Use errors.Is / errors.As at decision boundaries
go
if err := svc.Process(ctx, req); err != nil {
    if errors.Is(err, domain.ErrRateLimited) {
        return http.StatusTooManyRequests, "too many requests"
    }
    logger.Error("process request failed", zap.Error(err))
    return http.StatusInternalServerError, "internal error"
}

3.3 Custom Error Types – For Rich Semantics

When you need structured information, define custom types:

go
type ValidationError struct {
    Field   string
    Message string
}

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

And use errors.As to inspect:

go
var vErr *ValidationError
if errors.As(err, &vErr) {
    return http.StatusBadRequest, vErr.Error()
}

Rule of thumb (2025):

  • Sentinel: domain‑level flags (ErrNotFound, ErrConflict)
  • Wrapped: default for most IO / infra errors
  • Custom types: when HTTP / gRPC / logs need extra fields

4. Designing an Error Stack That Works in Production

In real systems, you don’t care only about the top error message – you care about:

  • Where it failed (service / module / function)
  • Why it failed (upstream / downstream / user input / config)
  • How often it happens (metrics)
  • What to show to the user vs what to log

4.1 A Practical Pattern

go
// domain/service.go
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    if err := s.validator.ValidateCreateOrder(req); err != nil {
        return nil, fmt.Errorf("validate create order: %w", err)
    }
    order, err := s.repo.Create(ctx, req)
    if err != nil {
        return nil, fmt.Errorf("persist order: %w", err)
    }
    return order, nil
}

// transport/http.go
func (h *Handler) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
    // ...
    order, err := h.svc.CreateOrder(r.Context(), &req)
    if err != nil {
        status, msg := h.mapError(err)
        h.logger.Error("create order failed", zap.Error(err))
        http.Error(w, msg, status)
        return
    }
    // ...
}
go
func (h *Handler) mapError(err error) (int, string) {
    var vErr *ValidationError
    switch {
    case errors.As(err, &vErr):
        return http.StatusBadRequest, vErr.Error()
    case errors.Is(err, repo.ErrConflict):
        return http.StatusConflict, "order already exists"
    default:
        return http.StatusInternalServerError, "internal server error"
    }
}

This separates:

  • Where to log (handler)
  • Where to enrich errors (service)
  • Where to decide user‑facing messages (transport)

5. Logging, Metrics, Tracing – Errors in the Observability Loop

Error handling doesn’t live alone – it must integrate with observability. If你还没有建立一套完整的可观测性方案,可以参考 From Trace to Insight: A Closed-Loop Observability Practice for Go Projects

5.1 Logging with Context

go
func (s *Service) ProcessPayment(ctx context.Context, req *PaymentRequest) error {
    if err := s.gateway.Charge(ctx, req); err != nil {
        s.logger.Error("charge failed",
            zap.String("order_id", req.OrderID),
            zap.String("user_id", req.UserID),
            zap.Error(err),
        )
        return fmt.Errorf("charge order %s: %w", req.OrderID, err)
    }
    return nil
}

5.2 Metrics for Error Rates

go
var (
    errCounter = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "service_errors_total",
            Help: "Total number of service errors",
        },
        []string{"operation", "reason"},
    )
)

func (s *Service) wrapError(op, reason string, err error) error {
    if err != nil {
        errCounter.WithLabelValues(op, reason).Inc()
        return fmt.Errorf("%s: %w", op, err)
    }
    return nil
}

5.3 Tracing with Error Spans

When using OpenTelemetry:

go
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)

This allows you to:

  • Quickly see where errors happen in a trace
  • Correlate with latency and resource usage

6. Common Anti‑Patterns in 2025 (Still Everywhere)

6.1 Swallowing Errors

go
// ❌ Don't do this
if err := doSomething(); err != nil {
    // ignore
}

Always either:

  • Return the error
  • Log with enough context

6.2 Panics Instead of Errors

Use panic only for:

  • Programmer errors (impossible states)
  • Initialization failures that must abort the process

For everything else – return error.

6.3 Over‑Logging the Same Error

Don’t log the same error at every layer:

  • Pick one place (usually transport boundary) for ERROR level
  • Inner layers can use DEBUG logs or no logs, just wrapping

7. Error Handling Patterns for Concurrency

In concurrent code, error handling becomes trickier. If you’re not comfortable with advanced patterns yet,建议先阅读 Advanced Go Concurrency Patterns for Scalable Applications

7.1 Error Group Pattern

go
type Result struct {
    Data string
    Err  error
}

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]string, len(urls))

    for i, url := range urls {
        i, url := i, url
        g.Go(func() error {
            data, err := fetch(ctx, url)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", url, err)
            }
            results[i] = data
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

7.2 Channel‑Based Error Fan‑in

go
func processWorkers(ctx context.Context, jobs <-chan Job) error {
    errCh := make(chan error, 1)
    var wg sync.WaitGroup

    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                if err := handleJob(ctx, job); err != nil {
                    select {
                    case errCh <- err:
                    default:
                    }
                    return
                }
            }
        }()
    }

    go func() {
        wg.Wait()
        close(errCh)
    }()

    if err := <-errCh; err != nil {
        return err
    }
    return nil
}

8. Checklist: Production‑Ready Error Handling in Go

Use this checklist to review your services:


9. Where to Go Next

To build truly production‑ready Go services, error handling must be combined with:

Error handling is not “just boilerplate” – it’s how your system explains failures to you in production.
Investing a bit of design time now will save you countless hours of debugging later.

Happy (and safe) Go coding in 2025! 🚀

Last updated: