Error Wrapping in Go

May 8, 2024

Error Wrapping in Go
Error Wrapping in Go

Go errors are values. Wrapping them adds context without losing the original cause.

Estimated Reading Time : 8m

The problem with bare errors

func getUser(id int) (*User, error) {
    row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)
    var name string
    if err := row.Scan(&name); err != nil {
        return nil, err
    }
    return &User{Name: name}, nil
}

When this fails, the caller gets sql: no rows in result set. No context about what operation failed or which user was requested. In a large codebase, this error could come from dozens of places.

Wrapping with %w

fmt.Errorf with %w wraps an error — adding context while preserving the original:

if err := row.Scan(&name); err != nil {
    return nil, fmt.Errorf("getUser(%d): %w", id, err)
}

Now the caller sees getUser(42): sql: no rows in result set. The message has context, and the original error is still accessible programmatically.

errors.Is

errors.Is checks if any error in the chain matches a target value. It unwraps recursively:

_, err := getUser(42)
if errors.Is(err, sql.ErrNoRows) {
    // handle missing user
}

This works even though the error was wrapped — errors.Is unwraps through the fmt.Errorf layer and finds sql.ErrNoRows inside.

Without wrapping, you’d compare directly: if err == sql.ErrNoRows. That breaks the moment someone wraps the error upstream.

errors.As

errors.As extracts a specific error type from the chain:

var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    fmt.Println("Postgres error code:", pgErr.Code)
    if pgErr.Code == "23505" {
        // handle unique violation
    }
}

errors.As walks the error chain looking for a value that can be assigned to the target. It’s the type-assertion equivalent of errors.Is.

Building an error chain

Each layer of your application adds context:

// repository layer
func (r *Repo) FindUser(id int) (*User, error) {
    // ...
    return nil, fmt.Errorf("repo.FindUser(%d): %w", id, err)
}

// service layer
func (s *Service) GetProfile(id int) (*Profile, error) {
    user, err := s.repo.FindUser(id)
    if err != nil {
        return nil, fmt.Errorf("getting profile: %w", err)
    }
    // ...
}

// handler layer
func (h *Handler) HandleGetProfile(w http.ResponseWriter, r *http.Request) {
    profile, err := h.service.GetProfile(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
    }
    // ...
}

The handler can check errors.Is(err, sql.ErrNoRows) even though three layers of wrapping sit between it and the original error. The error message reads: getting profile: repo.FindUser(42): sql: no rows in result set.

Custom error types

For errors that carry structured data, implement the error interface and an Unwrap method:

type ValidationError struct {
    Field   string
    Message string
    Err     error
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

func (e *ValidationError) Unwrap() error {
    return e.Err
}

Now errors.As can extract it:

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Printf("field %s: %s\n", valErr.Field, valErr.Message)
}

%w vs %v

%w wraps — the original error is preserved and errors.Is / errors.As can find it.

%v formats — the original error becomes a string. The chain is broken. Use %v only when you intentionally want to hide the underlying error from callers.

// wraps — caller can check errors.Is(err, sql.ErrNoRows)
fmt.Errorf("query failed: %w", err)

// formats — caller sees the message but can't unwrap
fmt.Errorf("query failed: %v", err)

When in doubt, use %w.

Common mistakes

Wrapping with redundant context. Don’t repeat what the caller already knows:

// BAD: the caller already knows it called GetUser
return fmt.Errorf("error in GetUser: failed to get user: %w", err)

// GOOD: add the context the caller doesn't have
return fmt.Errorf("GetUser(%d): %w", id, err)

Wrapping sentinel errors you create. If you define var ErrNotFound = errors.New("not found"), don’t wrap it again when returning it — just return it directly. Wrapping your own sentinels adds noise.

Not wrapping at package boundaries. When an error crosses a package boundary, wrap it with context. The caller shouldn’t need to know the internal structure of your package to understand what went wrong.

Exposing internal errors in your API. If your package wraps a database error with %w, callers can now depend on sql.ErrNoRows as part of your API contract. If you later switch databases, their code breaks. Use %v or define your own sentinel errors when you want to hide implementation details.

The rule of thumb

Add context at each layer. Use %w to preserve the chain. Check with errors.Is and errors.As instead of string matching. Keep error messages short and specific — function name and the input that caused the failure is usually enough.