Error Wrapping with %w & errors.As
Wrapping adds context to failures while preserving the original cause for inspection.
fmt.Errorf with %w builds an unwrap chain; errors.Is and errors.As traverse that chain so callers recognize sentinels and custom types through intermediate layers.
Summary
Go 1.13 standardized error wrapping: each layer adds operation context (read config, dial db) without destroying the root os.ErrNotExist or your domain type.
errors.As extracts typed data when a sentinel is too coarse.
Use %v when the inner error must not participate in Is/As.
Recipe
Quick-reference recipe card - copy-paste ready.
if err != nil {
return fmt.Errorf("fetch user %q: %w", id, err)
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Println("path:", pathErr.Path)
}
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}When to reach for this:
- Every layer between I/O and the handler adds distinct context
- Callers must distinguish timeout vs not-found through wraps
- You need fields from a custom error type mid-chain
- Parallel steps produce multiple errors to aggregate with
errors.Join
Working Example
package main
import (
"errors"
"fmt"
"net"
"os"
)
type OpError struct {
Op string
}
func (e OpError) Error() string {
return fmt.Sprintf("%s failed", e.Op)
}
func readDB(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return OpError{Op: "read"}
}
return nil
}
func loadUser(path string) error {
if err := readDB(path); err != nil {
return fmt.Errorf("load user from %q: %w", path, err)
}
return nil
}
func main() {
err := loadUser("missing.db")
var op OpError
if errors.As(err, &op) {
fmt.Println("operation:", op.Op)
}
fmt.Println("not exist:", errors.Is(err, os.ErrNotExist))
fmt.Println("net timeout:", errors.Is(err, net.ErrClosed))
}What this demonstrates:
- Custom type
OpErroris reachable through afmt.Errorfwrap errors.Asrequires a pointer to the target typeerrors.Isstill findsos.ErrNotExistunderOpErrorif the chain includes it- Message strings are for humans;
Is/Asare for program logic
Deep Dive
How It Works
%wcreates a wrapper implementingUnwrap() error.errors.Unwrap(err)returns one level;Is/Asloop until match or nil.- Only one
%wperfmt.Errorfcall is allowed. errors.Join(errs...)(Go 1.20+) returns an error that unwraps to multiple values;Is/Ascheck each.
%w vs %v
| Verb | Unwrap chain | errors.Is / errors.As |
|---|---|---|
%w | Preserves inner | Works through wrap |
%v | No unwrap link | Inner not visible to Is/As |
errors.As Rules
- Second argument must be a pointer to a type that implements
erroror is a pointer to a struct field. - Returns true and assigns the first matching value in the chain.
- Prefer value receivers on error types unless you need pointer-specific behavior.
Go Notes
// Typed extraction
var pe *os.PathError
if errors.As(err, &pe) {
_ = pe.Path
}
// Manual one-level unwrap (rare)
inner := errors.Unwrap(err)Gotchas
- Multiple
%win one Errorf - Compile error. Fix: Wrap once per call; chain with nestedfmt.Errorf. - Passing non-pointer to errors.As - Always fails. Fix:
var target MyErr; errors.As(err, &target). - Pointer vs value receiver on error types -
errors.Asinto*MyErrvsMyErrmust match what you return. Fix: Be consistent; document constructor return type. - Using As for sentinels -
errors.Isis simpler forvar ErrX. Fix: ReserveAsfor types with fields. - Leaking secrets in wrap messages -
%wplus verbose logs can expose tokens. Fix: Sanitize messages; use%vat public boundaries. - Assuming Error() text is stable - Callers must not parse strings. Fix: Export sentinels or types for programmatic checks.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
errors.Is only | Sentinel identity is enough | You need structured fields |
| Flat errors without wrap | Single-layer CLI tool | Multi-package services need cause chains |
status.Convert (gRPC) | RPC transport mapping | Pure domain layer |
Custom Unwrap []error | Multi-error aggregation | Simple single-failure paths |
FAQs
What does %w do in fmt.Errorf?
It wraps the error argument so the result implements Unwrap and participates in errors.Is and errors.As.
How is errors.As different from a type assertion?
errors.As walks the unwrap chain. A type assertion only inspects the top-level dynamic type.
Can I wrap a nil error?
fmt.Errorf("msg: %w", nil) produces an error with nil unwrap. Avoid wrapping; return nil on success paths.
How deep can wrap chains go?
No hard limit, but deep chains hint at missing boundary logging. Prefer context at meaningful layers.
Does errors.As work with pointer error types?
Yes. Match the pointer shape you return (*MyErr) and pass &target of the correct type to As.
When should I use errors.Join?
When several operations fail in parallel (validation, fan-in) and you want one returned error listing all causes.
What is errors.Unwrap for?
Low-level access to the immediate inner error. Most code uses Is/As instead of manual loops.
Should I wrap errors in library code?
Yes, add context at your API boundary (package x: operation failed). Do not log and wrap redundantly without new information.
Can I use %w with custom WrapError types?
Implement Unwrap() error on your type or use fmt.Errorf with %w for standard behavior.
How do wraps interact with os.IsNotExist?
Helpers like os.IsNotExist use errors.Is internally, so they work through %w chains containing os.ErrNotExist.
Should HTTP handlers use As or Is?
Use Is for sentinels mapping to status codes; As when extracting error details for structured JSON responses.
Is wrapping slower than plain errors?
Small allocation cost per wrap. Clarity and debuggability usually dominate unless profiling shows a hot path issue.
Related
- Sentinel Errors & errors.Is - sentinel comparison through chains
- Custom Error Types & Error Interfaces - designing types for As
- Stack Traces with runtime/debug - attach diagnostics to wrapped errors
- Error Handling in Concurrent Code - joining errors from goroutines
- Error Handling Best Practices - wrap policy for teams
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).