Programming · free software · the open webSubscribe via RSS

Go error wrapping makes implementation details part of the API

In Go, wrapping an error preserves a machine-readable relationship that callers can inspect. Decide whether that relationship is a supported part of your package contract before using `%w` or exposing an Unwrap method.

An error string and an error relationship are different outputs

A Go function can add context to a failure while it moves toward its caller. If a file-reading helper returns "load settings: permission denied", the message helps a person locate the failed operation. A caller may also want to decide whether to retry, report a missing file differently, or stop immediately. Those are two different jobs: the text explains; the error value carries program-inspectable meaning.

The distinction appears in formatting. fmt.Errorf("load %s: %v", name, err) produces useful context but does not preserve the original error as an inspectable child. Replacing %v with %w opts into wrapping; errors.Is and errors.As can then examine the underlying error as well as the outer one. [1] [2]

Wrapping gives callers a dependency on what lies below

Imagine a package function ReadProfile that currently reads a local file. It returns an error that wraps fs.ErrNotExist. A caller can now use errors.Is(err, fs.ErrNotExist) to select a “create a default profile” path. If the package later moves profiles into a database, callers may still rely on that sentinel. The storage choice has crossed the abstraction boundary because wrapping made the lower-level condition observable.

This is not automatically a mistake. If the function promises “missing profile” as a meaningful outcome, preserving that condition may be a good contract even when storage changes. But a filesystem-specific error should not leak merely because it was convenient to format. The Go project’s guidance makes the deciding question explicit: wrapping exposes an error to callers; omit wrapping when it would expose an implementation detail that the package does not intend to support. [1]

Choose the contract before choosing the formatting verb

A useful review starts from caller decisions rather than from the current implementation. List the outcomes callers are allowed to distinguish: perhaps invalid input, a missing logical record, temporary unavailability, and an internal failure. Then decide which of those are stable package concepts. A package can define its own sentinel or error type for a stable concept, translate lower-level failures into it, and preserve the low-level cause only where that detail is deliberately part of the API.

For example, a repository might document ErrProfileMissing as the result of a lookup that found no profile. Internally it can map either a filesystem not-found result or a database no-row result to that package-level condition. Callers depend on the repository’s vocabulary rather than on which storage engine happened to implement it. If debugging needs the storage cause, the package can log it internally under its own privacy policy or expose a separately documented diagnostic interface.

The tradeoff is information. Translating errors can discard details that would help a caller recover, while wrapping every cause can make future refactors breaking changes. Explicitly naming the stable outcomes makes the boundary reviewable.

Use Is and As for questions the contract supports

errors.Is asks whether the error or any wrapped error matches a target value. It is appropriate for sentinel-style questions such as “is this the package’s not-found condition?” errors.As searches for a matching concrete error type and exposes its fields when the API documents those fields as useful to callers. A direct equality comparison or type assertion only sees the outer value and can miss an error deeper in the wrapping chain. [2]

A wrapped chain should not be treated as a bag of every internal detail. If callers start using errors.As to extract a driver-specific type from your package’s public result, that behavior becomes a dependency whether or not the documentation intended it. Tests should therefore check the promised condition at the public boundary, not only assert that the displayed text contains a particular phrase. Text can change for clarity; stable programmatic outcomes should change only as an API decision.

Review error changes like other interface changes

When adding context, ask: who created the underlying error, who owns its meaning, and which callers are expected to inspect it? Use %w when preserving that relationship is intentional; use %v or a new package-level error when the cause should remain an implementation detail. Document the cases callers can test, and keep the wording of the message useful without asking programs to parse it. Go’s older guidance emphasizes errors as values that can carry operation context; wrapping adds a second, separate choice about which value relationships callers may inspect. [3] [1]

A small change from %v to %w can compile without changing the visible message, yet it expands what downstream code can observe. Treat it like adding a method, field, or exported sentinel: choose the promise deliberately, test that promise, and preserve freedom to change what the package has not promised.

References

  1. Working with Errors in Go 1.13
  2. Go errors package documentation
  3. Effective Go: Errors

Suggest a correction · All posts