You maintain a persistence package every service imports. How do you decide which errors it wraps with %w?
answer
- the verb is an export, not formatting
- cheap to add, silently breaking to remove
- translate at the boundary rather than forward
- a short list, not a long leak
- go doc is where the promise lives
basics
~20 sAnything reachable through %w becomes a promise callers may match on and you cannot quietly withdraw. Wrap the small set of sentinels you intend to support, translate lower-level errors into them, flatten the rest, and document which are matchable.
solid answer
~50 sI treat `%w` as an API commitment rather than formatting. Once my package returns an error whose chain reaches something like `sql.ErrNoRows` or a storage layer's concrete type, some service will write `errors.Is` against it, and removing it later breaks them silently, with no compile error. So I publish a deliberately small vocabulary, say `ErrNotFound`, `ErrConflict` and `ErrUnavailable`, wrap those with `%w`, and translate lower-level errors into them rather than forwarding them, so callers depend on my abstraction and not on the storage layer or its version. Everything else is flattened with `%v` into a message that is useful to a human and useless for branching. The package doc comment lists what is matchable, so `go doc` is the contract. When a team needs a detail I flattened, I answer with a new named sentinel rather than by widening the chain for everyone.
code
go · 12 linesvar ErrNotFound = errors.New("record not found")
func (r *Repo) LoadRecord(ctx context.Context, id string) (*Record, error) {
rec, err := r.query(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("load record %s: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("load record %s: %v", id, err)
}
return rec, nil
}go deeper
Know that a %w in an exported function is visible to everyone who imports your package, so the verb is not purely a formatting choice you can revisit freely.
Be ready to explain how a caller's errors.Is depends on every layer between them and the sentinel, and why that makes the choice of verb a cross-package concern rather than a local one.
Show how you document and test the matchable set for a package you own, and why you translate lower-level errors into your own sentinels instead of forwarding them upward.
Argue the tradeoff out loud: a small published vocabulary against consumer flexibility, who gets overruled when a team wants more exposed, and how a change to that contract is versioned and communicated.
## The decision, stated plainly Wrapping with `%w` is not a formatting choice made inside your function. It is an export. The moment an exported function returns an error whose chain reaches value X or type T, every importer can write `errors.Is(err, X)` or `errors.As(err, &t)`, and they will. Taking it away later produces no compile error anywhere, no failing build in their CI, just a branch that stops being taken. That asymmetry, cheap to add and silently breaking to remove, is what makes the verb an ownership question rather than a style one. ## The default posture: a small published vocabulary For a package that everything above it imports, I define a short list of errors that are part of the contract: ``` var ( ErrNotFound = errors.New("record not found") ErrConflict = errors.New("record already exists") ErrUnavailable = errors.New("storage unavailable") ) ``` These are the only things I wrap with `%w` on the way out. Everything else, a scan failure, a malformed row, a driver-specific condition I do not want to promise, is flattened with `%v` into a message that reads well in a log and cannot be branched on. The list is short on purpose. Each entry is a behaviour I am agreeing to keep producing under the same conditions for as long as the package exists. Three is a contract; thirty is a leak with a table of contents. ## Translate, do not forward The most consequential form of this decision is whether to forward lower-level errors. If my repository returns an error chain that still reaches `sql.ErrNoRows`, then every service above me is now coupled to the fact that I use `database/sql` and to how that layer signals a missing row. That is a dependency I did not intend to create and cannot see, and it makes replacing the storage layer a fleet-wide breaking change. So the boundary translates: ``` if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("load record %s: %w", id, ErrNotFound) } return nil, fmt.Errorf("load record %s: %v", id, err) ``` The caller matches on my vocabulary. What I use underneath stays mine to change. ## Document it where the decision is made The contract belongs in the doc comment of every exported function that participates: ``` // LoadRecord returns a record by id. It returns an error wrapping // ErrNotFound when no record has that id, and ErrUnavailable when the // store cannot be reached. Other errors are not matchable and their // text may change. ``` Now `go doc` answers the question a consuming team actually has, which is not "what does this return" but "what am I allowed to depend on". The last sentence is the important one: it tells callers explicitly which errors carry no promise, which is what lets me keep changing them. Back each documented sentinel with a test that asserts `errors.Is` through the exported call. Documentation states the contract; the test enforces it. ## Handling pressure to expose more A team will eventually ask for the underlying error so they can retry on some specific condition. Widening the chain for them is the easy answer and the wrong default, because it makes one team's need permanent for every caller. The better answer is a narrow named commitment: add `ErrUnavailable`, or an exported predicate, and set it where the condition holds. The retry rule then lives against my vocabulary, survives me replacing the storage layer, and can be tested on both sides. Sometimes I lose this argument, and that is legitimate: if the abstraction genuinely cannot express what a consumer needs, exposing a concrete type is the honest answer. What matters is that it is decided and written down, not that it leaked out of a `fmt.Errorf` nobody reviewed. ## Cost of the opposite posture Flattening aggressively has a real price. Callers who cannot react programmatically to a condition fall back to string-matching messages, which is untestable and breaks on the first rewording, and they will do it whether or not you approve. So the balance is not "wrap less", it is "wrap deliberately": rich messages for humans, a small named set for machines. ## Changing the contract later Adding a sentinel is additive and safe. Removing one, or ceasing to wrap it, is breaking, and needs the treatment breaking changes get in your organisation: a release note, a version bump, and in a monolith where you can see every consumer, a grep for `errors.Is` and `errors.As` against your sentinels before you touch anything. The point of writing the matchable set down is that this search is possible at all.
- A team asks you to expose the underlying storage error so they can retry on one specific condition. What do you do?I do not widen the chain by default, because that makes one team's need permanent for every caller. I add a narrow named commitment instead, an exported sentinel my package sets for retryable conditions, so their retry rule matches my vocabulary. It survives me replacing the storage layer, and both sides can test it. If the abstraction truly cannot express what they need, exposing the type is the honest answer, but as a decided, documented change.
- How do you make removing a %w a controlled change rather than a silent break?Write the matchable set into the package doc comment, keep one boundary test per sentinel asserting `errors.Is` through the exported call, and treat edits to that list as breaking, with a release note and a version bump. Inside a monolith where every consumer is visible, grep for `errors.Is` and `errors.As` against your sentinels first; the documented list is what makes that search possible.
- What does aggressive flattening cost you?Callers lose the ability to react to conditions you did not anticipate, so they fall back to string-matching the message, which is untestable and breaks on the first rewording, and they will do it without asking. The balance is not to wrap less but to wrap deliberately: keep messages rich for humans and keep the machine-matchable set small, named and documented.
saying these in an interview costs you the question
- Wraps everything with %w because it seems more informative
- Treats the error chain as an internal detail callers cannot see
- Forwards a storage layer's concrete error type through a public API
- Changes which errors are wrapped without a release note
- Expects callers to string-match messages instead of sentinels