4 min read
On this page

Wrapping & Sentinel Errors

Once you have the basics of if err != nil, the next question is: how do you inspect errors after they have been wrapped through multiple layers? Go provides errors.Is and errors.As for traversing the error chain, sentinel errors for known failure conditions, and custom error types for carrying structured data. Together, these let you build error chains that tell you what happened and where.

Sentinel Errors

A sentinel error is a package-level variable that represents a specific failure condition. The name comes from the idea of a "sentinel value" -- a special value that signals a condition.

package store

import "errors"

var (
    ErrNotFound     = errors.New("not found")
    ErrConflict     = errors.New("conflict")
    ErrUnauthorized = errors.New("unauthorized")
)

Callers check for sentinels using errors.Is:

user, err := store.GetUser(ctx, id)
if errors.Is(err, store.ErrNotFound) {
    http.Error(w, "user not found", http.StatusNotFound)
    return
}
if err != nil {
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

Sentinel errors are exported (uppercase) and declared at the package level. They represent stable, documented failure conditions that callers are expected to handle.

Standard Library Sentinels

The standard library defines several sentinel errors you will encounter regularly:

import (
    "database/sql"
    "io"
    "os"
)

// io.EOF: end of stream
data, err := reader.Read(buf)
if errors.Is(err, io.EOF) {
    // Normal end of input, not an error
}

// sql.ErrNoRows: query returned no results
err := row.Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrNotFound  // Translate to your own sentinel
}

// os.ErrNotExist: file does not exist
_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
    // File is missing
}

errors.Is: Checking the Error Chain

errors.Is traverses a chain of wrapped errors looking for a match. It replaces the old pattern of direct comparison (err == ErrNotFound), which breaks when errors are wrapped.

func getDocument(id string) (*Document, error) {
    data, err := s3.GetObject(ctx, bucket, id)
    if err != nil {
        return nil, fmt.Errorf("fetching document %s: %w", id, err)
    }
    // ...
}

// Somewhere up the call stack, many layers of wrapping later:
doc, err := getDocument("abc-123")

// Direct comparison would FAIL because the error is wrapped:
// if err == store.ErrNotFound { ... }  // Does not match

// errors.Is unwraps the chain and finds it:
if errors.Is(err, store.ErrNotFound) {
    // This matches even through multiple layers of wrapping
}

errors.Is works by repeatedly calling Unwrap() on the error to walk the chain. At each step, it checks if the current error matches the target.

Custom Error Types

Sometimes you need more than a string message. Custom error types carry structured data that callers can extract.

type ValidationError struct {
    Field   string
    Message string
}

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

type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %s not found", e.Resource, e.ID)
}

Any type that implements the error interface (which requires only the Error() string method) is an error. You can attach any data you need.

func getUser(id string) (*User, error) {
    user, ok := users[id]
    if !ok {
        return nil, &NotFoundError{Resource: "user", ID: id}
    }
    return &user, nil
}

errors.As: Extracting Error Types

errors.As traverses the error chain looking for an error that can be assigned to a target type. This is how you extract structured data from custom error types.

user, err := service.GetUser(ctx, userID)
if err != nil {
    var nfe *NotFoundError
    if errors.As(err, &nfe) {
        // nfe is now populated with the NotFoundError data
        fmt.Printf("resource: %s, id: %s\n", nfe.Resource, nfe.ID)
        http.Error(w, nfe.Error(), http.StatusNotFound)
        return
    }

    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("bad field: %s\n", ve.Field)
        http.Error(w, ve.Error(), http.StatusBadRequest)
        return
    }

    // Unrecognized error
    log.Printf("unexpected error: %v", err)
    http.Error(w, "internal error", http.StatusInternalServerError)
}

The difference between errors.Is and errors.As:

  • errors.Is checks if a specific error value exists in the chain (identity check)
  • errors.As checks if any error in the chain matches a specific type (type check)

When to Wrap vs Return As-Is

Wrap When You Add Context

Wrap an error when you can add useful information about what was happening when the error occurred.

func (s *OrderService) PlaceOrder(ctx context.Context, req OrderRequest) error {
    if err := s.inventory.Reserve(ctx, req.Items); err != nil {
        return fmt.Errorf("placing order for customer %s: %w", req.CustomerID, err)
    }
    return nil
}

