The problem with error strings
You call a function that reads a configuration file. That function calls another function to open the file. The open call fails because the file does not exist. By the time the error reaches your HTTP handler, the error value looks like load config: open config.yaml: no such file or directory.
You need to know if the root cause is a missing file so you can return a 404 instead of a 500. You could check if the error string contains "no such file", but that breaks the moment the OS changes its message or you wrap the error again with different text. String matching is fragile. You need a way to inspect the structure of the error, not just its message.
Go solves this with error wrapping. Each layer of your call stack can add context to an error while preserving a reference to the original cause. errors.Unwrap is the standard library function that retrieves the underlying error from a wrapped error, allowing you to traverse the chain programmatically.
How error wrapping works
Error wrapping creates a linked list of errors. Each error value holds a message and, optionally, a pointer to the error that caused it. The original error sits at the bottom of the chain. Every wrapper sits above it, adding context as the error bubbles up.
The mechanism relies on a single interface. Any error type can implement an Unwrap method that returns the underlying error.
type UnwrapError interface {
Unwrap() error
}
If an error implements this method, it signals that there is a deeper cause. errors.Unwrap checks for this method. If it exists, errors.Unwrap calls it and returns the result. If the method is missing, errors.Unwrap returns nil, indicating the chain has ended.
This design means error wrapping is not magic. It is just interface dispatch. Third-party errors can join the chain if they implement Unwrap. Standard library errors like os.PathError implement Unwrap so you can detect os.ErrNotExist even after the error has been wrapped multiple times.
Wrap close to the source. Check at the boundary.
Minimal example
Here's the simplest way to create and unwrap a chain using fmt.Errorf and the %w verb.
package main
import (
"errors"
"fmt"
)
// ErrNotFound is a sentinel error for missing resources.
var ErrNotFound = errors.New("not found")
// wrapError adds context and returns a wrapped error.
func wrapError(err error) error {
// fmt.Errorf with %w wraps the error, preserving the chain.
// The %w verb tells Go to store the underlying error.
return fmt.Errorf("processing failed: %w", err)
}
func main() {
original := ErrNotFound
wrapped := wrapError(original)
// errors.Unwrap retrieves the error stored by %w.
// It returns nil if the error has no underlying cause.
if inner := errors.Unwrap(wrapped); inner != nil {
fmt.Println("Inner error:", inner)
}
}
The %w verb is the key. When you pass an error to %w, fmt.Errorf creates a wrapper that implements Unwrap. That method returns the error you passed. If you use %v instead, the error is converted to a string and the chain breaks. The underlying error is lost forever.
What happens under the hood
When the compiler sees fmt.Errorf("msg: %w", err), it generates code that calls a formatting function. That function inspects the format string. When it finds %w, it captures the corresponding argument and stores it inside a new error struct. This struct implements the error interface via a Error() method that returns the formatted string. It also implements the Unwrap() method to return the stored argument.
At runtime, calling errors.Unwrap(wrapped) performs a type assertion. It checks if the error value satisfies the UnwrapError interface. If it does, the function calls the method and returns the result. If the assertion fails, it returns nil.
This means errors.Unwrap works with any error type that defines Unwrap, not just errors created by fmt.Errorf. You can define your own error types and join them to the chain by adding the method.
// CustomError wraps an error with a custom code.
type CustomError struct {
Code int
Err error
}
// Error returns the error message.
func (e *CustomError) Error() string {
return fmt.Sprintf("code %d: %s", e.Code, e.Err)
}
// Unwrap returns the underlying error.
func (e *CustomError) Unwrap() error {
// Returning e.Err allows errors.Is to find targets inside CustomError.
return e.Err
}
The chain is only as strong as the weakest %w.
Realistic error handling
Here's a realistic pattern: wrap errors as they bubble up, then check the chain at the top level.
package main
import (
"errors"
"fmt"
"os"
)
// processFile reads a file and checks for specific errors in the chain.
func processFile(path string) error {
_, err := os.ReadFile(path)
if err != nil {
// Wrap the error. The %w verb preserves the underlying error.
// The caller can still detect os.ErrNotExist via errors.Is.
return fmt.Errorf("load %s: %w", path, err)
}
return nil
}
func main() {
err := processFile("missing.json")
if err != nil {
// errors.Is walks the chain to find os.ErrNotExist.
// It returns true even though the error is wrapped.
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File is missing, creating default")
return
}
// Handle other errors
fmt.Println("Unexpected error:", err)
}
}
In this example, processFile wraps the OS error. The wrapper adds the file path for context. The main function checks for os.ErrNotExist using errors.Is. errors.Is automatically walks the chain by calling Unwrap repeatedly until it finds a match or reaches the end. You do not need to call errors.Unwrap manually to check for sentinels. errors.Is handles the traversal for you.
The convention is to wrap errors near the point of failure and check for specific errors at the boundaries of your application, such as HTTP handlers or CLI entry points. This keeps context attached to the error while allowing callers to react to specific conditions.
Error messages describe the context. The chain describes the cause.
Pitfalls and compiler behavior
Using %v instead of %w is the most common mistake. The compiler does not warn you. The code compiles and runs. The error chain breaks silently. errors.Is returns false because the underlying error is no longer accessible. The error message still contains the text of the original error, which can mislead you into thinking the chain is intact. Always use %w when you want to preserve the error.
Manual unwrapping loops are dangerous. You might write a loop to find a specific error type.
for err != nil {
if _, ok := err.(*TargetError); ok {
// found
}
err = errors.Unwrap(err)
}
This loop can hang forever if an error's Unwrap method returns itself. errors.Is and errors.As protect against cycles by tracking visited errors. Manual loops do not. Use the standard library helpers instead of writing your own traversal logic.
If you pass a non-error value to errors.Unwrap, the compiler rejects the program with cannot use value (type string) as error value in argument to errors.Unwrap. The function signature requires an error interface. You cannot unwrap a string or an integer.
Custom error types must return nil from Unwrap if there is no underlying error. Returning a non-nil error that is not the underlying cause confuses errors.Is and errors.As. The helpers assume Unwrap returns the direct cause. If Unwrap returns a different error, the chain becomes inconsistent and checks may fail unexpectedly.
The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrapping errors adds context without hiding the check. Keep the checks explicit.
Manual loops are traps. Use the helpers.
When to use what
Use errors.Unwrap when you need to iterate the chain manually for logging or debugging, though errors.Is and errors.As handle most cases.
Use errors.Is when you need to check if a sentinel error exists anywhere in the chain.
Use errors.As when you need to extract a specific error type to access its fields.
Use fmt.Errorf with %w when you want to add context while preserving the underlying error.
Use fmt.Errorf with %v when you want to break the chain and treat the error as a final message.
Use a custom error type with an Unwrap method when you need to wrap errors without fmt.Errorf or add extra behavior.
Use errors.Join when you need to combine multiple errors into a single error value that can be unwrapped individually.
Trust the helpers. They handle cycles, nil checks, and type assertions safely.