DEV Community

Cover image for %w and the Error Chain: Wrapping Without Losing the Cause in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

%w and the Error Chain: Wrapping Without Losing the Cause in Go


You've seen the report. A request failed in production. The log line
reads failed to load profile. That is the whole message. No status
code, no row id, no sentinel you can match on. Somewhere five layers
down a sql.ErrNoRows got turned into a string, and the string ate
everything the caller needed to decide what to do next.

Go gives you a way out of this, and it has been in the standard
library since Go 1.13: fmt.Errorf with the %w verb, plus
errors.Is and errors.As to walk what %w builds. The mechanics
are small. The places people get them wrong are not.

%w vs %v: one keeps the cause, one throws it away

fmt.Errorf formats a string. With %v it formats the error's text
and stops there. With %w it formats the text and keeps a
reference to the original error so callers can reach it later.

base := errors.New("connection refused")

withV := fmt.Errorf("dial db: %v", base)
withW := fmt.Errorf("dial db: %w", base)
Enter fullscreen mode Exit fullscreen mode

Both produce the string dial db: connection refused. They are not
the same value. withV is a plain error whose only content is that
string. withW is a *fmt.wrapError that holds base underneath
and exposes it through an Unwrap() error method.

That Unwrap method is the whole game. It is the link in the chain.
%v does not create one, so the cause is gone the moment you format
it.

fmt.Println(errors.Unwrap(withV)) // <nil>
fmt.Println(errors.Unwrap(withW)) // connection refused
Enter fullscreen mode Exit fullscreen mode

The rule: use %w when a caller might need to react to the
underlying error. Use %v when you genuinely want to flatten the
error into text and hide what caused it — for example when the cause
is an implementation detail you don't want to leak across a package
boundary.

errors.Is walks the chain for you

Before %w, callers compared errors with ==. That breaks the
moment anyone wraps:

if err == sql.ErrNoRows { // false once err is wrapped
    // ...
}
Enter fullscreen mode Exit fullscreen mode

errors.Is fixes it by walking the chain. It starts at the error
you hand it, checks for a match, then calls Unwrap and repeats
until it finds the target or runs out of links.

if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}
Enter fullscreen mode Exit fullscreen mode

This works no matter how many times the error was wrapped on the way
up, as long as every layer used %w and not %v. One %v anywhere
in the chain cuts the rope, and errors.Is returns false for
everything below the cut.

errors.As reaches for a concrete type

errors.Is answers "is this that error." errors.As answers
"is there an error of this type in the chain, and if so give it to
me." You need it when the underlying error carries fields you want
to read.

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    log.Printf("failed on path %s: %v",
        pathErr.Path, pathErr.Err)
}
Enter fullscreen mode Exit fullscreen mode

errors.As walks the same chain, but instead of comparing for
equality it checks whether each link is assignable to the target
pointer. When it finds one, it assigns and returns true. The second
argument has to be a pointer to the type you want, which is why it is
&pathErr and not pathErr.

Use As for typed errors with data: a validation error with a field
name, an HTTP error with a status code, a database error with a
constraint name. Use Is for sentinels you match by identity.

Wrap so callers can act, annotate when they can't

Not every error needs %w. Wrapping creates a contract: you are
telling callers "the error underneath is part of my API, you may
match on it." That is sometimes exactly wrong.

Consider a repository that talks to Postgres. If you wrap the raw
driver error with %w and return it, every caller can now reach in
with errors.As and pull out a *pgconn.PgError. Your storage
engine has leaked into your business logic. The day you swap Postgres
for something else, those callers break.

// Leaks the driver: callers can match *pgconn.PgError.
func (r *Repo) Get(id string) (*User, error) {
    u, err := r.query(id)
    if err != nil {
        return nil, fmt.Errorf("repo.Get: %w", err)
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

If the driver detail should not cross the boundary, translate it
into your own sentinel and wrap that, or annotate with %v and
return a clean error:

func (r *Repo) Get(id string) (*User, error) {
    u, err := r.query(id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("repo.Get %s: %w",
            id, ErrUserNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("repo.Get %s: %v", id, err)
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

The decision per layer: wrap with %w when the cause is part of the
contract you want to expose. Annotate with %v when the cause is an
internal detail and all you owe the caller is context.

The double-wrapping pitfall

Once a team learns about %w, the next failure mode is wrapping at
every single layer. The handler wraps the service error. The service
wraps the repo error. The repo wraps the driver error. You end up
with a log line like this:

handle profile: load user: repo.Get abc: query user: dial db: connection refused
Enter fullscreen mode Exit fullscreen mode

That is five frames of "and then I called the next thing," and most
of them add no information. Each layer repeated what the call already
said. The chain still works for errors.Is, but the message is noise
and the stack of %w calls implies five distinct meanings where
there is really one.

The fix is to wrap with intent, not by reflex. Add context where you
know something the layer below didn't: an id, an input, the
operation in business terms. Pass the error through untouched when
you have nothing to add.

// Nothing to add here. Pass it through.
func (s *Service) LoadProfile(id string) (*Profile, error) {
    u, err := s.repo.Get(id)
    if err != nil {
        return nil, err // already says "repo.Get abc: ..."
    }
    return s.assemble(u)
}
Enter fullscreen mode Exit fullscreen mode

Returning the error bare is a real option, not laziness. If the layer
below already produced a message a human can act on, repeating it
under a %w does not help anyone.

Wrapping two errors at once

Go 1.20 let %w appear more than once in a single fmt.Errorf, and
errors.Join arrived for combining errors that have no parent-child
relationship.

err := errors.Join(errClose, errFlush)
// errors.Is(err, errClose) -> true
// errors.Is(err, errFlush) -> true
Enter fullscreen mode Exit fullscreen mode

errors.Is and errors.As both understand a tree, not only a single
line of Unwrap calls. That is useful for cleanup paths where two
independent things fail and you want to report both. Reach for it
when the errors are siblings. Reach for %w when one error caused
the other.

What to check on Monday

Four greps for the codebase you already have.

  1. : %v" near an error you return — ask whether a caller upstream needs to match on the cause. If yes, it should be %w.
  2. err == against any error that crosses a package boundary — that comparison breaks the first time someone wraps. Move it to errors.Is.
  3. fmt.Errorf(".*%w returning a third-party driver or library error straight out of a repository — decide on purpose whether that detail belongs in your public contract.
  4. Log lines with four or more colons of "and then I called X" — that is double-wrapping. Drop the layers that added nothing.

%w is one verb. The hard part was never the syntax. It is deciding,
at each layer, whether the error underneath is something a caller
should be able to see and act on, or something you should keep to
yourself.


If this was useful

Error handling is one of those Go topics that looks settled until you
trace a real incident back through five wrapped layers and find the
one %v that ate the cause. The Complete Guide to Go Programming
walks through errors.Is, errors.As, Join, and the design
question of what belongs in a package's error contract. Hexagonal
Architecture in Go
shows where to translate errors at the boundary
so storage and transport details never leak into the domain.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)