Introduction
When an error travels up through multiple layers of a program, context gets lost. Error wrapping (introduced in Go 1.13) solves this by letting you add context to an error while preserving the original for later inspection. Combined with errors.Is and errors.As, wrapping creates rich, inspectable error chains.
Key Concepts
- Error Wrapping: Adding context to an error using
fmt.Errorfwith the%wverb, preserving the original error. - Error Chain: A linked sequence of wrapped errors from the outermost context to the root cause.
errors.Is: Checks if any error in the chain matches a specific sentinel value.errors.As: Extracts a specific error type from anywhere in the chain.
Real World Context
In production services, a database timeout might propagate through a repository layer, a service layer, and an HTTP handler. Without wrapping, you see only "connection timed out." With wrapping, you see "handling request: fetching user: querying database: connection timed out"—a complete trace. Teams use errors.Is to make decisions (retry on timeout?) and errors.As to extract structured data (which field failed validation?).
Deep Dive
The %w verb in fmt.Errorf wraps an error, creating a chain.
goif err != nil { return fmt.Errorf("failed to open file: %w", err) }
The returned error's message includes the context, and the original error is preserved for inspection.
Inspecting with errors.Is
errors.Is traverses the entire error chain looking for a match against a sentinel error.
goif errors.Is(err, os.ErrNotExist) { fmt.Println("File does not exist") }
This works even if the error has been wrapped multiple times.
Inspecting with errors.As
errors.As finds the first error in the chain that matches a target type and extracts it.
govar pathErr *os.PathError if errors.As(err, &pathErr) { fmt.Println("Path:", pathErr.Path) }
This lets you access structured fields from custom error types deep in the chain.
Go 1.26: errors.AsTypeT
Go 1.26 introduces errors.AsType[T]() as a generic alternative to errors.As. Instead of declaring a variable and passing its pointer, you get the typed error directly.
go// Traditional errors.As var pathErr *os.PathError if errors.As(err, &pathErr) { ... } // Go 1.26 generic alternative if pathErr, ok := errors.AsType[*os.PathError](err); ok { ... }
The generic form is more concise and avoids the need for a separate variable declaration.
Sentinel Errors
Define package-level errors for callers to compare against.
govar ( ErrNotFound = errors.New("not found") ErrInvalid = errors.New("invalid") )
Callers use errors.Is(err, pkg.ErrNotFound) to check for specific conditions regardless of wrapping.
Common Pitfalls
- Using
%vinstead of%w—fmt.Errorf("context: %v", err)converts the error to a string, breaking the chain.errors.Isanderrors.Aswill no longer find the original error. - Wrapping errors you should not expose — Wrapping preserves the original error type. If you wrap an internal database error, callers can inspect it with
errors.As, potentially leaking implementation details.
Best Practices
- Always use
%wwhen you want callers to inspect the cause — This preserves the chain forerrors.Isanderrors.As. - Add meaningful context at each layer — Each wrap should explain what the current function was trying to do, building a readable error narrative.
Summary
- Wrap errors with
fmt.Errorf("context: %w", err)to preserve the chain. errors.Ischecks for sentinel error matches anywhere in the chain.errors.Asextracts a specific error type from the chain for structured inspection.- Use
%w(not%v) to maintain the error chain. - Define sentinel errors at the package level for stable comparison targets.
Code Examples
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}