When the error is buried
You write a function that saves a user profile. It calls a database, validates the input, and checks permissions. Somewhere in that chain, a permission check fails and returns a sentinel error ErrForbidden. The function wraps that error with context: fmt.Errorf("save profile: %w", ErrForbidden). The caller receives the wrapped error. You need to return a 403 HTTP status code, but only if the root cause is forbidden.
If you compare the error directly with err == ErrForbidden, the check fails. The wrapped error is a different object. The pointer changed. The chain hides the sentinel. You need a way to ask, "Is ErrForbidden anywhere inside this error?"
That is what errors.Is does. It traverses the error chain and returns true if the target error matches the current error or any error wrapped inside it.
errors.Is walks the chain
errors.Is(err, target) returns a boolean. It checks if err is equal to target, or if err wraps target directly or indirectly. This function is the standard way to detect sentinel errors in Go 1.13 and later. It replaces direct pointer comparison, which breaks the moment an error gets wrapped.
Sentinel errors are package-level variables of type error. You define them once and return them from functions. Wrapping adds context without losing the identity of the original error. errors.Is respects that identity.
Here's the core pattern: define a sentinel, wrap it with context, and use errors.Is to detect it through the wrapper.
package main
import (
"errors"
"fmt"
)
// ErrNotFound signals that a requested resource does not exist.
var ErrNotFound = errors.New("not found")
func getItem(id int) error {
// Simulate a database layer returning the sentinel
return fmt.Errorf("query failed: %w", ErrNotFound)
}
func main() {
err := getItem(42)
// errors.Is traverses the wrapper to find ErrNotFound inside
if errors.Is(err, ErrNotFound) {
fmt.Println("Resource missing")
}
}
The output prints Resource missing. The check succeeds even though getItem returned a wrapped error. errors.Is found the sentinel deep in the chain.
Goroutines are cheap. Channels are not magic. Error checks are cheap too. errors.Is is a fast runtime operation. Run it whenever you need to distinguish between error types.
How the check works under the hood
errors.Is follows a precise algorithm. It does not just compare pointers. It respects custom error types and the wrapping chain.
First, errors.Is checks if err implements the Is(error) bool method. If it does, the function calls that method and returns the result. This allows custom error types to define their own equality logic.
Second, if there is no Is method, errors.Is compares err and target using ==. For pointer types, this checks pointer equality. For struct types, this checks field equality.
Third, if the comparison fails, errors.Is checks if err implements Unwrap() error. If it does, the function unwraps the error and recurses. It repeats the process on the inner error until the chain ends.
This design gives you control. You can define Is to match on error codes, status values, or any logic you need. You can wrap errors freely, and errors.Is will still find the target.
The compiler does not enforce error wrapping. If you pass a non-error value to errors.Is, the compiler rejects the program with cannot use target as error value in argument. The type system ensures both arguments are errors. The runtime logic handles the chain.
Context is plumbing. Run it through every long-lived call site. Error handling is plumbing too. Run errors.Is through every check where the error might be wrapped.
Real code: handling multiple outcomes
In a real service, functions often return multiple sentinel errors. Each sentinel maps to a specific behavior. A signup handler might return ErrInvalidEmail for bad input, ErrUserExists for duplicates, or a wrapped database error for infrastructure failures.
You use errors.Is to branch on the sentinel. This keeps the error handling explicit and readable. You check each case in order. The first match wins.
Here's a handler that distinguishes between validation errors, conflicts, and unknown failures.
package main
import (
"errors"
"fmt"
)
// ErrInvalidEmail signals that the email format is wrong.
var ErrInvalidEmail = errors.New("invalid email format")
// ErrUserExists signals that the user is already registered.
var ErrUserExists = errors.New("user already exists")
func createUser(email string) error {
// Validate input before touching the database
if email == "" {
return ErrInvalidEmail
}
// Simulate a database check that wraps the sentinel
return fmt.Errorf("db lookup: %w", ErrUserExists)
}
func handleSignup(email string) {
err := createUser(email)
if err != nil {
// Check validation error first
if errors.Is(err, ErrInvalidEmail) {
fmt.Println("Return 400 Bad Request")
return
}
// Check conflict error next
if errors.Is(err, ErrUserExists) {
fmt.Println("Return 409 Conflict")
return
}
// Unknown error, log and return 500
fmt.Println("Return 500 Internal Server Error")
}
}
The handler checks ErrInvalidEmail and ErrUserExists separately. Each check returns a different status code. The final branch catches any error that doesn't match the sentinels. This pattern scales. Add more sentinels and more checks as the function grows.
The community accepts the if err != nil boilerplate because it makes the unhappy path visible. errors.Is fits naturally inside that block. You check for nil, then check for specific sentinels. The code reads like a decision tree.
Don't fight the type system. Wrap the value or change the design. If you need to extract fields from an error, use errors.As instead of errors.Is. errors.Is checks identity. errors.As extracts types.
Pitfalls that break the chain
errors.Is works only if the chain is intact. Several common mistakes break the chain or cause false negatives.
Wrapping with %v instead of %w
The %w verb wraps the error. The %v verb formats the error as a string and discards the chain. If you use %v, errors.Is cannot find the target.
// BAD: %v breaks the chain
err := fmt.Errorf("failed: %v", ErrNotFound)
errors.Is(err, ErrNotFound) // returns false
// GOOD: %w preserves the chain
err = fmt.Errorf("failed: %w", ErrNotFound)
errors.Is(err, ErrNotFound) // returns true
The compiler does not catch this mistake. %v is a valid verb. The error is lost at runtime. Always use %w when you want to wrap an error. Use %v only when you are formatting a non-error value or when you intentionally want to drop the chain.
Passing non-errors to %w
The %w verb expects an error value. If you pass a string or an integer, the program panics at runtime.
// This panics at runtime
err := fmt.Errorf("msg: %w", "not an error")
The panic message is fmt: %w has invalid verb. The compiler does not check the type of the argument for %w. This is a runtime check. Be careful when refactoring. If you change a variable from error to string, the %w verb will panic.
Custom errors without an Is method
If you define a custom error type as a struct, errors.Is falls back to pointer comparison by default. Two instances of the struct are not equal, even if the fields match.
type MyError struct {
Code int
}
func (e MyError) Error() string {
return fmt.Sprintf("error %d", e.Code)
}
func main() {
err1 := MyError{Code: 404}
err2 := MyError{Code: 404}
// errors.Is compares pointers. err1 and err2 are different instances.
fmt.Println(errors.Is(err1, err2)) // prints false
}
To fix this, implement the Is(error) bool method. The method defines what counts as equal. You can match on the error code, the message, or any other field.
func (e MyError) Is(target error) bool {
// Check if target is also a MyError with the same code
t, ok := target.(MyError)
return ok && e.Code == t.Code
}
With the Is method, errors.Is returns true when the codes match. The receiver name is usually one or two letters matching the type: (e MyError), not (this MyError). Follow the convention. It makes the code consistent with the rest of the standard library.
The worst goroutine bug is the one that never logs. The worst error bug is the one that silently returns false. Test your error checks. Verify that errors.Is finds the sentinel through every wrapper in your stack.
Decision matrix
Use errors.Is when you need to check if an error matches a specific sentinel value or wraps it. Use errors.As when you need to extract a concrete type from an error chain to access its fields or methods. Use err.Error() string comparison only when you are interfacing with a third-party library that doesn't export sentinels and you have no other choice. Use direct pointer comparison err == target only when you are certain the error has not been wrapped and you are reading legacy code that hasn't migrated to Go 1.13+. Use a custom Is method on your error type when you want to define equality logic that goes beyond pointer identity, such as matching on an error code.
Trust gofmt. Argue logic, not formatting. Error handling is logic. Make it explicit. Make it testable.