The wrapping adds "placing order for customer X" -- context the caller would not otherwise have.

Return As-Is When Wrapping Adds Nothing

If you are just proxying a call and have no additional context, returning the error directly is fine.

func (r *repo) Close() error {
    return r.db.Close()  // No additional context to add
}

Translate at Boundaries

At package or layer boundaries, translate errors into your domain's vocabulary. Do not let database errors leak into your HTTP handler's error messages.

func (r *userRepo) FindByEmail(ctx context.Context, email string) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE email = $1", email)
    var u User
    if err := row.Scan(&u.ID, &u.Name); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // Translate database-specific error to domain error
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("querying user by email: %w", err)
    }
    return &u, nil
}

Building an Error Chain

Here is a complete example showing how error chains work in practice across multiple layers of a service.

// Layer 1: Database
func (r *repo) GetOrder(ctx context.Context, id string) (*Order, error) {
    row := r.db.QueryRowContext(ctx, query, id)
    var o Order
    if err := row.Scan(&o.ID, &o.CustomerID, &o.Total); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, &NotFoundError{Resource: "order", ID: id}
        }
        return nil, fmt.Errorf("scanning order row: %w", err)
    }
    return &o, nil
}

// Layer 2: Service
func (s *service) GetOrderSummary(ctx context.Context, id string) (*Summary, error) {
    order, err := s.repo.GetOrder(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("getting order summary: %w", err)
    }

    customer, err := s.repo.GetCustomer(ctx, order.CustomerID)
    if err != nil {
        return nil, fmt.Errorf("getting customer for order %s: %w", id, err)
    }

    return &Summary{Order: order, Customer: customer}, nil
}

// Layer 3: Handler
func (h *handler) handleGetOrder(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    summary, err := h.service.GetOrderSummary(r.Context(), id)
    if err != nil {
        var nfe *NotFoundError
        if errors.As(err, &nfe) {
            writeJSON(w, http.StatusNotFound, map[string]string{
                "error": nfe.Error(),
            })
            return
        }
        h.logger.Error("getting order", "id", id, "error", err)
        writeJSON(w, http.StatusInternalServerError, map[string]string{
            "error": "internal server error",
        })
        return
    }
    writeJSON(w, http.StatusOK, summary)
}

If the order does not exist, the error chain looks like:

getting order summary: order order-789 not found

The handler extracts the NotFoundError with errors.As, even though it was created two layers down and wrapped once on the way up. The structured data (Resource: "order", ID: "order-789") is preserved and accessible.

Common Pitfalls

  • Comparing errors with == instead of errors.Is. Direct comparison breaks when errors are wrapped. Always use errors.Is for sentinel checks and errors.As for type checks.
  • Creating sentinel errors inside functions. Sentinels must be package-level variables. An error created inside a function is a new value every time -- errors.Is will not match it.
// Wrong: new error value every call
func getUser(id string) (*User, error) {
    return nil, errors.New("not found")  // Cannot check with errors.Is
}

// Right: package-level sentinel
var ErrNotFound = errors.New("not found")
  • Wrapping without %w. Using %v instead of %w converts the error to a string. The chain is broken and errors.Is/errors.As cannot find the original error.
  • Over-wrapping with repeated context. Each wrapping layer should add new information. If the wrapped message already says "connecting to database," do not wrap it with "failed to connect to database."
  • Using sentinel errors for everything. Sentinels are for well-known, stable error conditions. For transient or context-dependent errors, fmt.Errorf with wrapping is better.
  • Pointer receivers on error types without using pointers. If your error type has a pointer receiver for Error(), you must create it with &MyError{}. Creating it as MyError{} means it does not satisfy the error interface through a value.

Key Takeaways

  • Sentinel errors are package-level variables representing known failure conditions. Check them with errors.Is.
  • Custom error types carry structured data. Extract them with errors.As.
  • errors.Is checks identity through the chain. errors.As checks type through the chain.
  • Wrap errors with %w to preserve the chain. Use %v to intentionally break it.
  • Translate errors at boundaries: database errors should not leak into API responses.
  • Each wrapping layer should add new context about what was being attempted.
  • Go 1.20+ supports wrapping multiple errors in a single fmt.Errorf call.