When a string isn't enough
You write a function that reads a configuration file. That function calls a library to parse JSON. The library fails because the file contains invalid syntax. You get an error back. You want to return that error to the caller, but you also want to add context: "failed to load config."
If you return the raw library error, the caller sees "invalid syntax" and has no idea which file caused the problem. If you format a new string with fmt.Errorf("load config: %v", err), the caller gets the context, but the error is now just text. The caller can no longer check if the underlying cause was a syntax error versus a permission denied error. You've lost the type information.
Go solves this with error wrapping. The %w verb in fmt.Errorf lets you attach a message to an existing error without destroying it. The result is a chain of errors. Each link in the chain holds a message and a reference to the cause. Code higher up the stack can walk the chain to find specific error types, even if they are buried deep inside.
How wrapping works
Wrapping creates a linked list of errors. When you use %w, fmt.Errorf constructs a new error value that implements the Unwrap method. This method returns the wrapped error. The errors package provides helpers that use Unwrap to traverse the chain automatically.
Think of it like forwarding a package with a note. The original package is still there. You tape a note to it explaining why you're forwarding it. The recipient can read your note, then peel it off to see the previous note, and so on, until they reach the original package. %w is the tape. %v is like taking a photo of the package, writing a note on the photo, and throwing the package away. The recipient gets the note, but they can't inspect the package.
The two main tools for inspecting wrapped errors are errors.Is and errors.As. errors.Is checks if any error in the chain matches a target sentinel error. errors.As checks if any error in the chain matches a target type and extracts it. Both functions walk the chain by calling Unwrap recursively until they find a match or reach the end.
Minimal example
This example shows how %w preserves the error chain and how errors.Is finds a sentinel error inside the wrapper.
package main
import (
"errors"
"fmt"
)
// ErrNotFound is a sentinel error defined at the package level.
var ErrNotFound = errors.New("item not found")
// lookupItem simulates a database lookup.
// It returns ErrNotFound if the ID is empty.
func lookupItem(id string) error {
if id == "" {
return ErrNotFound
}
return nil
}
// processItem adds context around the lookup.
// It uses %w to wrap the error, keeping the chain intact.
func processItem(id string) error {
err := lookupItem(id)
if err != nil {
// %w wraps the error. The result is a new error that
// holds the message and a reference to the cause.
return fmt.Errorf("process failed for %q: %w", id, err)
}
return nil
}
func main() {
err := processItem("")
// errors.Is walks the chain. It checks the top error,
// then calls Unwrap and checks the next, until it finds
// ErrNotFound or reaches the end.
if errors.Is(err, ErrNotFound) {
fmt.Println("Missing item detected")
}
// Printing the error shows the full chain.
fmt.Println("Error:", err)
}
The output shows the chain structure:
Missing item detected
Error: process failed for "": item not found
errors.Is returns true even though err is not directly ErrNotFound. It found the sentinel deep in the chain. The printed message concatenates all the notes in the chain.
Walkthrough of the chain
When processItem returns, the error value is a wrapper struct created by fmt.Errorf. This struct stores the formatted string and the underlying error. It also implements Unwrap() error, which returns the underlying error.
When errors.Is(err, ErrNotFound) runs, it does the following:
- It checks if
errequalsErrNotFound. They are not equal.erris the wrapper. - It checks if
errimplementsUnwrap. It does. - It calls
err.Unwrap()to get the next error in the chain. - It repeats the check with the unwrapped error. Now the error is
ErrNotFound. - It finds a match and returns true.
This mechanism allows you to add context at every layer of your application without losing the ability to make decisions based on the root cause. You can wrap errors as they bubble up, and only check for specific conditions at the boundaries where you handle them.
Realistic example: HTTP handler with retry logic
In a real application, you might have a handler that fetches data from a backend service. You want to retry the request if the backend returns a specific timeout error, but fail immediately for other errors. The timeout error might come from the net package, wrapped by your client code, and wrapped again by your handler.
package main
import (
"errors"
"fmt"
"net"
)
// Simulated backend error.
var ErrTimeout = errors.New("request timeout")
// fetchBackend simulates a network call.
// It returns a wrapped timeout error to demonstrate chain traversal.
func fetchBackend() error {
// The net package returns a OpError which wraps a timeout.
// Here we simulate a simple chain for clarity.
return fmt.Errorf("backend call failed: %w", ErrTimeout)
}
// processRequest handles the business logic.
// It wraps the fetch error with handler context.
func processRequest() error {
err := fetchBackend()
if err != nil {
// Wrap the error. The chain now has three links:
// processRequest -> fetchBackend -> ErrTimeout.
return fmt.Errorf("handler: %w", err)
}
return nil
}
// handleRequest decides whether to retry based on the error chain.
func handleRequest() {
err := processRequest()
if err != nil {
// errors.Is finds ErrTimeout even though it's wrapped twice.
if errors.Is(err, ErrTimeout) {
fmt.Println("Retrying request...")
return
}
// For other errors, we fail fast.
fmt.Printf("Fatal error: %v\n", err)
return
}
fmt.Println("Success")
}
func main() {
handleRequest()
}
The output is:
Retrying request...
errors.Is traverses the entire chain and finds ErrTimeout. This pattern lets you define retry policies based on root causes, regardless of how many layers of wrapping sit on top. The handler adds context for logging, the client adds context for the call, and the core logic checks the cause.
Pitfalls and compiler errors
Using %w is straightforward, but there are common mistakes that break the chain or cause runtime issues.
Using %v instead of %w
If you use %v by mistake, the error becomes a plain string. The chain is broken. errors.Is will only check the top-level error. If you wrap with %v, you lose the ability to inspect the cause.
// BAD: %v formats the error as text. The chain is lost.
err := fmt.Errorf("context: %v", underlyingErr)
// errors.Is will fail to find underlyingErr.
errors.Is(err, underlyingErr) // false
The compiler does not catch this. It's a logic error. Always use %w when you want to preserve the error type.
Wrapping nil
If you pass nil to %w, fmt.Errorf treats it as the string "nil". The result is a non-nil error with the message "nil". This is almost always a bug.
var err error // nil
// BAD: Wrapping nil creates a non-nil error with text "nil".
wrapped := fmt.Errorf("failed: %w", err)
fmt.Println(wrapped) // failed: nil
fmt.Println(wrapped == nil) // false
Check for nil before wrapping. The standard if err != nil pattern prevents this.
Multiple %w verbs
You can only use one %w verb per format string. If you try to wrap two errors, the compiler rejects the code.
// BAD: Multiple %w verbs are not allowed.
err := fmt.Errorf("a: %w, b: %w", errA, errB)
The compiler rejects this with fmt: %w verb is not supported. If you need to combine multiple errors, wrap one and mention the other in the text, or use a custom error type that holds multiple causes.
errors.As requires a pointer
errors.As extracts an error type into a target variable. The target must be a pointer to a type. If you pass a value, errors.As returns false and does nothing.
var target MyError
// BAD: target is a value, not a pointer.
errors.As(err, target) // returns false, target is unchanged
// GOOD: target is a pointer.
errors.As(err, &target)
The function signature requires any for the target, but the implementation checks for a pointer. This is a common runtime mistake. The compiler won't catch it because any accepts anything.
Decision matrix
Choose the right error handling approach based on what you need to preserve and where you are in the call stack.
Use fmt.Errorf("context: %w", err) when you are returning an error up the call stack and want to add context while keeping the error inspectable. This is the default choice for most functions.
Use fmt.Errorf("context: %v", err) when you are at the boundary of your application and converting the error to a user-facing message that will never be checked programmatically. This breaks the chain intentionally.
Use errors.New("message") when defining a sentinel error that other packages will check with errors.Is. Sentinel errors are cheap, immutable, and easy to compare.
Use errors.As(err, &target) when you need to access methods or fields on a specific error type buried in the chain. This is useful for extracting details like status codes or operation names.
Use a custom error type with an Unwrap method when you need to wrap an error but also add structured data that callers can extract. This gives you full control over the chain and the interface.
Where to go next
Wrap errors to add context, not to hide the cause. Use %w to keep the chain alive. errors.Is checks the chain, not just the surface. The worst error is the one that loses its type.