Go errors.Is and errors.As: Error Wrapping

Before Go 1.13, adding context to errors meant losing the original error entirely. If you wanted to annotate an error with additional information about where it occurred, you'd create a new error...

Key Insights

  • Error wrapping with fmt.Errorf("%w", err) preserves the error chain, allowing you to add context without losing the original error type or value
  • Use errors.Is() to check for specific sentinel errors anywhere in the chain, and errors.As() to extract typed error information from wrapped errors
  • Wrap errors at internal boundaries to add context, but consider returning unwrapped errors at API boundaries to avoid exposing implementation details

Introduction to Error Wrapping in Go

Before Go 1.13, adding context to errors meant losing the original error entirely. If you wanted to annotate an error with additional information about where it occurred, you’d create a new error string, severing any connection to the underlying cause. This made it impossible to check for specific error types or values after they’d been annotated.

// Pre-1.13 approach - loses the original error
func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config: %v", err)
    }
    // ...
}

// Caller can't check if it was os.ErrNotExist
cfg, err := ReadConfig("config.yaml")
if err != nil {
    // This will NEVER be true, even if the underlying error was ErrNotExist
    if errors.Is(err, os.ErrNotExist) {
        // Won't execute
    }
}

Error wrapping solves this by maintaining a chain of errors, where each wrapped error points to its underlying cause. This allows you to add context at each layer while preserving the ability to inspect the original error.

The fmt.Errorf %w Verb

The %w verb in fmt.Errorf wraps an error instead of just converting it to a string. This creates an error chain that can be traversed using the standard library’s error inspection functions.

func ProcessFile(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("process file %s: %w", path, err)
    }
    
    if err := validateData(data); err != nil {
        return fmt.Errorf("validation failed for %s: %w", path, err)
    }
    
    return nil
}

func validateData(data []byte) error {
    if len(data) == 0 {
        return fmt.Errorf("empty data: %w", io.ErrUnexpectedEOF)
    }
    return nil
}

// The error chain might look like:
// "validation failed for config.yaml: empty data: unexpected EOF"
// But you can still check for io.ErrUnexpectedEOF

Use %w when you want callers to be able to inspect the underlying error. Use %v when you want to create an opaque error that hides implementation details. As a rule of thumb, wrap errors within your package or application, but consider whether to expose wrapped errors across API boundaries.

errors.Is() for Error Comparison

The errors.Is() function checks if any error in the chain matches a target error. It unwraps the error chain recursively, comparing each error with the target using equality or a custom Is() method if implemented.

package main

import (
    "errors"
    "fmt"
    "io"
    "os"
)

func ReadUserConfig(userID string) ([]byte, error) {
    path := fmt.Sprintf("/users/%s/config.yaml", userID)
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read user %s config: %w", userID, err)
    }
    return data, nil
}

func main() {
    data, err := ReadUserConfig("12345")
    if err != nil {
        // errors.Is() traverses the chain
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Config doesn't exist, using defaults")
            // Handle missing config gracefully
        } else if errors.Is(err, os.ErrPermission) {
            fmt.Println("Permission denied")
            // Handle permission error
        } else {
            fmt.Printf("Unexpected error: %v\n", err)
        }
    }
    
    // Direct comparison would fail
    if err == os.ErrNotExist {
        // This will NEVER execute because err is a wrapped error
        fmt.Println("This won't print")
    }
}

The power of errors.Is() is that it works regardless of how many layers of wrapping exist. Whether the error was wrapped once or ten times, you can still check for the sentinel error you care about.

errors.As() for Type Assertions

While errors.Is() checks for specific error values, errors.As() extracts errors of a specific type from the chain. This is crucial when you need to access fields or methods on custom error types.

package main

import (
    "errors"
    "fmt"
    "os"
)

type ValidationError struct {
    Field string
    Value interface{}
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Msg)
}

func ValidateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field: "age",
            Value: age,
            Msg:   "must be non-negative",
        }
    }
    return nil
}

func ProcessUser(age int) error {
    if err := ValidateAge(age); err != nil {
        return fmt.Errorf("user processing: %w", err)
    }
    return nil
}

