Right, let’s talk about wrapping errors. This is where we go from the polite but utterly useless “something went wrong” to the glorious, detailed “the flux capacitor failed because you tried to input 1.21 gigawatts on a 15-amp household circuit, you maniac.”

Before Go 1.13, we were all basically doing this by hand, attaching context with fmt.Errorf("some context: %v", err). It worked, but it was a string-concatenation free-for-all. There was no standard way to unwrap the error and get back to the original cause. The %w verb and the accompanying errors package changes fixed that. It’s one of those “why wasn’t it always like this?” features.

The Basic Incantation: fmt.Errorf and %w

The magic is simple. Instead of using %v or %s to embed an error into a new error message, you use %w. This creates a new error that wraps the original one, preserving it for inspection.

package main

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

var ErrFileCorrupt = errors.New("file is corrupt: invalid magic number")

func readConfig() error {
    _, err := os.Open("config.toml")
    if err != nil {
        // Wrap the low-level os.Open error with our own context
        return fmt.Errorf("could not read config file: %w", err)
    }
    // ... some parsing logic that fails...
    return ErrFileCorrupt
}

func main() {
    err := readConfig()
    if err != nil {
        fmt.Println(err) 
        // Output: could not read config file: open config.toml: no such file or directory
    }
}

See what happened there? The error message now includes the full chain: our custom context and the original OS error. We’ve added meaning without obliterating the underlying cause. This is infinitely better for debugging.

Unwrapping the Onion: errors.Is and errors.As

So you’ve wrapped an error. Now what? You can’t just use == to check for a specific sentinel error anymore because the returned error is a new, wrapper type. This is where most people face-plant initially. Enter the unwrapping duo.

errors.Is is the new ==. It intelligently traverses the chain of wrapped errors until it finds a match.

err := readConfig()

// The OLD way (will often fail with wrapping)
if err == ErrFileCorrupt {
    fmt.Println("Found it! (but probably not)")
}

// The NEW way (actually works)
if errors.Is(err, ErrFileCorrupt) {
    fmt.Println("Found it! This will work even if ErrFileCorrupt is wrapped.")
}

// It also works for the wrapped os.PathError!
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("The config file doesn't exist. Go create it.")
}

errors.As is the new type assertion. It traverses the chain looking for a specific error type and, if found, copies the error into your target variable.

func main() {
    err := readConfig()
    
    var pathError *os.PathError
    if errors.As(err, &pathError) {
        // Now we can access the rich details of the original PathError!
        fmt.Printf("Wow, even more detail: Operation: %s, Path: %s\n", pathError.Op, pathError.Path)
    }
}

This is incredibly powerful. It means you can preserve the rich, typed information from low-level libraries all the way up your call stack, and then pluck it out at the level where you can actually do something about it.

The %w Verb is Not Magic Formatting

Here’s a crucial detail that’s easy to miss: %w is only meaningful when used with fmt.Errorf. You can’t use it with other functions like fmt.Sprintf or log.Printf and expect any special unwrapping behavior. Those functions will just treat it as a verb for a string and create a mess. The fmt.Errorf function is specifically designed to create a wrapper error type when it encounters %w.

// WRONG. This does NOT wrap the error.
wrappedErr := fmt.Sprintf("context: %w", err)

// RIGHT. This DOES wrap the error.
wrappedErr := fmt.Errorf("context: %w", err)

The Pitfall of Over-Wrapping

Just because you can add context at every level doesn’t always mean you should. The most common anti-pattern I see is mindless wrapping at every function call, creating comically long error messages.

// Please don't do this.
err = fmt.Errorf("service layer: %w", err)
err = fmt.Errorf("database layer: %w", err)
err = fmt.Errorf("network layer: %w", err)
// Error message: "network layer: database layer: service layer: no rows found"

You end up with a tragic novel instead of a useful error. Wrap when you add meaningful, relevant context that isn’t already implied by the call stack. In many cases, especially in lower-level functions, simply returning the underlying error with return err is perfectly fine. Wrap when you can answer the “why,” not just the “what.”