The difference between %w and %v is an API decision, not a formatting choice

1. The Hidden Contract in %w
Most Go developers learn error wrapping early: fmt.Errorf("failed to fetch user: %w", err). It looks innocent — we’re just adding context. But %w does something %v does not: it makes the wrapped error inspectable via errors.Is and errors.As.
This means callers can now write:
1 | if errors.Is(err, sql.ErrNoRows) { |
That’s a contract. Your function now implicitly promises that it uses a SQL database. If you switch to Redis, add a cache layer, or move to a different ORM, callers relying on sql.ErrNoRows will break silently — their errors.Is checks will stop matching, and the code path will change without any compiler warning.
2. When Wrapping Hurts
Message proliferation. Deep error chains produce verbose, fragile strings:
1 | "processing order: validating payment: charging card: calling stripe: Post https://api.stripe.com/v1/charges: dial tcp: connect: connection refused" |
When you rename validating payment to verifying payment, every alert pattern matching that string breaks. The same root cause produces different strings through different code paths, making log aggregation painful.
Leaking implementation details. CockroachDB wraps errors with stack traces at every level — useful for their internal debugging, but those traces expose internal architecture. For libraries and services consumed by others, each %w is a window into your internals.
3. The %v Escape Hatch
%v produces identical human-readable output to %w but severs the error chain. Callers see the message in logs but cannot programmatically inspect the inner error:
1 | // %w — caller can unwrap and inspect |
This is not about hiding information from developers. It’s about not making implementation details part of your API surface.
4. The Decision Framework
After surveying how CockroachDB, Terraform, and various Go standard library packages handle this, a clear pattern emerges:
Within a package: return errors bare. The caller is your own code — you control both sides. Adding context within a package often creates redundancy.
1 | func (s *Store) getUser(id string) (*User, error) { |
Across package boundaries: wrap with %v by default. Add context about what your package was trying to do, but don’t expose what you’re using internally.
1 | func (s *UserService) GetUser(id string) (*User, error) { |
At system boundaries: translate errors into domain-specific sentinels. Don’t let database errors, HTTP errors, or RPC errors leak across service boundaries.
1 | var ErrUserNotFound = errors.New("user not found") |
5. Application-Specific Guidance
Libraries: use %v by default. Only expose inner errors with %w when it’s intentional, documented, and part of your API contract. Define your own sentinel errors.
CLI tools: wrap freely with %w. Call stacks are shallow, output is user-facing, and maximum context helps debugging. Nobody is programmatically inspecting your CLI’s errors.
Services: wrap at package boundaries with %w for internal error inspection, use %v at system boundaries, and combine with structured logging at handlers for request-level context.
6. The Structured Logging Alternative
Dave Cheney — who originally popularized error wrapping with pkg/errors — later evolved his thinking. Instead of packing debugging context into error strings, carry it as structured log fields:
1 | func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) { |
The error stays clean and domain-appropriate. The debugging context goes to structured logs where it’s queryable, filterable, and doesn’t pollute the error chain.
7. Conclusion
The choice between %w and %v is an API design decision. %w says: “I promise this error type is part of my interface.” %v says: “I’ll tell you what happened, but how I implement things is my business.”
Default to %v. Reach for %w only when you’ve consciously decided that the inner error type is part of your public contract. And at system boundaries, translate — don’t wrap.
What’s your team’s approach to error wrapping? Do you have lint rules enforcing a specific strategy, or is it left to individual judgment?
Comments