Efficient Error Handling with Go 1.13 Wrapping

Gopher LearningImage credit: Ashley McNamara - Gopher Learning

Error handling is often cited as one of Go’s most distinctive features. If you have written more than ten lines of Go, you are intimately familiar with the if err != nil pattern. However, for a long time, Go developers faced a dilemma: how do you add context to an error (like "failed to read config") without losing the original error information (like "file not found")? Before Go 1.13, this often involved third-party libraries or messy string parsing. The introduction of error wrapping in Go 1.13 fundamentally changed this, providing a standard way to layer context onto errors while keeping the underlying cause accessible.

In this article, we will explore the mechanics of error wrapping, standard testing functions, and how to build custom error types that leverage these features for cleaner, more maintainable code.

The Evolution of Error Handling

To appreciate the modern approach, it helps to briefly look at the past. Before Go 1.13, if you wanted to add context to an error, you typically used fmt.Errorf:

// Pre-Go 1.13
if err != nil {
    return fmt.Errorf("loading config failed: %v", err)
}

This created a new error with a string message. The problem? The original error err was lost. It was converted to a string and embedded in the new message. If the caller wanted to check if the underlying error was specifically a os.ErrNotExist, they had to resort to fragile string matching:

// Fragile and discouraged
if strings.Contains(err.Error(), "no such file or directory") {
    // handle missing file
}

Go 1.13 introduced a standardised mechanism to "wrap" an error, preserving the original error instance inside the new one, forming a chain of errors that can be programmatically traversed.

Core Mechanics: The %w Verb

The simplest way to wrap an error is using the %w verb with fmt.Errorf. It works almost exactly like %v, but it instructs the compiler and runtime to store the original error as a wrapped entity.

func LoadConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // We add context ("reading config") but wrap the original error
        return nil, fmt.Errorf("reading config %s: %w", path, err)
    }
    return data, nil
}

In this example, the error returned by LoadConfig contains two things:

  1. The formatted message: "reading config /tmp/config.json: no such file or directory"
  2. The original underlying error returned by os.ReadFile.

This allows callers to inspect the chain of errors to find the root cause, regardless of how many times it has been wrapped.

Checking for Sentinel Errors: errors.Is

When you wrap errors, you can no longer compare them using simple equality (err == os.ErrNotExist) because the error value has changed—it is now a new error containing the old one.

To solve this, Go provides errors.Is. This function recursively unwraps the error chain to see if any error in the chain matches the target.

Example Usage

var ErrPermission = errors.New("permission denied")

func DoSomething() error {
    return fmt.Errorf("operation failed: %w", ErrPermission)
}

func main() {
    err := DoSomething()

    // Correct way to check wrapped errors
    if errors.Is(err, ErrPermission) {
        fmt.Println("Handle permission error gracefully")
    }
}

Use errors.Is when you are looking for a specific value (a sentinel error). It is the robust replacement for ==.

Inspecting Error Types: errors.As

Sometimes, you don't just care about which error occurred, but you need data from the error. For example, checking a *os.PathError to get the path that failed.

errors.As is the wrapped equivalent of a type assertion. It traverses the error chain and tries to assign the first matching error it finds to the target variable.

Gopher Inspecting CodeImage credit: Ashley McNamara - Nerdy Gopher Inspecting Details

Example Usage

func main() {
    _, err := LoadConfig("missing.json")
    if err != nil {
        var pathErr *os.PathError
        // errors.As checks if any error in the chain is of type *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("File error on path: %s\n", pathErr.Path)
            fmt.Printf("Operation: %s\n", pathErr.Op)
        } else {
            fmt.Println("Unknown error:", err)
        }
    }
}

If errors.As returns true, pathErr will be populated with the error found in the chain.

Creating Custom Error Types

For complex applications, standard errors might not be enough. You might want a custom struct that holds an error code, a user ID, or other metadata. To make your custom error type play nicely with errors.Is and errors.As, you simply need to implement the Unwrap method.

Implementing Unwrap

The Unwrap method must return the underlying error.

type QueryError struct {
    Query string
    Err   error // The underlying error
}

// Implement the error interface
func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

// Implement Unwrap to support errors.Is and errors.As
func (e *QueryError) Unwrap() error {
    return e.Err
}

Now, any function receiving a *QueryError can use standard Go 1.13 patterns:

func RunQuery(q string) error {
    return &QueryError{
        Query: q,
        Err:   context.DeadlineExceeded,
    }
}

func main() {
    err := RunQuery("SELECT * FROM users")
    
    // Checks if the underlying error is context.DeadlineExceeded
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("Query timed out")
    }
    
    // Checks if the error is of type *QueryError
    var qe *QueryError
    if errors.As(err, &qe) {
        fmt.Printf("The failing query was: %s\n", qe.Query)
    }
}

Best Practices and Pitfalls

While wrapping is powerful, it introduces new responsibilities.

1. Avoid Leaking Implementation Details

When you wrap an error, you expose the underlying error to the API consumer. If the underlying error is an implementation detail (like a specific database driver error) that you don't want users to depend on, do not use %w. Use %v instead.

// Good: Hides the internal DB error, preventing API leakage
if err != nil {
    return fmt.Errorf("user lookup failed: %v", err) 
}

// Bad: Exposes internal SQL driver error types to the caller
if err != nil {
    return fmt.Errorf("user lookup failed: %w", err)
}

Only wrap errors when you intend for the caller to be able to unwrap them and handle specific root causes.

2. Don't Wrap Twice

A common mistake is wrapping an error at every layer of the stack. This leads to verbose, repetitive error messages like: "controller failed: service failed: repository failed: db failed: connection timeout"

Wrap errors only when you are adding useful context (like an ID or specific operation name). If a layer merely passes the error up, just return it directly.

3. Log OR Return, Never Both

This is a classic anti-pattern. If you log an error and then return it (wrapped or not), the caller will likely log it again. This floods your logs with duplicate noise.

// Anti-pattern
if err != nil {
    log.Println("error:", err) // Don't do this
    return fmt.Errorf("failed: %w", err)
}

Choose one: either handle the error (log it and return nil or a clean response) or return it up the stack.

Go 1.13's error wrapping brings structure and depth to error handling without sacrificing the simplicity Go is known for. By using %w, errors.Is, and errors.As, you can create rich error chains that carry both human-readable context and machine-readable root causes.

The key is intentionality: wrap errors when the caller needs to know the "why," but mask them when the implementation details should remain private. Mastering these tools allows you to write libraries and applications that are easier to debug and more reliable in production.

Resources