Skip to content
Codeloom
Go

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.

·6 min read · By Codeloom
Intermediate 9 min read

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

  • %w wraps the error. Callers can use errors.Is and errors.As to inspect it.
  • %v formats 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 %w with fmt.Errorf to wrap errors and preserve the chain.
  • Use errors.Is to match sentinel error values through wrapped layers.
  • Use errors.As to extract typed errors for structured handling.
  • Implement Unwrap() on custom error types so they participate in the chain.
  • Use errors.Join for collecting multiple errors.
  • Add context at each layer, but avoid redundant wrapping.
  • Use %v instead of %w when you want to hide implementation details from callers.