Go's %w Error Wrapping: Why errors.Is Silently Breaks the Moment You Forget One Verb
Here is a bug that will not show up in code review, will not fail a build, and will not trip go vet. It only shows up in production, and only on the exact code path nobody bothered to write a test for: the error path.
var ErrNotFound = errors.New("resource not found")
func fetchFromDB(id string) error {
return fmt.Errorf("fetching id %s: %w", id, ErrNotFound)
}
func loadResource(id string) error {
if err := fetchFromDB(id); err != nil {
return fmt.Errorf("loading resource: %v", err) // note: %v, not %w
}
return nil
}
func handleRequest(id string) int {
err := loadResource(id)
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound
}
if err != nil {
return http.StatusInternalServerError
}
return http.StatusOK
}
Run this and every request for a missing resource returns 500, not 404. The error message printed to the log still reads "loading resource: fetching id abc123: resource not found", so at a glance everything looks fine. Someone reading the log sees the right sentence. The programme reading the error chain sees a dead end.
What %w actually does that %v doesn't
Both verbs happily accept an error as an argument and both produce the same formatted string. The difference is invisible in the output and entirely in the plumbing: %w tells fmt.Errorf to make the resulting error implement Unwrap() error, returning the wrapped value. %v just calls the wrapped error's Error() method and throws the value away. The string survives; the identity does not.
errors.Is and errors.As work by walking that Unwrap chain. A simplified version of what errors.Is does looks like this:
func Is(err, target error) bool {
for {
if err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
unwrapper, ok := err.(interface{ Unwrap() error })
if !ok {
return false
}
err = unwrapper.Unwrap()
if err == nil {
return false
}
}
}
The moment one link in the chain fails to implement Unwrap(), the loop terminates and returns false, regardless of what is buried further down. It doesn't matter that ErrNotFound is genuinely somewhere inside that error's string representation; the walk never gets there because the error returned by loadResource has nothing to unwrap into. It's a dead-end node dressed up as a chain.
Why the compiler and go vet let this through
go vet's printf checker is not silent on %w misuse in general. It will catch:
- Using
%wwith an argument that doesn't implementerror("Errorf format %w has arg ... which does not implement error"). - Using more than one
%win a single format string, before Go 1.20 made that legal.
What it cannot catch is exactly the mistake above, because %v is a perfectly legal, perfectly ordinary verb for an error argument. Every error implements Error() string, and %v on anything with an Error() method just calls it. There is nothing type-incorrect about writing fmt.Errorf("loading resource: %v", err); it's exactly as valid as printing an error to a log line, because that's genuinely all it is. The compiler has no way to know you meant "propagate this error's identity" rather than "print this error's message". Both are common, legitimate things to do with an error value, and only you know which one applies at that call site.
This is the uncomfortable bit: the bug isn't a type error, a nil pointer, or a race. It's a one-character difference between two equally valid, equally idiomatic pieces of code, where only one of them preserves a property (identity through the chain) that nothing in the type system enforces.
The fix isn't vigilance, it's a linter
Telling people to "be careful" about which verb they use is not a strategy, it's a wish. The actual answer is errorlint, a static analysis tool built specifically for this class of mistake. Its core check, sometimes called the "non-wrapping format verb" check, flags exactly this pattern: a call to fmt.Errorf where one of the arguments is a value of type error but the corresponding verb is %v or %s instead of %w. Running it against the snippet above produces something like:
main.go:12:39: non-wrapping format verb for fmt.Errorf. Use `%w` to format errors
errorlint also checks the other direction: comparisons like err == ErrNotFound or type switches on error values, which silently stop working the moment something upstream starts wrapping that error, even though they compile and look identical to the correct errors.Is/errors.As form. If your CI doesn't run it, this entire category of bug is invisible until someone hits the affected path in production and starts wondering why a 404 became a 500.
Multiple wraps make this sharper, not softer
Go 1.20 added support for multiple %w verbs in a single fmt.Errorf call, alongside errors.Join. The resulting error implements Unwrap() []error rather than the single-error form, and both errors.Is and errors.As were updated to walk a tree instead of a list, checking every branch:
err := fmt.Errorf("saving record: %w and %w", ErrValidation, ErrPermission)
errors.Is(err, ErrValidation) // true
errors.Is(err, ErrPermission) // true
This is a genuine improvement for cases where an operation can fail for more than one underlying reason at once, but it doesn't change the fundamental problem, it just adds more places for the same mistake to hide. Forget %w on one of three joined errors in a long fmt.Errorf call and you get a chain that correctly reports two of the three failures to errors.Is and silently drops the third. That's a harder bug to spot than the single-verb version, because two out of three checks pass and the code looks like it's doing the right thing.
A boundary test costs less than the incident
Beyond running errorlint in CI, the cheap insurance is a test at whatever boundary actually matters for your application's behaviour, typically the outermost layer that maps an error to a decision (an HTTP status, a retry, an alert). Something as blunt as:
func TestLoadResource_NotFoundIsDetectable(t *testing.T) {
err := loadResource("missing-id")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected errors.Is to detect ErrNotFound, got: %v", err)
}
}
would have caught the original bug immediately, and it costs about as much to write as the mistake did to introduce. Unlike a general "test your error handling" instruction, this one has a specific, checkable property: not "does the function return an error" but "does the identity of a specific sentinel survive the trip through every layer that's supposed to preserve it". That's the property %v quietly breaks, and it's the one thing a log line reading "resource not found" will never tell you has gone wrong.