Hermes Wiki
Developer/Languages/Go/ErrorHandlingAndLogging/Fundamentals/error-wrapping-and-structured-logging-in-go

Error Wrapping and Structured Logging in Go

Concept

Go treats errors as ordinary values, not exceptions — a function that can fail returns (result, error), and the caller is expected to check err != nil at every call site rather than relying on unwinding a stack. This is deliberate: it forces failure handling to be visible in the code's control flow instead of hidden in a catch block somewhere up the call stack, at the cost of the if err != nil { return err } boilerplate every Go codebase accumulates.

Error wrapping (standardized in Go 1.13) lets a function add context to an error while preserving the original for programmatic inspection: fmt.Errorf("fetching user %d: %w", id, err) wraps err behind a new error whose message includes it. Two functions unwrap that chain: errors.Is(err, target) walks the chain checking for a specific sentinel value (e.g., errors.Is(err, sql.ErrNoRows)), and errors.As(err, &target) walks the chain looking for an error of a specific type, populating target if found (e.g., extracting a custom *ValidationError to read its field name). errors.Join (Go 1.20) combines multiple independent errors into one that both Is and As can still traverse — useful for reporting several validation failures from one call instead of stopping at the first.

Structured logging replaces the older pattern of formatted strings (log.Printf("user %d fetch failed: %v", id, err)) with explicit key-value pairs a machine can parse without regexing the message: slog.Error("user fetch failed", "user_id", id, "err", err). The standard library's log/slog package (Go 1.21) is now the idiomatic choice — a Logger writes Records through a pluggable Handler (TextHandler for local key=value output, JSONHandler for anything feeding a log aggregator), with leveled methods (Debug/Info/Warn/Error) and Attr values for structured fields.

Tradeoffs

Approach Benefit Cost
Sentinel errors (var ErrNotFound = errors.New(...)) + errors.Is Simple, zero-allocation comparison; works well for a small fixed set of known failure modes Doesn't carry any dynamic context (which ID wasn't found) without wrapping it separately
Custom error types + errors.As Carries structured data (field name, retry-after duration, HTTP status) alongside the failure More code per error kind; overkill for a failure the caller only ever checks for existence, not detail
Unwrapped fmt.Errorf("...: %v", err) (pre-1.13 style, or %v instead of %w today) Simplest possible - a human-readable string Breaks the chain — errors.Is/errors.As can no longer see the original error, so callers lose the ability to programmatically distinguish failure causes
log/slog (structured) Machine-parseable without a separate log-parsing layer; consistent shape across every log line in the service Slightly more verbose call sites than a single format string for quick, throwaway debug output
log.Printf-style unstructured logging Fastest to write, reads naturally in a terminal Every downstream consumer (log aggregator, alerting rule, dashboard) needs a regex/parser tuned to the exact message format, which breaks the moment the message wording changes

The wrapping tradeoff and the logging tradeoff compound: a wrapped error chain passed to slog.Error(msg, "err", err) only shows its final .Error() string in the log line unless the handler is taught to walk the chain — logging the wrapped error's message is not the same as logging its structured cause, so a service that cares about querying "how many failures were ErrNotFound vs ErrTimeout" needs to errors.As/errors.Is into a structured attribute before logging, not just log the error value as-is.

When to use / when not to

  • Wrap with %w (not %v) at every layer that adds meaningful context on the way up the call stack, so a caller several layers removed from where the error originated can still errors.Is/errors.As into the root cause.
  • Define a sentinel or custom type specifically for failure modes a caller needs to branch on (retryable vs. not, not-found vs. server error) — don't create one for every possible error message, since most callers only care that something failed, not exactly what.
  • Log at the boundary where an error either gets handled or crosses a system boundary (returned from an HTTP handler, published to a queue's dead-letter path) — logging the same error again at every intermediate layer it passes through produces duplicate, redundant log lines for one failure.
  • Use slog's structured attributes (not string interpolation into the message) for anything a dashboard or alert might filter/group on later — request ID, user ID, status code, duration.

Common pitfall

Logging and returning the same error at every layer ("log-and-rethrow"), which turns one failure into N near-identical log lines as it propagates up the call stack — expensive to read during an incident because the on-call engineer has to de-duplicate by hand to find where the failure actually originated versus where it was merely observed passing through. The fix is a single ownership rule: a layer either handles the error (and may log it, since it's now handled) or propagates it (and does not log it, since a caller further up will). Combined with %w wrapping so the eventual single log line still carries the full causal chain via errors.Unwrap, this gives one log entry per failure with full context, instead of a scattered trail.

Engineering Lens

Go's explicit if err != nil model and log/slog's structured attributes are both instances of the same tradeoff: pushing a small, constant cost onto every call site (boilerplate checks, verbose log calls) in exchange for failure paths that are visible in the code and queryable in the logs, rather than implicit in an exception's stack trace or buried in a string a human has to parse. The practical test in review is whether an error crossing a service boundary still carries enough structure — via errors.As into a typed cause, then into slog attributes — for an on-call engineer to distinguish "this specific dependency timed out" from "something failed" without re-deploying with more logging first.

Sources

Hermes Wiki