The wrapped error problem
A function calls a database, the database calls a driver, the driver calls the network stack. Each layer catches a failure, adds its own context, and returns a new error. By the time the error reaches your top-level handler, it looks like a single value, but it actually contains a trail of breadcrumbs. You need to know if the root cause was a timeout so you can retry, or if it was a constraint violation so you can show a user message. Direct equality checks fail because the original error is buried inside a wrapper. Go 1.13 solved this with fmt.Errorf wrapping and two inspection functions: errors.Is and errors.As.
Error wrapping turns a flat error value into a chain. Each link holds a message and a pointer to the next link. errors.Is walks that chain to find a specific sentinel value. errors.As walks the chain to find a specific type and hands it back to you. They solve two different problems that constantly appear in production code.
Error wrapping is the standard way to add context without losing the original cause. Trust the chain. Inspect it with the right tool.
How error chains actually work
Go errors implement the error interface, which requires a single Error() string method. Before Go 1.13, errors were opaque strings or simple structs. You compared them with == or checked types with type assertions. Both approaches broke the moment you added context.
Go 1.13 introduced an optional Unwrap() error method. If an error implements it, the standard library knows how to peel back layers. fmt.Errorf("failed to read: %w", err) automatically creates a wrapper that stores the new message and implements Unwrap to return the original err. The %w verb is the key. It tells the formatter to preserve the underlying error instead of just printing it.
The standard library provides two functions to navigate these chains. errors.Is compares values. errors.As compares types and extracts data. They both start at the top of the chain and move downward until they find a match or hit the bottom.
The chain is just a linked list of errors. Walk it deliberately.
Minimal example: checking and extracting
Here is a small program that demonstrates both functions. It creates a sentinel error, wraps it twice, and then inspects the result.
package main
import (
"errors"
"fmt"
)
// ErrNotFound represents a missing resource.
var ErrNotFound = errors.New("resource not found")
// FindItem simulates a lookup that wraps errors.
func FindItem(id int) error {
// Return a wrapped error to preserve the chain.
return fmt.Errorf("query failed for id %d: %w", id, ErrNotFound)
}
func main() {
// Capture the wrapped error from the function call.
err := FindItem(42)
// Check if the chain contains our sentinel error.
if errors.Is(err, ErrNotFound) {
fmt.Println("Found the missing resource error")
}
// Define a variable to receive the extracted error.
var target error
// Attempt to assign the sentinel error to our variable.
if errors.As(err, &target) {
fmt.Println("Extracted error:", target.Error())
}
}
The first check uses errors.Is. It walks the chain, compares each link to ErrNotFound, and returns true when it finds a match. The second check uses errors.As. It walks the chain, checks if any link matches the type of &target, and assigns it if it does. Both succeed because ErrNotFound is inside the chain.
Sentinel errors are cheap to create and cheap to compare. Keep them package-level and exported when other packages need to check for them.
Walking the chain at runtime
Understanding what happens under the hood removes the magic. When you call errors.Is(err, target), the runtime does three things. First, it checks if err and target are identical. If they match, it returns true immediately. Second, it checks if err implements an Is(error) bool method. If it does, it calls that method and returns the result. Third, it calls err.Unwrap() to get the next link and repeats the process until the chain ends.
errors.As follows a similar path but focuses on types. It checks if err can be assigned to the type of target. If yes, it performs the assignment and returns true. If no, it checks for an As(error) bool method. If that exists, it calls it. Otherwise, it unwraps and continues. The assignment requirement is why errors.As needs a pointer. Go cannot assign a value to a non-addressable variable, and the function needs a place to store the extracted error.
Custom error types can override the default behavior by implementing Is or As. This is useful when you want to match errors based on codes, status values, or partial data instead of strict identity. The standard library respects these methods automatically.
The runtime walks the chain methodically. Implement Is or As when default matching falls short.
Realistic example: HTTP handler with custom errors
Production code rarely deals with bare sentinels. You usually have custom error types that carry metadata, and you need to extract that metadata in a handler. Here is how a file upload endpoint might handle validation failures.
package main
import (
"errors"
"fmt"
"net/http"
)
// ValidationError carries details about a failed upload.
type ValidationError struct {
Field string
Message string
}
// Error implements the error interface for ValidationError.
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
// ValidateFile checks size and format, returning wrapped errors.
func ValidateFile(name string, size int64) error {
if size > 10_000_000 {
// Wrap the custom error with context about the file.
return fmt.Errorf("processing %s: %w", name, &ValidationError{
Field: "size",
Message: "file exceeds 10MB limit",
})
}
if name[len(name)-3:] != ".csv" {
return fmt.Errorf("processing %s: %w", name, &ValidationError{
Field: "format",
Message: "only CSV files allowed",
})
}
return nil
}
// HandleUpload processes an incoming file request.
func HandleUpload(w http.ResponseWriter, r *http.Request) {
// Simulate validation with a large file.
err := ValidateFile("report.pdf", 15_000_000)
if err != nil {
// Check for the specific validation error type.
var vErr *ValidationError
if errors.As(err, &vErr) {
// Return a structured response using extracted fields.
http.Error(w, fmt.Sprintf("%s: %s", vErr.Field, vErr.Message), http.StatusBadRequest)
return
}
// Fallback for unexpected errors.
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
The handler receives a wrapped error. It declares a pointer to ValidationError and passes its address to errors.As. If the chain contains a *ValidationError, the function assigns it to vErr. The handler then reads the Field and Message structs to build a precise HTTP response. The if err != nil pattern remains visible and explicit, which matches Go community expectations. Error handling should never hide behind silent recovery.
Custom types make extraction powerful. Keep the extraction logic close to the boundary where you need the data.
Pitfalls and compiler traps
The functions are simple, but a few patterns cause friction. The most common mistake is forgetting the address-of operator in errors.As. The function signature requires a pointer to the target variable. If you pass a value instead, the compiler rejects the program with cannot use target (variable of type MyError) as *MyError value in argument. The pointer is mandatory because the function needs to write the extracted value back to your variable.
Shadowing creates silent bugs. If you declare a new variable inside the if block instead of reusing the outer one, the extracted value disappears immediately after the block ends. Declare the target variable outside the condition so you can use it later.
Custom error types sometimes break errors.Is when they wrap errors internally but do not implement Unwrap. If your struct holds an error field, you must implement Unwrap() error to return it. Without it, the chain stops at your type, and errors.Is never reaches the underlying cause. The compiler will not warn you about missing Unwrap methods. The runtime simply stops walking.
Another trap is comparing wrapped errors with ==. Direct equality only checks the top-level wrapper. It never looks inside. If you write if err == ErrTimeout, the check fails the moment you wrap ErrTimeout with fmt.Errorf. Switch to errors.Is and the problem disappears.
The compiler catches type mismatches. The runtime catches missing Unwrap methods. Test your error chains with wrapped values.
When to reach for which tool
Both functions inspect error chains, but they answer different questions. Pick the right one based on what you need to do with the result.
Use errors.Is when you need to check for a specific sentinel error value anywhere in the chain. Use errors.As when you need to extract a custom error type or interface to access its fields or methods. Use direct == comparison only when you are certain the error has not been wrapped and you are checking the immediate return value. Use type assertions only when you are working with unchained errors and need a quick type check. Use errors.Is with a custom Is method when you want to match errors based on status codes or partial data instead of strict identity. Use errors.As with a custom As method when you want to convert one error type into another during chain traversal.
The choice depends on whether you need identity or data. Match the tool to the requirement.