Go Error Wrapping with errors.Is and errors.As
Errors often cross several layers of a Go application. A low-level function may know that a file is missing, while a higher-level function needs to add context about which operation failed. Go error wrapping lets you add that context without losing information callers need for reliable handling.
Why error strings are fragile
Do not make program logic depend on error wording. Adding a filename or changing punctuation can break string comparisons even when the underlying condition is unchanged. Prefer semantic checks:
if errors.Is(err, os.ErrNotExist) {
// Handle a missing file.
}errors.Is examines the error and its wrapped chain.
Wrap an error with context
Use %w with fmt.Errorf when you want to preserve the original error:
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %q: %w", path, err)
}
return data, nil
}Callers can still detect the underlying condition:
_, err := readConfig("config.json")
if errors.Is(err, os.ErrNotExist) {
fmt.Println("create a configuration file first")
}Use %v instead of %w when the underlying error should not participate in the public error chain.
Add a domain-level sentinel
A sentinel error can represent a stable application category:
var ErrConfig = errors.New("configuration error")Modern Go allows multiple %w verbs in fmt.Errorf, so an error can preserve both a domain category and a specific cause:
return fmt.Errorf("%w: %w", ErrConfig, err)Then both of these checks can succeed:
errors.Is(err, ErrConfig)
errors.Is(err, os.ErrNotExist)This lets one caller handle all configuration failures while another reacts specifically to a missing file.
Use custom error types for structured data
Use a custom type when callers need fields such as a path or retry delay:
type ConfigError struct {
Path string
Err error
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("read config %q: %v", e.Path, e.Err)
}
func (e *ConfigError) Unwrap() error {
return e.Err
}Unwrap connects the custom error to its cause. Retrieve the custom type with errors.As:
var configErr *ConfigError
if errors.As(err, &configErr) {
fmt.Println(configErr.Path)
}Unlike a direct type assertion, errors.As searches through wrapped errors.
Complete example
package main
import (
"errors"
"fmt"
"os"
)
var ErrConfig = errors.New("configuration error")
type ConfigError struct {
Path string
Err error
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("read config %q: %v", e.Path, e.Err)
}
func (e *ConfigError) Unwrap() error {
return e.Err
}
func load(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("%w: %w", ErrConfig, &ConfigError{Path: path, Err: err})
}
return nil
}
func main() {
err := load("missing.json")
fmt.Println(errors.Is(err, ErrConfig))
fmt.Println(errors.Is(err, os.ErrNotExist))
var configErr *ConfigError
if errors.As(err, &configErr) {
fmt.Println(configErr.Path)
}
}With missing.json absent, the output is:
true
true
missing.jsonThe same error now communicates the application category, the underlying missing-file condition, and the path that failed.
errors.Is versus errors.As
Use errors.Is when checking whether an error chain represents a particular error value or condition:
if errors.Is(err, context.DeadlineExceeded) {
// Handle a timeout.
}Use errors.As when you need an error of a particular type and want to access its fields:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Path)
}A useful rule is: Is checks identity or meaning; As extracts a type.
Common pitfalls
Using == can fail when an error is wrapped. Prefer errors.Is. A direct type assertion checks only the outermost value, while errors.As can search the chain. Custom errors that intentionally preserve a cause should implement Unwrap.
Also avoid logging and returning the same error at every layer. Add useful context as the error moves upward, then normally log once at the boundary responsible for handling the operation.
Treat wrapping as API design
Wrapping exposes an underlying error to callers and can become part of your API contract. If callers should depend on the cause, wrap it deliberately. If the implementation detail should remain private, translate it to a stable application-level error instead.
Good Go error handling is mostly about deciding what information callers should depend on. Use plain errors for simple failures, sentinel errors for stable categories, custom types for structured data, %w for intentional wrapping, errors.Is for semantic checks, and errors.As for typed extraction.