The context trap
You're writing a function that reads a config file. It fails. The error says open config.yaml: no such file or directory. That's fine for a script. Now you're building a service. The config reader is called by a handler, which is called by a router. The error bubbles up. If you just print it, the user sees open config.yaml: no such file or directory. They don't know this came from the config loader. You add context: fmt.Errorf("config load failed: %v", err). Now the message is better. But later, you want to retry if the file is missing. You check errors.Is(err, os.ErrNotExist). It returns false. You broke the chain. You added context but lost the ability to inspect the cause.
This is the error wrapping problem. Go solves it by treating errors as values that can be chained. Wrapping adds a layer of context without destroying the original error. You get readable messages for humans and inspectable values for code.
How wrapping works
Error wrapping relies on the Unwrap method. Any error type can implement Unwrap() error to point to the next error in the chain. fmt.Errorf with the %w verb generates this automatically. The chain can be as long as needed. errors.Is and errors.As walk the chain. errors.Is compares values. errors.As compares types.
Think of wrapping like adding a label to a package without destroying the contents. The outer box has the address and notes. Inside is the original package. You can read the notes, or you can open the box to find the original package. The original package remains intact. You can pass the wrapped error around, and anyone can peel back the layers to find the root cause.
This design avoids exceptions. Exceptions jump the stack and hide control flow. Errors are explicit returns. Wrapping keeps them explicit while adding context. The convention is to wrap errors as close to the source as possible. Add context that helps the caller understand what went wrong in this layer. Don't wrap every single error. Wrap when the context adds value.
Errors are values. Treat them like data.
Minimal example
Here's the simplest way to wrap an error: use fmt.Errorf with %w.
package main
import (
"errors"
"fmt"
"os"
)
// ErrNotFound is a sentinel error for missing files.
var ErrNotFound = errors.New("file not found")
// readFile opens a file and returns a wrapped error if it fails.
func readFile(path string) error {
// Attempt to open the file.
_, err := os.Open(path)
if err != nil {
// %w wraps the error, preserving the chain for errors.Is and errors.As.
// Using %v here would break the chain and make the original error uncheckable.
return fmt.Errorf("reading %s: %w", path, err)
}
return nil
}
func main() {
err := readFile("missing.txt")
if err != nil {
// errors.Is checks the entire chain, not just the top-level error.
if errors.Is(err, os.ErrNotExist) {
fmt.Println("File does not exist")
} else {
fmt.Printf("Unexpected error: %v\n", err)
}
}
}
The code defines a sentinel error ErrNotFound. Sentinel errors are singleton values used to represent specific conditions. readFile opens a file. If it fails, it wraps the error with fmt.Errorf. The %w verb is crucial. It tells the formatter to wrap the error. The wrapper implements Unwrap to return the original error. In main, errors.Is checks the chain. It finds os.ErrNotExist deep inside and returns true. If you used %v instead of %w, errors.Is would return false. The chain would be broken.
The chain is transparent to the standard library functions.
Walking the chain
When you call fmt.Errorf("reading %s: %w", path, err), the function returns a new error value. This value implements the error interface, so it has an Error() string method. It also implements an Unwrap() error method. The Unwrap method returns the original err.
When you print the error, Go's formatting logic sees the chain. It calls Error() on the wrapper. The wrapper formats its message, then calls Error() on the wrapped error. This continues recursively until the chain ends. The output looks like reading missing.txt: open missing.txt: no such file or directory. Each colon separates a layer of context.
When you call errors.Is(err, os.ErrNotExist), the function walks the chain. It checks if the current error equals the target. If not, it calls Unwrap() and checks the next one. It repeats this until it finds a match or reaches the end. This means you can check for the root cause even after wrapping it multiple times.
errors.As works similarly but checks types. It tries to assign the current error to a target pointer. If the types match, it succeeds. If not, it unwraps and tries again. This lets you extract custom error types from deep in a chain.
The convention is to check errors immediately after they are returned. The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore an error.
Check errors where they happen. Don't defer the check.
Realistic example
Real code often involves multiple layers and custom error types. You might want to check for a specific type to extract details, not just a sentinel value. errors.As handles this. Custom error types must implement Unwrap to join the chain.
Here's a custom error type that adds structured data.
// ConfigError represents a failure to load configuration.
type ConfigError struct {
// File is the path that failed to load.
File string
// Reason is the underlying cause.
Reason error
}
// Error implements the error interface.
// Error formats the config error with the file path and cause.
func (e *ConfigError) Error() string {
return fmt.Sprintf("config error in %s: %v", e.File, e.Reason)
}
// Unwrap returns the underlying cause.
// Unwrap enables errors.Is and errors.As to traverse into the Reason field.
func (e *ConfigError) Unwrap() error {
return e.Reason
}
The ConfigError struct holds the file path and the reason. The Error method formats the message. The Unwrap method returns the Reason. This connects the custom error to the underlying cause. Without Unwrap, errors.Is and errors.As would stop at ConfigError. They wouldn't see the cause inside.
Receiver naming follows Go conventions. The receiver is e, a short name matching the type. Don't use this or self. Public names start with a capital letter. Private names start lowercase. The File and Reason fields are exported so callers can access them.
Custom errors must implement Unwrap to join the chain.
Here's how to use the custom error in a handler.
func loadConfig(path string) error {
// Simulate a permission error from the OS.
cause := fmt.Errorf("open %s: permission denied", path)
// Wrap the OS error in a custom type to add domain context.
return &ConfigError{
File: path,
Reason: cause,
}
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
err := loadConfig("app.yaml")
if err != nil {
// Attempt to extract the custom error type from the chain.
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
// Access structured fields only available on the custom type.
fmt.Printf("Config failed for %s\n", cfgErr.File)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Fallback for unexpected errors.
fmt.Printf("Error: %v\n", err)
}
}
loadConfig creates a ConfigError and wraps the OS error. handleRequest calls loadConfig. It uses errors.As to extract the ConfigError. errors.As takes a pointer to the target variable. It walks the chain. When it finds a *ConfigError, it assigns it to cfgErr and returns true. The handler can then access cfgErr.File. This pattern lets you attach structured data to errors while preserving the chain.
The mantra "Accept interfaces, return structs" applies here. The function returns error, an interface. The implementation returns *ConfigError, a struct. Callers accept the interface. They don't need to know the concrete type unless they use errors.As.
Accept interfaces, return structs.
Pitfalls and errors
The most common mistake is using %v instead of %w. If you write fmt.Errorf("failed: %v", err), the error chain breaks. The new error contains the string representation of the old error, but not the error value itself. errors.Is will fail to find the root cause. The compiler won't catch this. It's a runtime logic error. You'll see checks return false when you expect true. A broken chain is harder to debug than a missing check.
Another pitfall is wrapping nil. fmt.Errorf("msg: %w", nil) returns an error that wraps nil. errors.Is handles this correctly, but it's redundant. Check for nil before wrapping. Also, avoid circular chains. If error A wraps B and B wraps A, errors.Is loops forever and panics. This is rare but possible with custom logic. The compiler won't catch circular references at runtime.
If you forget to import the errors package, the compiler rejects the code with undefined: errors. If you try to pass a non-error value to errors.Is, you get a type error like cannot use x as error value in argument. If you use errors.As with a non-pointer target, the compiler complains with cannot use non-pointer as target. Always pass a pointer to errors.As.
Don't fight the type system. Wrap the value or change the design.
Decision matrix
Use fmt.Errorf with %w when you need to add context to an error and preserve the ability to inspect the original cause.
Use fmt.Errorf with %v when you want to format an error as a string and do not need to check the error type later.
Use a custom error type with Unwrap() when you need to attach structured data to an error, such as a file path or request ID, while still supporting chain traversal.
Use errors.New when creating a sentinel error value that represents a specific condition, like ErrNotFound.
Use errors.Is when checking if an error matches a specific sentinel value anywhere in the chain.
Use errors.As when extracting a specific error type from the chain to access its fields or methods.
Use plain error returns when the function is the root cause and no wrapping is needed.