What this usually means
The core issue is that %w only works when you wrap a single error that is a sentinel or a type implementing error. Common pitfalls include: (1) wrapping a nil error—%w on nil produces a new error that is not nil but errors.Is won't match anything; (2) using %w on a non-error type (like a string) that happens to implement error—it won't unwrap; (3) wrapping an error that is itself a wrapped error—errors.Unwrap only unwraps one level, so you need to call errors.Is which iterates the chain; (4) double-wrapping with %s or %v then %w—the outermost %w only sees the formatted string, not the original error; (5) Go versions before 1.13 don't support %w at all—code compiles but wrapping is a no-op.
The first ten minutes — establish facts before touching code.
- 1Check Go version: go version — must be >= 1.13 for %w support.
- 2Print the error chain: for e := err; e != nil; e = errors.Unwrap(e) { fmt.Println(e) } — see if the sentinel appears.
- 3Test errors.Is directly: wrap a known sentinel and call errors.Is in a small test file.
- 4Verify you are not wrapping nil: if originalErr == nil { return nil } else { return fmt.Errorf("context: %w", originalErr) }.
- 5Inspect the format string: ensure %w appears exactly once and the corresponding argument is an error variable.
- 6Check for custom Error() methods that might shadow the unwrapping behavior.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchGo source files using fmt.Errorf with %w
- searcherror.go or errors.go files defining sentinel errors (var ErrSomething = errors.New(...))
- searchUnit test files (test_*.go) that assert errors.Is/As
- searchGo 1.13+ migration guide: https://go.dev/doc/go1.13#error_wrapping
- searchVendor/module files if using older Go versions
- searchCI pipeline configuration for Go version
- searchLog output that prints full error chain
Practical causes, not theory. These are the things you will actually find.
- warningWrapping a nil error: fmt.Errorf("context: %w", nil) creates a non-nil error that errors.Is sees as non-nil but never matches.
- warningUsing %w with a non-error type that satisfies the error interface (e.g., a string pointer with Error() method) — doesn't unwrap.
- warningWrapping the same error multiple times in a chain but only checking the immediate parent with errors.Unwrap.
- warningAccidentally using %v or %s instead of %w — the error message looks the same but wrapping is lost.
- warningGo version < 1.13: %w is treated as %v, no wrapping occurs.
- warningCustom error types that don't implement Unwrap() method — errors.Is/As rely on Unwrap to traverse the chain.
- warningUsing errors.New() inside fmt.Errorf: fmt.Errorf("msg: %w", errors.New("inner")) — this wraps the new error, not a sentinel.
Concrete fix directions. Pick the one that matches your root cause.
- buildAlways guard against nil before wrapping: if originalErr != nil { return fmt.Errorf("context: %w", originalErr) } else { return nil }
- buildUse errors.Is and errors.As instead of direct == or type assertions when checking wrapped errors.
- buildEnsure sentinel errors are defined as var ErrFoo = errors.New("foo") and not as const or inline.
- buildFor custom error types, implement Unwrap() error that returns the inner error.
- buildIf you need to wrap multiple errors, consider errors.Join (Go 1.20+) or wrap them sequentially with context added each time.
- buildUpgrade Go toolchain to >= 1.13 (ideally latest) and update go.mod accordingly.
A fix you cannot prove is a guess. Close the loop.
- verifiedWrite a small test that creates a sentinel, wraps it with %w, and asserts errors.Is(err, sentinel) is true.
- verifiedPrint the full error chain using a loop: for e := err; e != nil; e = errors.Unwrap(e) { fmt.Printf("%T: %v\n", e, e) }
- verifiedTest with nil parent: ensure wrapping nil returns nil, not a wrapped nil error.
- verifiedRun tests under the same Go version as production (check go.mod).
- verifiedAdd a unit test that verifies custom error types unwrap correctly: implement Unwrap and test errors.As.
- verifiedCheck that errors.As works for concrete types: var target *MyError; ok := errors.As(err, &target)
Things that make this bug worse or harder to find.
- warningAssuming errors.Is works like a type assertion — it only works for sentinel values (errors.New), not arbitrary strings.
- warningUsing %w on a variable that is nil — it creates a non-nil error that matches nothing.
- warningWrapping the same error in multiple fmt.Errorf calls without using errors.Is — only the outermost wrap is checked.
- warningForgetting that errors.Unwrap only goes one level; use errors.Is/As for full chain traversal.
- warningRelying on errors.Is with a string literal — it compares pointer identity of sentinel errors, not message equality.
- warningIgnoring Go version: deploying code using %w to an environment with Go < 1.13 will compile but silently lose wrapping.
Missing File Error Lost in Wrapping Chain
Timeline
- 09:15Deploy new file upload endpoint using fmt.Errorf with %w
- 09:30User reports upload failure; logs show 'failed to open file: file not found'
- 09:45I check error handling: expected errors.Is(err, os.ErrNotExist) but returns false
- 10:00Print error chain: first level is 'failed to open file: file not found', errors.Unwrap returns nil
- 10:05Inspect code: originalErr from os.Open; if originalErr != nil { return fmt.Errorf("failed to open file: %w", originalErr) }
- 10:10Notice originalErr is checked but not assigned: originalErr, _ := os.Open(path) — the second return value is ignored!
- 10:12Fix: originalErr, err := os.Open(path); if err != nil { return fmt.Errorf("failed to open file: %w", err) }
- 10:15Deploy fix; errors.Is now works correctly.
We had a file upload endpoint that needed to return a 404 if the file didn't exist. The code used os.Open and wrapped the error with fmt.Errorf("failed to open file: %w", err). I wrote a unit test that asserted errors.Is(err, os.ErrNotExist) and it passed locally. But in production, the condition never triggered—the API always returned 500. I was confused because the log said 'file not found'.
After ten minutes of staring, I printed the error chain. To my surprise, errors.Unwrap returned nil. That meant the %w didn't actually wrap anything. I looked at the real code: originalErr, _ := os.Open(path). The developer had used the blank identifier for the error! The variable originalErr was nil, so fmt.Errorf wrapped nil, which created a new error that looked like the message but had no underlying error to unwrap.
The fix was to capture the error properly: f, err := os.Open(path); if err != nil { return fmt.Errorf("failed to open file: %w", err) }. The root cause was a combination of coding oversight and the non-obvious behavior that wrapping nil with %w produces a non-nil error. I added a linter rule to warn about %w with nil arguments and wrote a test that explicitly checks errors.Is with a separate sentinel.
Root cause
Wrapping a nil error with %w because the actual error was ignored using blank identifier.
The fix
Capture the error correctly: err, not _.
The lesson
Always verify that the error variable passed to %w is non-nil. Add guard: if err != nil { return fmt.Errorf(...) } else { return nil }.
fmt.Errorf with %w creates an error value that implements the Unwrap() method. The implementation is in the fmt package: it creates a *wrapError struct that holds the format string and the original error. Unwrap returns the original error. errors.Is and errors.As then traverse the chain by repeatedly calling Unwrap. This is a singly linked list of errors.
The critical detail: %w only works on the first error argument in the format string. If you have multiple %w, only the first one is used; subsequent %w are treated as %v. Also, if the argument to %w is nil, the resulting error still implements Unwrap but returns nil from Unwrap. So errors.Is will see a non-nil error but its chain ends immediately—no matching.
errors.Is only works with sentinel errors—variables defined with errors.New that are compared by pointer identity. You cannot use errors.Is to match against a dynamically created error like fmt.Errorf("some message") because each call creates a new pointer. This trips up developers who expect errors.Is to match by message content.
For dynamic errors, use custom types and errors.As. For example, define a struct that implements error and has a field for the message. Then errors.As can extract it. But if you wrap a custom type with %w and the custom type itself does not implement Unwrap, errors.Is won't traverse into it.
Go 1.20 introduced errors.Join, which creates an error that wraps multiple errors. This is useful when you need to accumulate errors without losing individual identities. However, fmt.Errorf still only supports a single %w. If you need to wrap multiple errors, use errors.Join and then wrap that with fmt.Errorf if needed.
Before 1.20, you had to manually create a custom error type that implements Unwrap() and returns a slice of errors. This is error-prone. Upgrade to 1.20+ if you need multi-error wrapping.
When you suspect error wrapping is broken, the first step is to dump the entire error chain. You can do this with a simple loop: for e := err; e != nil; e = errors.Unwrap(e) { log.Printf("%T: %v", e, e) }. This will show you each level of the chain and its type.
If you see a level where the type is *fmt.wrapError but Unwrap returns nil, that means the original error was nil. If you see a level with type *errors.errorString (from errors.New), then something used errors.New instead of a sentinel. Also check that the error message contains the expected text—sometimes the message is correct but the underlying error is lost.
If you define a custom error type like type MyError struct { Msg string; Err error }, you must implement Error() string and Unwrap() error. If you forget Unwrap, errors.Is and errors.As will not descend into the chain. This is a frequent issue in codebases that define their own error types.
Another gotcha: implementing Unwrap() that returns a nil error. This is valid but will stop the chain. Ensure Unwrap returns the actual inner error, not nil. Also, if your custom type wraps multiple errors, you need to implement Unwrap() []error (Go 1.20+) or use errors.Join.
Frequently asked questions
Why does errors.Is return false even though I wrapped the error with %w?
Most common reasons: (1) the argument to %w was nil—wrap nil produces a non-nil error that matches nothing; (2) you're trying to match a non-sentinel error (like a formatted string); (3) you're using Go <1.13 where %w is just %v; (4) the error you wrapped is itself a wrapped error and you're only checking the top level with == instead of errors.Is.
Can I use %w with multiple errors in one fmt.Errorf?
No. fmt.Errorf only recognizes the first %w; subsequent %w are treated as %v. If you need to wrap multiple errors, either use errors.Join (Go 1.20+) or create a custom error type that implements Unwrap() []error (for 1.20) or Unwrap() error (returning the first error, but you lose the rest).
What's the difference between %w and %v?
%w creates a wrapping error that implements Unwrap, allowing errors.Is/As to traverse the chain. %v just formats the error as a string—the original error is lost for programmatic inspection. Both produce the same output in the error message, so the difference is only in the metadata.
How do I check if an error wraps a specific type?
Use errors.As: var target *MyError; if errors.As(err, &target) { ... }. This will traverse the chain and assign the first error that matches *MyError to target. Make sure your custom type implements the error interface and that you pass a pointer to a pointer of the type.
Why does my custom error type not unwrap correctly?
You likely forgot to implement the Unwrap() error method. Add func (e *MyError) Unwrap() error { return e.Err }. Without it, errors.Is/As treat the error as a leaf node and stop. Also, if you have multiple inner errors, you need to implement Unwrap() []error (Go 1.20+) or use errors.Join.