Error Wrapping Strategies in Go
Master Go error wrapping with fmt.Errorf %w, errors.Is, errors.As, and custom error types for clear, debuggable error chains.
What you'll learn
- ✓How to wrap errors with fmt.Errorf and the %w verb
- ✓How to inspect error chains with errors.Is and errors.As
- ✓How to design custom error types for domain logic
Prerequisites
- •Basic Go knowledge
- •Familiarity with Go error handling conventions
Go errors are values. That simplicity is powerful, but it also means you need a strategy for adding context, preserving the original cause, and matching errors up the call stack. This guide covers the patterns that production Go code relies on.
The Problem with Bare Errors
Consider this code:
func GetUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
return nil, err // bare error: "sql: no rows in result set"
}
return &u, nil
}
When this error reaches your handler, all you see is sql: no rows in result set. Which query? Which user? Bare errors strip away context.
Wrapping with fmt.Errorf and %w
The %w verb wraps an error, preserving the original for inspection.
func GetUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return &u, nil
}
Now the error reads: get user u-123: sql: no rows in result set. Context plus cause.
%w vs %v
%wwraps the error. Callers can useerrors.Isanderrors.Asto inspect it.%vformats the error as a string. The original error is lost.
Use %w when callers might need to check the underlying error. Use %v when you intentionally want to hide implementation details.
// Internal detail you want to expose (wrap it)
return fmt.Errorf("query failed: %w", err)
// Third-party error you want to hide (format it)
return fmt.Errorf("service unavailable: %v", err)
Inspecting Errors with errors.Is
errors.Is walks the entire wrapped chain looking for a specific error value.
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := GetUser(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
Even though GetUser wrapped the error with fmt.Errorf("get user %s: %w", ...), errors.Is still finds sql.ErrNoRows inside the chain.
Sentinel Errors
Define package-level sentinel errors for conditions callers need to handle.
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("conflict")
)
func GetUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT name, email FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return &u, nil
}
Now callers check errors.Is(err, ErrNotFound) without knowing anything about SQL.
Inspecting Errors with errors.As
errors.As finds the first error in the chain that matches a specific type.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s %s", e.Field, e.Message)
}
func CreateUser(ctx context.Context, input CreateUserInput) error {
if input.Email == "" {
return &ValidationError{Field: "email", Message: "is required"}
}
if !strings.Contains(input.Email, "@") {
return &ValidationError{Field: "email", Message: "is invalid"}
}
// ...
return nil
}
In the handler:
func HandleCreateUser(w http.ResponseWriter, r *http.Request) {
err := CreateUser(r.Context(), input)
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"field": ve.Field,
"message": ve.Message,
})
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
Custom Error Types with Wrapping
Custom error types can wrap other errors by implementing Unwrap().
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
func NewAppError(code int, message string, err error) *AppError {
return &AppError{Code: code, Message: message, Err: err}
}
This allows errors.Is and errors.As to traverse through your custom error:
func GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, NewAppError(404, "order not found", err)
}
return nil, NewAppError(500, "failed to fetch order", err)
}
return order, nil
}
// Later, in middleware:
var appErr *AppError
if errors.As(err, &appErr) {
w.WriteHeader(appErr.Code)
json.NewEncoder(w).Encode(map[string]string{"error": appErr.Message})
return
}
Multiple Wrapping with errors.Join (Go 1.20+)
When an operation produces multiple errors, use errors.Join.
func ValidateConfig(cfg Config) error {
var errs []error
if cfg.Port == 0 {
errs = append(errs, fmt.Errorf("port is required"))
}
if cfg.Host == "" {
errs = append(errs, fmt.Errorf("host is required"))
}
if cfg.DBConnStr == "" {
errs = append(errs, fmt.Errorf("database connection string is required"))
}
return errors.Join(errs...)
}
The joined error’s Error() method concatenates all messages with newlines, and errors.Is checks against every error in the group.
err := ValidateConfig(cfg)
if err != nil {
// prints all validation failures
log.Printf("config invalid:\n%v", err)
}
Wrapping Strategy Guidelines
Add Context at Every Layer
Each function should add its own context. Reading the full chain should tell the story.
// Repository layer
return fmt.Errorf("repo.FindOrder id=%s: %w", id, err)
// Service layer
return fmt.Errorf("order service get: %w", err)
// Handler layer logs the full chain:
// "order service get: repo.FindOrder id=o-42: sql: connection refused"
Do Not Over-Wrap
Wrapping the same information twice adds noise.
// Bad: redundant context
return fmt.Errorf("error getting user: %w", fmt.Errorf("failed to get user: %w", err))
// Good: each layer adds new context
return fmt.Errorf("get user %s: %w", id, err)
Wrap at Boundaries, Not Everywhere
You do not need to wrap at every single function call. Wrap when crossing a meaningful boundary: entering a service layer, calling an external system, or translating between domains.
Summary
- Use
%wwithfmt.Errorfto wrap errors and preserve the chain. - Use
errors.Isto match sentinel error values through wrapped layers. - Use
errors.Asto extract typed errors for structured handling. - Implement
Unwrap()on custom error types so they participate in the chain. - Use
errors.Joinfor collecting multiple errors. - Add context at each layer, but avoid redundant wrapping.
- Use
%vinstead of%wwhen you want to hide implementation details from callers.
Related articles
- Go Table-Driven Tests and Test Helpers in Go
Write clear, maintainable Go tests using table-driven patterns, test helpers, subtests, and cleanup functions.
- Go Error Handling Best Practices in Go
Write idiomatic Go error handling — wrapping, sentinel errors, custom error types, errors.Is, errors.As, and patterns for clean error flows.
- Go Go Error Handling Patterns
Idiomatic error handling in Go: sentinel values, wrapping with %w, errors.Is and As, custom types, and the structural patterns that keep code readable.
- GraphQL GraphQL Error Handling Patterns and Extensions
Implement robust error handling in GraphQL with error extensions, union-based errors, and structured error responses for better client DX.