The story behind the error
You are writing a function that downloads a file, parses it, and saves the result. The download fails because the network is down. You return an error. The caller catches it and returns its own error. Now the error looks like save failed: download failed: connection refused. That chain tells the whole story. Without wrapping, you just get save failed and lose the root cause. Error chaining lets you add context as an error bubbles up, while keeping the original error accessible for checks.
Wrapping is like labeling a package
Think of error wrapping like adding a sticky note to a package. The package is the original error. The sticky note says "This came from the database layer." You can peel off the notes to see the package, or check the notes to see where it traveled. In Go, fmt.Errorf with %w creates this link. The errors package provides tools to inspect the chain. errors.Is checks if a specific error exists anywhere in the chain. errors.As extracts a specific type from the chain.
Minimal example
Here is the basic pattern: create a root error, wrap it with context, and check the chain.
package main
import (
"errors"
"fmt"
)
func main() {
// Create the root cause error.
rootErr := errors.New("disk full")
// Wrap the error with context using %w.
// The %w verb links the new error to rootErr.
wrappedErr := fmt.Errorf("write failed: %w", rootErr)
// errors.Is walks the chain to find rootErr.
if errors.Is(wrappedErr, rootErr) {
fmt.Println("Found disk full in chain")
}
}
Wrap errors to preserve context. Lose the chain and you lose the debug path.
How the chain works
fmt.Errorf returns a new error value. When you use %w, the returned error implements an Unwrap method. This method returns the wrapped error. errors.Is calls Unwrap repeatedly until it finds a match or the chain ends. The chain is a linked list of errors. Each node holds a message and a pointer to the next error.
You don't need to define Unwrap yourself unless you are building a custom error type that wraps another error. fmt.Errorf generates the wrapper automatically. The wrapper type is internal to the fmt package, but it satisfies the interface{ Unwrap() error } requirement. This interface is the contract that allows errors.Is and errors.As to traverse the chain.
Realistic example
Real code has multiple layers. Each layer adds context. Here is a file processing function that wraps errors at each step.
package main
import (
"errors"
"fmt"
"os"
)
// processFile reads a file and returns an error with context.
func processFile(path string) error {
// os.Open returns a file handle or an error.
f, err := os.Open(path)
if err != nil {
// Wrap the OS error with the path for debugging.
return fmt.Errorf("open %s: %w", path, err)
}
// Defer close to ensure the file handle is released regardless of errors.
defer f.Close()
// Simulate a processing error.
return fmt.Errorf("process %s: %w", path, errors.New("corrupt data"))
}
func main() {
err := processFile("data.bin")
if err != nil {
// Print the full chain.
fmt.Println(err)
}
}
# output:
open data.bin: corrupt data
The output shows the chain flattened into a single string. Each wrapper adds its prefix. The root cause is at the end. This format is standard in Go. The convention is to use a colon and space to separate context from the wrapped error.
Error handling is verbose by design. The if err != nil pattern makes the unhappy path visible. Don't fight the boilerplate. It keeps control flow explicit.
Extracting types with errors.As
errors.Is checks equality. errors.As checks type. If your chain contains a *os.PathError, errors.As can extract it so you can read the Path field. This is useful when you need details from a specific error type buried in the chain.
package main
import (
"errors"
"fmt"
"os"
)
func main() {
// Create a path error.
rootErr := &os.PathError{Op: "open", Path: "secret.txt", Err: errors.New("permission denied")}
// Wrap the error.
wrappedErr := fmt.Errorf("access denied: %w", rootErr)
// Extract the PathError from the chain.
var pathErr *os.PathError
if errors.As(wrappedErr, &pathErr) {
fmt.Println("Path:", pathErr.Path)
}
}
# output:
Path: secret.txt
errors.As walks the chain and tries to match the type. If it finds a match, it assigns the value to the target and returns true. The target must be a pointer to a pointer. If you pass pathErr instead of &pathErr, the function rejects the call with errors.As: target must be a non-nil pointer to either a type that implements error, or to any interface type. This is a common trap. The compiler won't catch it, but the runtime check will fail.
Pitfalls and compiler errors
Using %v instead of %w is the most common mistake. If you use %v, the error is formatted into the string, but the link is lost. errors.Is won't find the root error. The chain is broken. The compiler won't stop you. It's a runtime logic error. Your tests will catch it if you check for the root cause.
fmt.Errorf with %w requires the argument to be an error. If you pass a string, the compiler rejects it with fmt: %w has invalid verb error. This is a compile-time check. The compiler verifies the type.
Wrapping nil returns nil. fmt.Errorf("%w", nil) returns nil. This is intentional. It allows you to wrap an error that might be nil without checking first. If the error is nil, the result is nil. If the error is not nil, the result is a wrapped error. This pattern is safe and idiomatic.
func doSomething() error {
err := maybeFail()
// Wrap err even if it is nil.
// If err is nil, the result is nil.
return fmt.Errorf("doSomething: %w", err)
}
Sentinel errors are compared by identity. If you create a new error with the same message, errors.Is returns false. You need to compare against the specific variable.
var ErrTimeout = errors.New("timeout")
func check(err error) bool {
// This works.
return errors.Is(err, ErrTimeout)
}
func badCheck(err error) bool {
// This fails. New error is a different instance.
return errors.Is(err, errors.New("timeout"))
}
The compiler won't save you from %v. Your tests will.
Decision matrix
Use fmt.Errorf("%w", err) when you need to add context to an existing error and preserve the chain for errors.Is checks. Use fmt.Errorf("%v", err) when you want to embed the error message as text but don't need to unwrap it later. This breaks the chain. Use errors.New when you are creating a root error with no cause. Use a custom error type with an Is method when you need to match errors by semantic meaning rather than identity. Use errors.Join when you have multiple errors to combine into a single error value.
Sentinel errors are cheap. Custom types are powerful. Pick the tool for the job.