What Is the Difference Between errors.Is and errors.As

errors.Is checks for a specific error value, while errors.As extracts a custom error type to access its fields.

The error chain problem

You call a function that reads a configuration file. It returns an error. You wrap that error with your own context: fmt.Errorf("failed to load config: %w", err). Later, deep in the call stack, you catch the error. You need to decide what to do. Is this the "file not found" case where you should create a default? Or is it a permission denied case where you should abort? Or is it a custom error from your auth library that carries a specific HTTP status code?

Go gives you two tools for this: errors.Is and errors.As. They look similar, but they solve different problems. errors.Is checks identity. errors.As extracts data.

Concept in plain words

Think of errors as a chain of receipts. When you wrap an error, you're stapling a new note to the top of the stack. The note adds context, but the original receipt is still inside.

errors.Is walks down the chain and asks, "Is this exact error in the stack?" It returns true or false. It's a boolean check. You use it for sentinel errors: specific values you define once and compare against later.

errors.As walks down the chain and asks, "Can I turn any of these errors into this specific type?" If yes, it hands you a pointer to that type so you can read its fields. It's a transformation. You use it when the error carries metadata like a status code, a retry delay, or a request ID.

Minimal examples

Here's the simplest distinction. errors.Is checks for a match. errors.As extracts a value.

package main

import (
	"errors"
	"fmt"
)

func main() {
	// Sentinel errors are specific values you define once and reuse.
	var ErrTimeout = errors.New("timeout")

	// Wrapping adds context while keeping the original error accessible.
	wrapped := fmt.Errorf("request: %w", ErrTimeout)

	// errors.Is checks if ErrTimeout exists anywhere in the error chain.
	if errors.Is(wrapped, ErrTimeout) {
	// prints: true
		fmt.Println(errors.Is(wrapped, ErrTimeout))
	}
}

Here's how extraction works. You declare a target variable and pass its address to errors.As.

package main

import (
	"errors"
	"fmt"
)

// MyCustomError holds a status code that plain errors cannot carry.
type MyCustomError struct {
	Code int
}

// Error implements the error interface.
func (e *MyCustomError) Error() string {
	return fmt.Sprintf("code %d", e.Code)
}

func main() {
	// errors.As extracts a specific type from the chain into a pointer.
	var target *MyCustomError
	custom := fmt.Errorf("api: %w", &MyCustomError{Code: 500})

	if errors.As(custom, &target) {
	// prints: 500
		fmt.Println(target.Code)
	}
}

errors.Is asks "is this it?". errors.As asks "give me the details".

How wrapping works under the hood

When you use %w in fmt.Errorf, Go creates a wrapper. The wrapper holds the message and a pointer to the inner error. errors.Is and errors.As know how to traverse this chain. They don't just look at the top. They dig down.

The magic relies on the Unwrap method. If an error implements Unwrap() error, errors.Is and errors.As call it to get the next link in the chain. fmt.Errorf implements this automatically when you use %w. If you write a custom wrapper, you must implement Unwrap yourself.

// WrapperError adds context and implements Unwrap for traversal.
type WrapperError struct {
	msg   string
	cause error
}

// Error returns the formatted message.
func (e *WrapperError) Error() string {
	return e.msg
}

// Unwrap returns the underlying error so errors.Is can traverse the chain.
func (e *WrapperError) Unwrap() error {
	return e.cause
}

Go conventions matter here. The receiver name for methods should be short, usually one or two letters matching the type. Use (e *WrapperError), not (this *WrapperError) or (self *WrapperError). This keeps the code readable and matches the community style.

gofmt formats your code automatically. Run it on save. Don't argue about indentation or brace placement; let the tool decide. Most editors integrate gofmt so you never have to think about formatting.

Realistic usage

Here's how this looks in a real handler. You wrap errors as they bubble up. The handler checks for specific conditions using Is and As.

// SaveUser persists a user and wraps database errors.
// Context is always the first parameter by convention.
func SaveUser(ctx context.Context, db *DB, u User) error {
	// Attempt to insert the user.
	err := db.Insert(ctx, u)
	if err != nil {
	// Wrap the error with context about the operation.
		return fmt.Errorf("save user %s: %w", u.Name, err)
	}
	return nil
}

The caller decides what to do based on the error type.

func handleCreateUser(w http.ResponseWriter, r *http.Request) {
	// ... parse request ...
	err := SaveUser(ctx, db, user)
	if err != nil {
	// Check for specific database constraint violation.
		if errors.Is(err, db.ErrDuplicateKey) {
			http.Error(w, "user already exists", http.StatusConflict)
			return
		}

	// Extract custom error to get retry info.
		var retryErr *RetryableError
		if errors.As(err, &retryErr) {
		// Log and schedule retry based on backoff.
			log.Printf("retrying in %v", retryErr.Backoff)
			return
		}

	// Generic failure.
		http.Error(w, "internal error", http.StatusInternalServerError)
	}
}

Go forces you to check errors. The pattern if err != nil { return err } is verbose by design. It makes the unhappy path visible. You can't accidentally ignore an error. When you wrap, you're usually returning the wrapped error up the stack. The caller then uses Is or As to decide what to do.

Wrap early, check late. Let the error carry the story up the stack.

Pitfalls and runtime panics

The biggest trap with errors.As is the target variable. You must pass a pointer to a pointer. If you declare var target MyError and pass &target, you're passing a pointer to a value. errors.As expects a pointer to a pointer so it can modify the variable to point to the found error. If you get this wrong, the program panics at runtime with errors.As: target must be a non-nil pointer to either a type that implements error, or to any interface type. The compiler won't stop you here because the signature accepts any. Always use var target *MyError and pass &target.

errors.As also works with interfaces. You can extract an interface type, not just a concrete struct. This is useful when multiple error types share a behavior.

// Coder is an interface that errors can implement.
type Coder interface {
	Code() int
}

func main() {
	// errors.As can extract an interface type.
	var target Coder
	if errors.As(err, &target) {
	// Access the method defined by the interface.
		fmt.Println(target.Code())
	}
}

errors.As modifies the target variable in place. If you call it multiple times, the variable changes. Declare a new variable for each extraction. Reusing a variable can lead to stale data or confusion.

You can use == for errors, but only if they are never wrapped. Once wrapped, == fails. errors.Is works through wrappers. Prefer errors.Is for sentinel errors. It's safer. You can also use type assertion err.(*MyError), but it panics if the type doesn't match and doesn't traverse wrappers. errors.As is safer and traverses wrappers.

Custom errors can implement Is(error) bool and As(any) bool. This lets you control how errors.Is and errors.As behave. Implement Is to define equality logic. Implement As to define extraction logic. This is rare but powerful for complex error hierarchies. Be careful. This changes the semantics of Is. Usually, Is is for identity. Use this only when you have a strong reason.

errors.As demands a pointer to a pointer. Get the indirection right, or the runtime will panic.

Decision matrix

Use errors.Is when you need a boolean check against a specific sentinel error. Use errors.Is when you want to know if a condition occurred without caring about extra data. Use errors.As when you need to extract fields from a custom error type. Use errors.As when the error carries metadata like a status code, a retry delay, or a request ID. Use errors.As with an interface target when multiple error types share a behavior you want to access. Use plain == comparison only for simple errors that are never wrapped. Use fmt.Errorf with %w to wrap errors so Is and As can traverse the chain. Use fmt.Errorf with %v or %s to wrap errors when you want to hide the original cause from the caller.

Is checks identity. As extracts value. Pick the tool that matches the question.

Where to go next