DEV Community

Young Gao
Young Gao

Posted on

Pragmatic Error Handling in Go: Patterns That Scale (2026)

Every Go developer has written if err != nil thousands of times. Many coming from languages with exceptions see this as a flaw. I used to agree — until I spent years building production systems in Go and realized that explicit error handling is not a limitation. It is a feature that makes your systems more reliable, more debuggable, and easier to reason about under pressure at 3 AM.

This article walks through the error handling patterns I use in production Go services. No toy examples. Everything here comes from real systems handling real traffic.

Why Go's Error Handling Is Actually Good

Before we get into patterns, let's address the elephant in the room. Go's if err != nil feels verbose because it is verbose. That's the point.

In languages with exceptions, error paths are invisible. A function five levels deep can throw, and unless you've memorized every possible exception in the call chain, you don't know what can fail or where. In Go, every potential failure is right there in your face.

user, err := repo.FindUser(ctx, id)
if err != nil {
    return fmt.Errorf("finding user %s: %w", id, err)
}
Enter fullscreen mode Exit fullscreen mode

This line of code tells you three things immediately:

  1. FindUser can fail.
  2. You've decided what to do about that failure.
  3. The error carries context about what was happening when things went wrong.

That's not boilerplate. That's engineering discipline made explicit. Let's build on it.

Wrapping Errors with Context

The single most impactful error handling improvement you can make is consistent error wrapping. Raw errors without context are nearly useless in production.

Bad:

func GetOrder(ctx context.Context, id string) (*Order, error) {
    row := db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
        return nil, err // "sql: no rows in result set" — useless in a log
    }
    return &o, nil
}
Enter fullscreen mode Exit fullscreen mode

Good:

func GetOrder(ctx context.Context, id string) (*Order, error) {
    row := db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
        return nil, fmt.Errorf("get order %s: %w", id, err)
    }
    return &o, nil
}
Enter fullscreen mode Exit fullscreen mode

The %w verb in fmt.Errorf wraps the original error, preserving the full chain. Now when this shows up in your logs, you see get order abc-123: sql: no rows in result set — immediately actionable.

The wrapping rule: each function adds its own context — what it was trying to do and with what inputs — then wraps the underlying error. This builds a natural call-stack-like trace:

processing payment for user u_42: charging card: stripe api: connection refused
Enter fullscreen mode Exit fullscreen mode

You can read this left to right and understand exactly what happened without opening a single source file.

Wrapping Pitfall: Don't Wrap Twice

A common mistake is wrapping in both the callee and the caller with overlapping information:

// Don't do this
func getUser(id string) (*User, error) {
    u, err := db.Find(id)
    if err != nil {
        return nil, fmt.Errorf("failed to get user from database: %w", err)
    }
    return u, nil
}

func handleRequest(id string) error {
    u, err := getUser(id)
    if err != nil {
        return fmt.Errorf("failed to get user from database: %w", err)
        // Result: "failed to get user from database: failed to get user from database: ..."
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Each layer should add new context. The caller says what it was doing; the callee says what went wrong at its level.

Sentinel Errors

Sentinel errors are package-level variables that represent specific, well-known error conditions. The standard library uses them extensively: io.EOF, sql.ErrNoRows, context.Canceled.

Define sentinel errors when callers need to make decisions based on the kind of error:

package order

import "errors"

var (
    ErrNotFound      = errors.New("order not found")
    ErrAlreadyPaid   = errors.New("order already paid")
    ErrInvalidStatus = errors.New("invalid order status transition")
)
Enter fullscreen mode Exit fullscreen mode

Use them in your code:

func (r *Repository) GetOrder(ctx context.Context, id string) (*Order, error) {
    row := r.db.QueryRowContext(ctx, query, id)
    var o Order
    if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("scanning order %s: %w", id, err)
    }
    return &o, nil
}
Enter fullscreen mode Exit fullscreen mode

Notice: when we return ErrNotFound, we don't wrap sql.ErrNoRows. We're translating a storage-layer error into a domain-level error. The caller shouldn't know or care that SQL is involved.

Custom Error Types

When you need to carry structured data alongside the error, define a custom type:

type ValidationError struct {
    Field   string
    Message string
    Value   any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q: %s (got %v)",
        e.Field, e.Message, e.Value)
}
Enter fullscreen mode Exit fullscreen mode

