Go Error Chains: How errors.Is, errors.As, and errors.Join Actually Work
If you have ever written a for loop that calls errors.Unwrap until it returns nil, that loop silently ignores every child error produced by errors.Join. It is an easy gap to miss when tests only cover singly wrapped errors.
The errors package docs define wrapping with a short contract: an error e wraps w if e’s type has an Unwrap() error method returning w, or an Unwrap() []error method returning a slice containing w. Those two method shapes define the tree that errors.Is and errors.As traverse.
And because one of them returns a slice, successive unwrapping produces a tree rather than a chain. Is and As walk that tree pre-order, depth-first: the error itself first, then each child’s subtree in turn. Stop thinking “chain” and the behaviour of joined errors stops being surprising.
Wrapping with %w creates the tree
fmt.Errorf with the %w verb is the standard way to produce a wrapping error:
wrapsErr := fmt.Errorf("loading config: %w", err)
The returned error has an Unwrap() error method. errors.Unwrap will pull err back out:
err1 := errors.New("error1")
err2 := fmt.Errorf("error2: [%w]", err1)
fmt.Println(err2) // error2: [error1]
fmt.Println(errors.Unwrap(err2)) // error1
Here’s the caveat that bites people. errors.Unwrap only calls methods of the form Unwrap() error. It will not unwrap errors returned by errors.Join, because those implement Unwrap() []error. Hand-rolled unwrap loops skip joined errors entirely. Use errors.Is and errors.As, which handle both shapes.
A second caveat: it is invalid for an Unwrap method to return an []error containing a nil error value. If you build your own multi-error type, filter nils at construction time and stop worrying about it.
Sentinel matching with errors.Is
errors.Is reports whether any error in the tree matches the target. The docs are explicit that you should prefer it over equality:
if _, err := os.Open("non-existing"); err != nil {
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("file does not exist")
} else {
fmt.Println(err)
}
}
err == fs.ErrNotExist fails here because os.Open returns a *fs.PathError that wraps the sentinel. errors.Is succeeds because it walks the tree.
The target must be comparable. That’s why sentinels are package-level variables and not values built on each call. Every call to errors.New returns a distinct value, identical text or not:
func OopsNew() error { return errors.New("an error") }
var ErrSentinel = errors.New("an error")
func OopsSentinel() error { return ErrSentinel }
func main() {
fmt.Println(errors.Is(OopsNew(), OopsNew())) // false
fmt.Println(errors.Is(OopsSentinel(), OopsSentinel())) // true
}
Two errors with the same message are not the same error. If callers need to branch on a failure mode, export a sentinel or an error type. Comparing against a freshly constructed error is a common reason errors.Is returns false. For broader guidance on when to define these, see our post on error handling best practices in Go.
Typed errors: errors.As and errors.AsType
Sentinels answer “which failure?”. Typed errors answer “which failure, and with what data?”. errors.As finds the first error in the tree assignable to the target and sets it:
if _, err := os.Open("non-existing"); err != nil {
var pathError *fs.PathError
if errors.As(err, &pathError) {
fmt.Println("Failed at path:", pathError.Path)
}
}
As panics if target is not a non-nil pointer to a type implementing error, or to an interface type. So errors.As(err, pathError) with a missing & is a runtime panic rather than a compile error. Worth a lint rule.
Go 1.26 adds a generic alternative, errors.AsType, which returns the value instead of writing through a pointer:
if pathError, ok := errors.AsType[*fs.PathError](err); ok {
fmt.Println("Failed at path:", pathError.Path)
}
The docs say to prefer AsType for most uses. The pointer-to-pointer dance goes away, the panic case goes away, and the type parameter puts the intent at the call site. AsType can also target an interface that implements error; As remains useful for compatibility and for the unusual case where the target interface does not itself implement error. This is one example of generics simplifying an older reflection-based API, and it connects to the ongoing discussion about generic methods in Go.
errors.Join and multi-error trees
errors.Join returns an error wrapping all the given errors. Nil values are discarded, and if every value is nil, Join returns nil. That last property is what makes it good at accumulating validation failures:
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, ErrMissingName)
}
if u.Age < 0 {
errs = append(errs, ErrNegativeAge)
}
return errors.Join(errs...) // nil when errs is empty
}
No if len(errs) > 0 guard. The joined error formats as each Error() string separated by newlines, and implements Unwrap() []error:
err1 := errors.New("err1")
err2 := errors.New("err2")
err := errors.Join(err1, err2)
fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true
fmt.Println(err.(interface{ Unwrap() []error }).Unwrap()) // [err1 err2]
Depth-first traversal has a consequence worth spelling out: errors.As on a joined error returns the first match in tree order, not the most relevant one. Two *fs.PathError values in a join, and you get the leftmost. When you actually need all of them, type-assert to interface{ Unwrap() []error } and walk the children yourself.
Custom Is and As methods
An error type can override matching by implementing Is(error) bool or As(any) bool.
An Is method lets your type declare equivalence to an existing sentinel:
type MyIsError struct {
err string
}
func (e MyIsError) Error() string { return e.err }
func (e MyIsError) Is(err error) bool { return err == fs.ErrPermission }
Now errors.Is(MyIsError{"an error"}, fs.ErrPermission) returns true even though == returns false. syscall.Errno.Is in the standard library does precisely this, mapping platform error numbers onto fs.ErrPermission and friends.
One rule from the docs is easy to violate: an Is method should compare err and target shallowly, and must not call Unwrap on either. Traversal belongs to the caller. Unwrap inside your Is method and you get duplicated traversal, or worse, no termination.
An As method lets your type present itself as a different type entirely:
type MyAsError struct {
err string
}
func (e MyAsError) Error() string { return e.err }
func (e MyAsError) As(target any) bool {
pe, ok := target.(**fs.PathError)
if !ok {
return false
}
*pe = &fs.PathError{
Op: "custom",
Path: "/",
Err: errors.New(e.err),
}
return true
}
Your As method owns setting the target. Adapter layers are a practical use case: a driver-specific error can present as a standard type without exposing the driver package to callers. Use it sparingly, because the error’s runtime type and its matching behaviour can otherwise be hard to reason about.
ErrUnsupported and the wrapping convention
errors.ErrUnsupported demonstrates what the standard library expects from library authors. The docs say functions should not return it directly. Return an error carrying appropriate context that satisfies errors.Is(err, errors.ErrUnsupported), either by wrapping it or by implementing an Is method:
func (s *Store) Link(old, new string) error {
if !s.supportsLinks {
return fmt.Errorf("link %q %q: %w", old, new, errors.ErrUnsupported)
}
// ...
return nil
}
The caller gets a message naming the operation and its arguments, and can still branch on the sentinel. Do the same with your own sentinels: wrap with context at each layer instead of returning bare values.
API choices that preserve failure information
Wrap with context at each boundary, not at each call site. fmt.Errorf("querying user %d: %w", id, err) adds information. fmt.Errorf("error: %w", err) adds a line of noise to a stack trace nobody wanted. Every wrap should tell the caller something it doesn’t already know.
Treat wrapping as part of your public API. The moment callers write errors.Is(err, ErrNotFound), deleting a %w verb breaks them without a compile error. No panic, no failing build, just a branch that never runs again. Document which sentinels a function can return, the way the standard library does for ErrUnsupported.
When you want to hide the cause, use %v. An internal error type that shouldn’t leak into your public contract gets formatted with %v: the message survives, the match doesn’t. That is a deliberate decision, and at package boundaries it’s frequently the right one.
Reach for Join when failures are parallel and %w when they’re causal. A batch job that processes 100 items and fails on three joins three errors. A handler that failed because the database failed wraps one.
And never build behaviour on error strings. No strings.Contains(err.Error(), "not found"). Message text carries no compatibility guarantee; sentinels and types do. The same discipline applies across process boundaries, where status codes and details take over the job that sentinels and typed errors do in-process. gRPC error handling covers how that translation works and why a clear in-process error taxonomy matters.