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 err is nil, return false immediately (unless target is also nil, then true).
  • Compare the current error to target with ==. If equal, return true.
  • If the current error has a method Is(error) bool, call it with target; if it returns true, stop and return true.
  • 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 returns nil, the chain ends and Is returns false.

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, in fmt.Errorf when the caller might need to inspect the original error.
  • Declare sentinel errors with errors.New at package scope and export them (e.g. ErrNotFound) so callers can check them with errors.Is.
  • Prefer errors.Is/errors.As over == 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) bool or As(any) bool method 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 %v deliberately.

Practice Exercises

  • Define three sentinel errors, ErrPermission, ErrNotFound, and ErrConflict. Write a function repositoryOp(op string) error that returns one of them wrapped with %w depending on op. In main, use errors.Is in a switch to print a different message for each case.
  • Define a custom error type RateLimitError with a RetryAfterSeconds int field and a pointer-receiver Error() method. Wrap an instance of it through two nested functions with %w, then use errors.As in main to extract it and print "retry after Ns".
  • Give RateLimitError from the previous exercise a custom Is(target error) bool method that reports a match against any other *RateLimitError, regardless of RetryAfterSeconds. Verify with errors.Is against a freshly constructed &RateLimitError{} that the match succeeds even though the fields differ.

Summary

  • errors.Is(err, target) reports whether target appears anywhere in err‘s wrap chain, using == or a custom Is method.
  • errors.As(err, target) finds the first error in the chain assignable to target‘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.Errorf with the %w verb creates one automatically, %v does 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 — use errors.Is/errors.As instead.
  • Match target types exactly, including pointer-ness, to whatever concrete type your error actually uses.