For richer domain errors, you can embed additional context:

type DomainError struct {
    Code    string // machine-readable: "ORDER_EXPIRED", "INSUFFICIENT_FUNDS"
    Message string // human-readable description
    Err     error  // underlying cause (optional)
}

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

func (e *DomainError) Unwrap() error {
    return e.Err
}
Enter fullscreen mode Exit fullscreen mode

The Unwrap() method is critical — it lets errors.Is and errors.As traverse the error chain through your custom type.

errors.Is and errors.As

These two functions are how you inspect errors in Go 1.13+. They traverse the entire wrapped error chain, which is why Unwrap() matters.

errors.Is: Checking for Specific Errors

order, err := svc.GetOrder(ctx, orderID)
if errors.Is(err, order.ErrNotFound) {
    // Return 404 to the client
    http.Error(w, "order not found", http.StatusNotFound)
    return
}
if err != nil {
    // Something unexpected — log and return 500
    log.Error("unexpected error", "err", err, "order_id", orderID)
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}
Enter fullscreen mode Exit fullscreen mode

errors.Is works through wrapping. Even if the error was wrapped three times, errors.Is(err, ErrNotFound) still returns true.

errors.As: Extracting Error Details

var validErr *ValidationError
if errors.As(err, &validErr) {
    // We can now access validErr.Field, validErr.Message, validErr.Value
    respondJSON(w, http.StatusBadRequest, map[string]any{
        "error": "validation_failed",
        "field": validErr.Field,
        "detail": validErr.Message,
    })
    return
}
Enter fullscreen mode Exit fullscreen mode

errors.As finds the first error in the chain that matches the target type and sets the pointer. This is Go's type-safe alternative to catch (ValidationException e) in other languages.

Implementing Custom Matching

You can define Is on your custom type for semantic matching:

type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %s not found", e.Resource, e.ID)
}

// A NotFoundError "is" ErrNotFound regardless of resource/ID
func (e *NotFoundError) Is(target error) bool {
    if target == ErrNotFound {
        return true
    }
    _, ok := target.(*NotFoundError)
    return ok
}
Enter fullscreen mode Exit fullscreen mode

Now errors.Is(err, ErrNotFound) works even when the actual error is &NotFoundError{Resource: "order", ID: "abc"}.

Error Groups: Handling Concurrent Errors

When you run operations concurrently, you need a strategy for collecting errors. The errgroup package (from golang.org/x/sync) is the standard tool:

import "golang.org/x/sync/errgroup"

