errors.Is and errors.As
As programs grow, errors stop being flat strings and start forming chains: a low-level error gets wrapped with context as it travels up through function calls. errors.Is and errors.As, both from the standard library errors package, are the tools for looking inside that chain — errors.Is asks "does this chain contain a specific error?" and errors.As asks "does this chain contain an error of a specific type, and if so, give it to me." Together they replace the fragile pattern of comparing errors with == or type-asserting them directly, which breaks the moment an error gets wrapped.
Overview / How It Works
In Go, an error is just a value that satisfies the tiny error interface: anything with an Error() string method. Since Go 1.13, the standard library added a convention for wrapping errors — attaching context to an error while preserving the original underneath. You wrap an error with fmt.Errorf and the special %w verb:
return fmt.Errorf("opening config: %w", err)
The value returned by fmt.Errorf with %w is a new error whose message includes the wrapped error’s message, but which also implements an Unwrap() error method returning the original err. That single method is the entire mechanism: any error type that implements Unwrap() error (or, for errors created with errors.Join, Unwrap() []error) forms a link in a chain. You can build these chains yourself — any custom error struct with an Unwrap() method participates the same way fmt.Errorf‘s wrapped errors do.
errors.Is(err, target) walks this chain starting at err: it compares err to target with ==, and if that fails, checks whether err has an Is(error) bool method that reports a match, and if that also fails, calls Unwrap() to get the next error in the chain and repeats. This continues until a match is found or the chain ends (Unwrap() returns nil or doesn’t exist).
errors.As(err, target) does the analogous walk, but instead of checking equality it checks whether each error in the chain can be assigned to the type that target points to (via ordinary type assertion, or a custom As(any) bool method). The first match found is assigned into target, and As returns true. This is how you recover a concrete error type — and its fields — from deep inside a chain of wrapped errors, without knowing exactly how many layers of wrapping happened along the way.
Why does this matter? Because direct comparison (err == ErrNotFound) or a direct type assertion (err.(*ValidationError)) only works on the outermost error. As soon as any caller wraps that error for extra context — which is normal, idiomatic Go — the outer value is a completely different concrete type (usually *fmt.wrapError), and naive comparisons silently fail. errors.Is and errors.As exist specifically to see through that wrapping.
Syntax
func Is(err, target error) bool
func As(err error, target any) bool
func Unwrap(err error) error
| Part | Meaning |
|---|---|
err |
The error (possibly wrapped many times) that you want to inspect. |
target (in Is) |
A specific error value to look for in the chain, typically a package-level sentinel created with errors.New. |
target (in As) |
A non-nil pointer to a variable of the error type you want to extract, e.g. &valErr where valErr is a *ValidationError. errors.As panics if target is not a pointer to a type implementing error (or to an interface type). |
| Return value | Both functions return bool: whether a match was found. As additionally writes the matched error into *target on success. |
Examples
Example 1: errors.Is with a sentinel error
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("resource not found")
func findUser(id int) error {
if id != 1 {
return fmt.Errorf("findUser: %w", ErrNotFound)
}
return nil
}
func main() {
err := findUser(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("user lookup failed: not found")
} else {
fmt.Println("user lookup failed:", err)
}
}
Output:
user lookup failed: not found
findUser never returns ErrNotFound directly — it returns a new error created by fmt.Errorf whose message is "findUser: resource not found". A plain err == ErrNotFound would be false here, because err‘s concrete type is *fmt.wrapError, not ErrNotFound‘s type. errors.Is instead unwraps once, finds ErrNotFound underneath, and reports a match.
Example 2: errors.As with a custom error type
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
return nil
}
func processInput(age int) error {
if err := validateAge(age); err != nil {
return fmt.Errorf("processInput: %w", err)
}
return nil
}
func main() {
err := processInput(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("field %q had a problem: %s\n", valErr.Field, valErr.Msg)
} else {
fmt.Println("unexpected error:", err)
}
}
Output:
field "age" had a problem: must not be negative
processInput wraps the *ValidationError returned by validateAge inside another fmt.Errorf layer. errors.As walks past that wrapper, finds an error whose concrete type matches *ValidationError (the type valErr points to), and assigns it into valErr — giving main direct access to the Field and Msg fields, which a plain error message string could never provide.
Example 3: combining Is and As across multiple layers
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("operation timed out")
type HTTPError struct {
StatusCode int
URL string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d for %s", e.StatusCode, e.URL)
}
func fetch(url string) error {
return fmt.Errorf("fetch %s: %w", url, &HTTPError{StatusCode: 503, URL: url})
}
func fetchWithRetry(url string) error {
err := fetch(url)
if err != nil {
return fmt.Errorf("fetchWithRetry: %w", err)
}
return nil
}
func main() {
err := fetchWithRetry("https://api.example.com/data")
var httpErr *HTTPError
switch {
case errors.Is(err, ErrTimeout):
fmt.Println("retrying after timeout")
case errors.As(err, &httpErr):
fmt.Printf("server returned status %d, giving up on %s\n", httpErr.StatusCode, httpErr.URL)
default:
fmt.Println("unknown error:", err)
}
}
Output:
server returned status 503, giving up on https://api.example.com/data
Here the error passes through two layers of %w wrapping (fetch, then fetchWithRetry) before reaching main. Neither errors.Is nor errors.As cares how many layers deep the match is — both keep calling Unwrap() until they either find what they’re looking for or run out of chain. This pattern, checking several possible error kinds with Is/As in a switch, is common in real code that has to react differently to different failure causes.
How It Works Step by Step
For a call like errors.Is(err, target):
- If
errisnil, returnfalseimmediately (unlesstargetis alsonil, thentrue). - Compare the current error to
targetwith==. If equal, returntrue. - If the current error has a method
Is(error) bool, call it withtarget; if it returnstrue, stop and returntrue. - Otherwise call
Unwrap()on the current error to get the next error in the chain, and repeat from the top. - If
Unwrap()doesn’t exist or returnsnil, the chain ends andIsreturnsfalse.
errors.As follows the same walk, but at each step it checks with reflection whether the current error’s concrete type is assignable to the type target points to (or, if the current error implements As(any) bool, calls that instead). The first assignable error found is copied into *target and the walk stops.
Common Mistakes
Mistake 1: comparing wrapped errors with ==
// wrong: err is a *fmt.wrapError, never equal to ErrNotFound directly
if err == ErrNotFound {
fmt.Println("not found")
}
Once ErrNotFound has been wrapped by fmt.Errorf("...: %w", ErrNotFound), the value flowing through the program is a different concrete error whose message merely mentions ErrNotFound. Direct equality checks it against the wrong thing and always fails.
// correct: walks the chain looking for ErrNotFound
if errors.Is(err, ErrNotFound) {
fmt.Println("not found")
}
Mistake 2: wrapping with %v instead of %w
// wrong: %v stringifies err into the message but drops the link
return fmt.Errorf("query failed: %v", err)
The resulting error’s text looks identical to what %w would produce, but it has no Unwrap() method, so the original err is gone as far as errors.Is/errors.As are concerned — the chain is severed at that point.
// correct: %w preserves err so callers can unwrap it
return fmt.Errorf("query failed: %w", err)
Mistake 3: passing the wrong target type to errors.As
// wrong: ValidationError's Error() method has a pointer receiver, so the
// chain actually contains *ValidationError, not ValidationError
var valErr ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Msg)
}
This compiles fine and never panics — it just silently returns false, because the type &valErr points to (ValidationError) doesn’t match the concrete type actually stored in the chain (*ValidationError). This kind of mistake is easy to miss because there’s no error message, just a branch that never triggers.
// correct: target matches the pointer type that satisfies error
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println(valErr.Msg)
}
Best Practices
- Always use
%w, not%v, infmt.Errorfwhen the caller might need to inspect the original error. - Declare sentinel errors with
errors.Newat package scope and export them (e.g.ErrNotFound) so callers can check them witherrors.Is. - Prefer
errors.Is/errors.Asover==or bare type assertions anywhere an error might have passed through another package’s wrapping. - Keep custom error types’ method sets consistent: if
Error()has a pointer receiver, always construct and check for the pointer type. - Add context at each layer with
fmt.Errorf("doing X: %w", err)so a log line traces the whole call path, not just the innermost failure. - Only implement a custom
Is(error) boolorAs(any) boolmethod when you need matching by something other than identity or exact type, such as comparing an error code field. - Don’t wrap an error you have no intention of ever letting callers unwrap — if you want to hide implementation details, use
%vdeliberately.
Practice Exercises
- Define three sentinel errors,
ErrPermission,ErrNotFound, andErrConflict. Write a functionrepositoryOp(op string) errorthat returns one of them wrapped with%wdepending onop. Inmain, useerrors.Isin aswitchto print a different message for each case. - Define a custom error type
RateLimitErrorwith aRetryAfterSeconds intfield and a pointer-receiverError()method. Wrap an instance of it through two nested functions with%w, then useerrors.Asinmainto extract it and print"retry after Ns". - Give
RateLimitErrorfrom the previous exercise a customIs(target error) boolmethod that reports a match against any other*RateLimitError, regardless ofRetryAfterSeconds. Verify witherrors.Isagainst a freshly constructed&RateLimitError{}that the match succeeds even though the fields differ.
Summary
errors.Is(err, target)reports whethertargetappears anywhere inerr‘s wrap chain, using==or a customIsmethod.errors.As(err, target)finds the first error in the chain assignable totarget‘s pointed-to type and copies it in, letting you recover a concrete type’s fields.- A wrap chain is built by any error implementing
Unwrap() error;fmt.Errorfwith the%wverb creates one automatically,%vdoes not. - Both functions walk the chain step by step via
Unwrap()until they find a match or the chain ends. - Never compare wrapped errors with
==or use a bare type assertion — useerrors.Is/errors.Asinstead. - Match target types exactly, including pointer-ness, to whatever concrete type your error actually uses.