func main() {
    err := ProcessUser(-5)
    if err != nil {
        // Extract the ValidationError from the chain
        var validErr *ValidationError
        if errors.As(err, &validErr) {
            fmt.Printf("Invalid %s: %v (%s)\n", 
                validErr.Field, validErr.Value, validErr.Msg)
            // Output: Invalid age: -5 (must be non-negative)
        }
        
        // Also works with standard library errors
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("Path error on: %s\n", pathErr.Path)
        }
    }
}

Note that errors.As() requires a pointer to the target type. This is because it needs to assign the found error to your variable. The target must be a pointer to an interface or a pointer to a type that implements error.

Custom Error Types with Unwrap()

To make your custom error types work with the error wrapping chain, implement an Unwrap() method that returns the wrapped error. This allows errors.Is() and errors.As() to traverse through your custom errors.

package main

import (
    "errors"
    "fmt"
)

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query failed [%s]: %v", e.Query, e.Err)
}

func (e *QueryError) Unwrap() error {
    return e.Err
}

var ErrNoRows = errors.New("no rows found")

func ExecuteQuery(query string) error {
    // Simulate a query error
    return &QueryError{
        Query: query,
        Err:   ErrNoRows,
    }
}

func GetUser(id int) error {
    query := fmt.Sprintf("SELECT * FROM users WHERE id = %d", id)
    if err := ExecuteQuery(query); err != nil {
        return fmt.Errorf("get user %d: %w", id, err)
    }
    return nil
}

func main() {
    err := GetUser(123)
    if err != nil {
        // errors.Is() works because QueryError implements Unwrap()
        if errors.Is(err, ErrNoRows) {
            fmt.Println("User not found")
        }
        
        // errors.As() can extract the QueryError
        var qErr *QueryError
        if errors.As(err, &qErr) {
            fmt.Printf("Failed query: %s\n", qErr.Query)
            // Output: Failed query: SELECT * FROM users WHERE id = 123
        }
    }
}

You can also implement Unwrap() []error to return multiple errors, supporting error trees rather than just chains. This is useful for operations that can fail in multiple ways simultaneously.

Best Practices and Common Pitfalls

Don’t over-wrap errors. Each layer of wrapping should add meaningful context. Wrapping an error just to pass it up without adding information creates noise.

// Anti-pattern: Wrapping without adding value
func ProcessData(data []byte) error {
    if err := validate(data); err != nil {
        return fmt.Errorf("validate: %w", err) // Adds no context
    }
    return nil
}

// Better: Add context or return directly
func ProcessData(data []byte) error {
    if err := validate(data); err != nil {
        return fmt.Errorf("failed to process %d bytes: %w", len(data), err)
    }
    return nil
}

// Or just return if you have nothing to add
func ProcessData(data []byte) error {
    return validate(data)
}

Consider API boundaries carefully. When writing a library, think about whether you want to expose wrapped errors or create new error types. Wrapped errors can leak implementation details.

// Library code - might expose too much
func (c *Client) FetchUser(id string) (*User, error) {
    resp, err := http.Get(c.baseURL + "/users/" + id)
    if err != nil {
        // Exposes that we use http.Get internally
        return nil, fmt.Errorf("fetch user: %w", err)
    }
    // ...
}

// Better for a library - define your own error types
var ErrUserNotFound = errors.New("user not found")

func (c *Client) FetchUser(id string) (*User, error) {
    resp, err := http.Get(c.baseURL + "/users/" + id)
    if err != nil {
        if resp != nil && resp.StatusCode == 404 {
            return nil, ErrUserNotFound
        }
        // Return opaque error for other cases
        return nil, fmt.Errorf("fetch user failed")
    }
    // ...
}

Avoid wrapping errors multiple times in the same function. If you wrap an error and then need to wrap it again, you’re probably doing too much in one function.

Conclusion

Error wrapping transformed error handling in Go from a lossy process to one that preserves the full context chain. Use fmt.Errorf with %w to wrap errors when adding context, errors.Is() to check for sentinel errors anywhere in the chain, and errors.As() to extract typed error information.

The decision tree is straightforward: wrap errors with %w when moving up the call stack within your application, use errors.Is() when you need to check for specific error values, use errors.As() when you need to access fields or methods on custom error types, and implement Unwrap() on your custom errors to participate in the error chain.

This system gives you the best of both worlds: rich error messages for debugging and programmatic error inspection for control flow. Just remember that with great power comes great responsibility—wrap judiciously and always consider your API boundaries.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.