func (s *Service) EnrichOrder(ctx context.Context, o *Order) error {
    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        user, err := s.users.Get(ctx, o.UserID)
        if err != nil {
            return fmt.Errorf("fetching user: %w", err)
        }
        o.User = user
        return nil
    })

    g.Go(func() error {
        items, err := s.inventory.GetItems(ctx, o.ItemIDs)
        if err != nil {
            return fmt.Errorf("fetching inventory: %w", err)
        }
        o.Items = items
        return nil
    })

    if err := g.Wait(); err != nil {
        return fmt.Errorf("enriching order %s: %w", o.ID, err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Key behavior: errgroup.WithContext cancels the derived context when any goroutine returns an error. This means if the user fetch fails, the inventory fetch gets cancelled automatically — no wasted work.

For cases where you need all errors (not just the first), use errors.Join from Go 1.20+:

func ValidateOrder(o *Order) error {
    var errs []error

    if o.Total <= 0 {
        errs = append(errs, &ValidationError{
            Field: "total", Message: "must be positive", Value: o.Total,
        })
    }
    if o.UserID == "" {
        errs = append(errs, &ValidationError{
            Field: "user_id", Message: "required",
        })
    }
    if len(o.Items) == 0 {
        errs = append(errs, &ValidationError{
            Field: "items", Message: "at least one item required",
        })
    }

    return errors.Join(errs...) // returns nil if errs is empty
}
Enter fullscreen mode Exit fullscreen mode

errors.Join creates a multi-error that errors.Is and errors.As can traverse into each constituent error. This means callers can still use errors.As(err, &validErr) to extract individual validation failures from the joined result.

Structured Error Logging

Logging errors as unstructured strings is a production anti-pattern. Use structured logging so your observability tools can actually work with error data.

// Using log/slog (standard library, Go 1.21+)
func (h *Handler) HandlePayment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    orderID := chi.URLParam(r, "orderID")

    err := h.service.ProcessPayment(ctx, orderID)
    if err != nil {
        slog.ErrorContext(ctx, "payment processing failed",
            "order_id", orderID,
            "error", err,
            "request_id", middleware.GetReqID(ctx),
            "user_id", auth.UserFromContext(ctx),
        )
        respondError(w, err)
        return
    }

    slog.InfoContext(ctx, "payment processed", "order_id", orderID)
    respondJSON(w, http.StatusOK, map[string]string{"status": "paid"})
}
Enter fullscreen mode Exit fullscreen mode

With structured logging, you can query your log aggregator for things like error="*stripe*" AND order_id="ord_*" instead of regex-parsing free text.

Logging Level Strategy

Not all errors deserve the same log level:

func classifyAndLog(ctx context.Context, err error, msg string, attrs ...any) {
    switch {
    case errors.Is(err, context.Canceled):
        // Client disconnected — normal, not worth alerting on
        slog.DebugContext(ctx, msg, append(attrs, "error", err)...)
    case errors.Is(err, ErrNotFound):
        // Expected business case
        slog.InfoContext(ctx, msg, append(attrs, "error", err)...)
    case isTemporary(err):
        // Transient failures — retryable
        slog.WarnContext(ctx, msg, append(attrs, "error", err)...)
    default:
        // Unexpected — wake someone up
        slog.ErrorContext(ctx, msg, append(attrs, "error", err)...)
    }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling in HTTP Handlers: The Domain Error Pattern

This is the pattern I've used most successfully in production. The idea: your domain/service layer returns domain errors, and a single piece of middleware translates them to HTTP responses.

First, define your domain errors:

package apperr

import "fmt"

type Code string

const (
    CodeNotFound       Code = "NOT_FOUND"
    CodeAlreadyExists  Code = "ALREADY_EXISTS"
    CodeInvalidInput   Code = "INVALID_INPUT"
    CodeUnauthorized   Code = "UNAUTHORIZED"
    CodeForbidden      Code = "FORBIDDEN"
    CodeConflict       Code = "CONFLICT"
    CodeInternal       Code = "INTERNAL"
    CodeUnavailable    Code = "UNAVAILABLE"
)

type Error struct {
    Code    Code   `json:"code"`
    Message string `json:"message"`
    Err     error  `json:"-"` // never serialize the internal error
}

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

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

// Constructor helpers keep call sites clean
func NotFound(msg string, args ...any) *Error {
    return &Error{Code: CodeNotFound, Message: fmt.Sprintf(msg, args...)}
}

func InvalidInput(msg string, args ...any) *Error {
    return &Error{Code: CodeInvalidInput, Message: fmt.Sprintf(msg, args...)}
}

func Conflict(msg string, args ...any) *Error {
    return &Error{Code: CodeConflict, Message: fmt.Sprintf(msg, args...)}
}

func Forbidden(msg string, args ...any) *Error {
    return &Error{Code: CodeForbidden, Message: fmt.Sprintf(msg, args...)}
}

func Internal(err error) *Error {
    return &Error{Code: CodeInternal, Message: "internal error", Err: err}
}
Enter fullscreen mode Exit fullscreen mode

Now your service layer speaks in domain terms:

func (s *OrderService) CancelOrder(ctx context.Context, id string) error {
    order, err := s.repo.Get(ctx, id)
    if err != nil {
        return fmt.Errorf("cancel order: %w", err)
    }

    if order.Status == StatusShipped {
        return apperr.Conflict("cannot cancel shipped order %s", id)
    }
    if order.UserID != auth.UserFromContext(ctx) {
        return apperr.Forbidden("not your order")
    }

    if err := s.repo.UpdateStatus(ctx, id, StatusCancelled); err != nil {
        return fmt.Errorf("cancel order %s: %w", id, err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The HTTP translation happens in one place:

func respondError(w http.ResponseWriter, err error) {
    var appErr *apperr.Error
    if !errors.As(err, &appErr) {
        appErr = apperr.Internal(err)
    }

    status := mapCodeToHTTP(appErr.Code)
    respondJSON(w, status, map[string]any{
        "error": map[string]any{
            "code":    appErr.Code,
            "message": appErr.Message,
        },
    })
}

func mapCodeToHTTP(code apperr.Code) int {
    switch code {
    case apperr.CodeNotFound:
        return http.StatusNotFound
    case apperr.CodeAlreadyExists:
        return http.StatusConflict
    case apperr.CodeInvalidInput:
        return http.StatusBadRequest
    case apperr.CodeUnauthorized:
        return http.StatusUnauthorized
    case apperr.CodeForbidden:
        return http.StatusForbidden
    case apperr.CodeConflict:
        return http.StatusConflict
    case apperr.CodeUnavailable:
        return http.StatusServiceUnavailable
    default:
        return http.StatusInternalServerError
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern gives you:

  • Clean service code that doesn't know about HTTP
  • Consistent API responses across every endpoint
  • Safe error handling — internal errors never leak to clients (note the json:"-" tag on Err)
  • Easy testing — assert on error codes, not HTTP status codes

Common Anti-Patterns to Avoid

1. Logging and Returning

// WRONG: This causes duplicate log entries up the chain
if err != nil {
    log.Error("failed to get user", "err", err)
    return fmt.Errorf("getting user: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

The rule: either handle the error (log it, return a response, retry) or return it. Never both. If you log and return, every caller that also logs creates exponentially duplicated log lines.

2. Swallowing Errors

// WRONG: Silent failure is the worst kind of failure
result, _ := json.Marshal(data)
Enter fullscreen mode Exit fullscreen mode

The only time you should ignore an error is when you truly don't care about the outcome AND have documented why:

// Best-effort cache update — failure is non-critical
_ = cache.Set(ctx, key, value, ttl)
Enter fullscreen mode Exit fullscreen mode

3. Checking Error Strings

// WRONG: Fragile, breaks if error message changes
if strings.Contains(err.Error(), "not found") {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Use errors.Is or errors.As. Always. String matching is a maintenance nightmare waiting to happen.

4. Wrapping with Non-Descriptive Messages

// WRONG: Adds noise, not signal
return fmt.Errorf("error: %w", err)
return fmt.Errorf("an error occurred: %w", err)
return fmt.Errorf("failed: %w", err)
Enter fullscreen mode Exit fullscreen mode

Each wrapping should add what operation was being attempted and with what parameters:

// RIGHT: Actionable context
return fmt.Errorf("updating user %s email to %q: %w", userID, newEmail, err)
Enter fullscreen mode Exit fullscreen mode

5. Using panic for Error Handling

// WRONG: panic is not control flow
func MustGetConfig(key string) string {
    v, err := getConfig(key)
    if err != nil {
        panic(err)
    }
    return v
}
Enter fullscreen mode Exit fullscreen mode

Reserve panic for truly unrecoverable situations during initialization (e.g., a required config file is missing at startup). Never panic in code that handles requests.

6. Over-Wrapping in Utility Functions

// WRONG: os.ReadFile already includes the path in its error
func ReadFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("ReadFile failed for path %s: %w", path, err)
    }
    return data, nil
}
Enter fullscreen mode Exit fullscreen mode

os.ReadFile already includes the path in its error message. Wrapping at this level adds noise. Wrap at the level where you have business context:

cfg, err := ReadFile(configPath)
if err != nil {
    return fmt.Errorf("loading app config: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here is a complete handler showing these patterns working in concert:

func (h *PaymentHandler) CreatePayment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    var req CreatePaymentRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, apperr.InvalidInput("malformed request body"))
        return
    }

    if err := req.Validate(); err != nil {
        respondError(w, err) // Validate returns *apperr.Error
        return
    }

    payment, err := h.service.CreatePayment(ctx, req.OrderID, req.Method)
    if err != nil {
        slog.ErrorContext(ctx, "payment creation failed",
            "order_id", req.OrderID,
            "method", req.Method,
            "error", err,
        )
        respondError(w, err)
        return
    }

    slog.InfoContext(ctx, "payment created",
        "payment_id", payment.ID,
        "order_id", req.OrderID,
    )
    respondJSON(w, http.StatusCreated, payment)
}
Enter fullscreen mode Exit fullscreen mode

And the service layer that handles business logic and returns domain errors:

func (s *PaymentService) CreatePayment(
    ctx context.Context, orderID, method string,
) (*Payment, error) {
    order, err := s.orders.Get(ctx, orderID)
    if err != nil {
        return nil, fmt.Errorf("create payment: %w", err)
    }

    if order.Status != StatusPending {
        return nil, apperr.Conflict(
            "order %s is %s, expected pending", orderID, order.Status,
        )
    }

    charge, err := s.stripe.Charge(ctx, order.Total, method)
    if err != nil {
        var stripeErr *stripe.Error
        if errors.As(err, &stripeErr) && stripeErr.Code == stripe.CardDeclined {
            return nil, apperr.InvalidInput("card was declined")
        }
        return nil, apperr.Internal(
            fmt.Errorf("charging order %s: %w", orderID, err),
        )
    }

    payment := &Payment{
        ID:       uuid.New().String(),
        OrderID:  orderID,
        ChargeID: charge.ID,
        Amount:   order.Total,
        Status:   "completed",
    }

    if err := s.repo.Save(ctx, payment); err != nil {
        // Payment was charged but save failed — critical inconsistency
        slog.ErrorContext(ctx, "CRITICAL: payment charged but save failed",
            "charge_id", charge.ID,
            "order_id", orderID,
            "error", err,
        )
        return nil, apperr.Internal(
            fmt.Errorf("saving payment for order %s: %w", orderID, err),
        )
    }

    return payment, nil
}
Enter fullscreen mode Exit fullscreen mode

Notice how the service:

  • Translates external errors (Stripe) into domain errors
  • Adds context at each wrapping level
  • Uses appropriate error codes (Conflict, InvalidInput, Internal)
  • Logs at the right level — only the critical case (payment charged but save failed) logs in the service; normal errors propagate up to the handler

Final Thoughts

Go's error handling is verbose by design. That verbosity buys you explicitness, and explicitness buys you reliability. The patterns in this article — consistent wrapping, domain errors, structured logging, centralized HTTP translation — turn that verbosity into a system that's genuinely pleasant to debug at 3 AM.

The key takeaways:

  1. Wrap with context at every level — use %w and describe what you were doing.
  2. Use sentinel errors and custom types for errors callers need to act on.
  3. Translate errors at boundaries — storage errors become domain errors, domain errors become HTTP responses.
  4. Log or return, never both — pick one responsibility per function.
  5. Use errors.Is and errors.As — never string-match errors.
  6. Structure your logs — your future self will thank you.

Error handling isn't the glamorous part of Go. But it's the part that determines whether your 2 AM pages are "look at this clear error chain" or "something went wrong somewhere, good luck."

Choose the first one.


If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.


You Might Also Like

Follow me for more production-ready backend content!


If this helped you, buy me a coffee on Ko-fi!

Top comments (0)