Skip to content

Go Error Handling: errors.Is, errors.As, Wrapping, and Custom Types

BackendBytes Engineering Team
BackendBytes Engineering Team
10 min read
Go Error Handling: errors.Is, errors.As, Wrapping, and Custom Types

Key Takeaways

  • One underscore discarding an error (data, _ := marshal()) is enough to under-charge customers silently for days before anyone notices — Go's visibility of error paths is its strongest defence against silent failures, but only if you don't ignore the values it returns
  • errors.Is checks sentinel error identity; errors.As extracts typed information from the error chain — different tools for different problems
  • Wrap errors with fmt.Errorf %w to preserve the original error for inspection — stripped chains make postmortems impossible
  • Custom error types carry structured data (HTTP codes, field names, retry hints) that callers need to branch logic — use them when a string message isn't enough

The classic Go silent-error production bug. A billing system under-charges customers for days before anyone notices. The root cause is always the same shape — data, _ := marshal(event)[Go Language Specification], one underscore discarding a non-nil error. The marshal returns empty bytes when a struct field held an unjson-serializable value; downstream consumers silently deserialised empty objects. We've debugged this exact incident on multiple Go services.

Errors Are Values, Not Exceptions

The bug above traces back to Go's defining principle: errors are data, not exceptions — but the principle only protects you if you stop ignoring the data the runtime hands you.

TL;DR

Go errors are return values[Go Language Specification], not exceptions. Check them explicitly with if err != nil at the call site and decide immediately what to do. Use sentinel errors for known conditions (errors.Is), wrap with context (fmt.Errorf %w), and custom types when errors must carry structured data like HTTP codes or field names.

  • Sentinel errors for expected conditions; check with errors.Is
  • Wrap errors with fmt.Errorf %w to add context; preserves original error
  • Custom types when errors carry HTTP codes, field names, or retry hints
graph TD
    Need[Need to signal an error?] --> Q1{Caller will branch<br/>on the error?}
    Q1 -->|"Yes — known set of failures<br/>(NotFound, Timeout, ...)"| Sent[Sentinel error<br/>+ errors.Is]
    Q1 -->|"Yes — needs structured data<br/>(HTTP code, field name)"| Custom[Custom error type<br/>+ errors.As]
    Q1 -->|"No — just propagate up"| Wrap[fmt.Errorf %w<br/>add context, preserve chain]
    Q2[Multiple independent errors?] --> Join[errors.Join<br/>Go 1.20+]
    Sent -.->|never compare with ==<br/>use errors.Is| Chain[Wrapped chain still matches]
    Custom -.->|implement Is/As<br/>for sophisticated matching| Chain
    style Sent fill:#efe
    style Custom fill:#efe
    style Wrap fill:#efe
    style Join fill:#efe

The diagram is the error-pattern picker. The single question that drives the choice: what does the caller need to do with the error? Branch on identity → sentinel + errors.Is. Branch on structured data → custom type + errors.As. Just propagate → fmt.Errorf %w. The chain-traversal arrows show why errors.Is/errors.As are the right default — == comparison breaks as soon as anyone wraps the error with extra context.

The Quick Start: Pattern Decision Table

The decision tree — route by what the caller needs to do with the error:

graph TD
    Got[I got an error] --> Need{What does the<br/>caller need to do?}
    Need -->|Branch on the<br/>error type| Sentinel[Sentinel + errors.Is<br/>compare against<br/>known sentinels]
    Need -->|Read structured fields<br/>HTTP code, field name| Custom[Custom error type<br/>+ errors.As to extract]
    Need -->|Add context as it<br/>propagates upward| Wrap[fmt.Errorf with %w<br/>preserves the chain]
    Need -->|Combine many<br/>cleanup errors| Join[errors.Join<br/>Go 1.20+]
    Need -->|Just log and<br/>return upstream| Log[slog with err attr<br/>then return wrapped]
    Wrap -->|At top of stack| Top{HTTP handler?}
    Top -->|Yes| Status[Map sentinel/custom<br/>to status code]
    Top -->|No, RPC| RPC[Map to gRPC status<br/>codes.NotFound, etc.]
    style Sentinel fill:#dfd
    style Custom fill:#dfd
    style Wrap fill:#dfd
    style Join fill:#ffd
    style Status fill:#ffd
    style RPC fill:#ffd

The same routing as a table:

SituationPatternCodeWhen to use
Expected failure (not found, timeout, permission denied)Sentinel error + errors.Iserrors.Is(err, ErrNotFound)Caller branches logic on the error
Adding context as error propagatesfmt.Errorf with %wfmt.Errorf("loading user %d: %w", id, err)Every layer that touches the error
Error carries structured data (HTTP code, field, hints)Custom error typeCustom struct implementing error interfaceRich context beyond a message string
Multiple independent errorserrors.Joinerrors.Join(err1, err2)Go 1.20+; resource cleanup (multiple Close calls)
Extract a specific error type from chainerrors.Asvar terr *TimeoutError; errors.As(err, &terr)Inspect typed information inside wrapped chain

The error Interface

Go's error is a single-method interface:

type error interface {
    Error() string
}

Any type with an Error() method satisfies it. This simplicity is what makes Go's error system composable. The standard library provides errors.New for simple cases:

package main
 
import (
    "errors"
    "fmt"
)
 
func main() {
    err := errors.New("connection refused")
    if err != nil {
        fmt.Println(err.Error()) // connection refused
    }
}

Because error is an interface, custom types — structs, types wrapping other types, or even basic types like int — can implement it.

Sentinel Errors: Known Conditions

[Go errors pkg]

A sentinel error is a package-level variable for a specific, expected failure condition:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrTimeout      = errors.New("operation timed out")
)

Callers check them with errors.Is, which unwraps the entire error chain:

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    // ... query logic ...
    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("query failed: %w", err)
    }
    return user, nil
}
 
// Caller can branch
user, err := repo.FindByID(ctx, 42)
if err != nil {
    if errors.Is(err, ErrNotFound) {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

Using errors.Is instead of == ensures matching works even when errors have been wrapped multiple times by intermediate functions. In production systems, this matters: the database layer wraps the low-level driver error, the repository wraps the database layer's error, and the service wraps the repository's error. The caller at the HTTP handler still needs to recognize the original sentinel.

API stability warning: Exporting a sentinel error makes it part of your public API. Removing or replacing it breaks callers. Only export sentinels that callers genuinely need to branch on. This is a versioning contract — removing ErrNotFound in v2 while clients still depend on it will cause silent failures in production.

Wrapping: Adding Context with fmt.Errorf

Error wrapping[Go 1.13 error wrapping] adds context as errors propagate up the call stack. Use fmt.Errorf with the %w verb:

func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
    var u User
    err := row.Scan(&u.ID, &u.Name, &u.Email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("scan failed: %w", err)
    }
    return &u, nil
}
 
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
    u, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("fetching user: %w", err)
    }
    return u, nil
}
 
// Caller:
user, err := service.GetUser(context.Background(), 42)
if err != nil {
    log.Println(err) // "fetching user: user 42: not found"
    if errors.Is(err, ErrNotFound) {
        // Still true! errors.Is unwraps the chain.
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
}

The %w verb preserves the original error so downstream code can still call errors.Is or errors.As on it. Using %v instead loses the original error — a critical mistake in production.

%w vs %v: The critical difference. If you accidentally use %v, you get a string. Downstream code cannot extract the sentinel or inspect the underlying type:

// Wrong: %v loses the error chain
func save(data []byte) error {
    err := db.Write(data)
    if err != nil {
        return fmt.Errorf("save failed: %v", err) // Error is now a string
    }
    return nil
}
 
// Caller cannot do this anymore
if errors.Is(err, ErrDatabaseOffline) {
    // This won't work—err is a string wrapping ErrDatabaseOffline, not the error itself
}
 
// Right: %w preserves the chain
func save(data []byte) error {
    err := db.Write(data)
    if err != nil {
        return fmt.Errorf("save failed: %w", err) // Chain is preserved
    }
    return nil
}
 
// Caller can still unwrap
if errors.Is(err, ErrDatabaseOffline) {
    // This works—errors.Is unwraps and finds ErrDatabaseOffline
}

In production, a logging layer might need to extract a database-specific error type to decide whether to retry, and %w makes that possible across three or four layers of wrapping.

Custom Error Types: Carrying Structured Data

When an error must carry structured information — HTTP status codes, field names, retry hints, request IDs — define a custom struct:

type ValidationError struct {
    Field   string
    Message string
    Value   interface{}
}
 
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error in %q: %s (got %v)", e.Field, e.Message, e.Value)
}
 
// Usage
func validateEmail(email string) error {
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Message: "must contain @",
            Value:   email,
        }
    }
    return nil
}
 
// Caller can extract the typed error
user, err := parseUserRequest(r)
if err != nil {
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        // We have the field name and original value
        http.Error(w, valErr.Error(), http.StatusBadRequest)
        return
    }
    // Unknown error
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

For HTTP responses, a custom error type might carry the status code directly:

type HTTPError struct {
    Code    int
    Message string
}
 
func (e *HTTPError) Error() string {
    return e.Message
}
 
// In middleware
resp, err := handler(w, r)
if err != nil {
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        http.Error(w, httpErr.Message, httpErr.Code)
        return
    }
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

Multi-Error Handling with errors.Join (Go 1.20+)

When a single operation fails in multiple ways — e.g., closing multiple resources in cleanup — use errors.Join[Go errors pkg]:

func closeAll(conn1, conn2, conn3 io.Closer) error {
    var errs []error
 
    if err := conn1.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := conn2.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := conn3.Close(); err != nil {
        errs = append(errs, err)
    }
 
    if len(errs) > 0 {
        return errors.Join(errs...)
    }
    return nil
}
 
// Caller
err := closeAll(conn1, conn2, conn3)
if err != nil {
    // err is now an error containing all failed closes
    // errors.Is can still find specific errors within it
}

Custom Is and As Methods: Sophisticated Matching

[Go errors pkg]

Override default error matching for semantic criteria by implementing Is and As on your custom type:

type ErrorCode int
 
const (
    CodeTimeout ErrorCode = 1
    CodeNotFound ErrorCode = 2
)
 
type AppError struct {
    Code    ErrorCode
    Message string
}
 
func (e *AppError) Error() string {
    return e.Message
}
 
// Custom Is: match by code, not identity
func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}
 
// Usage
sentinel := &AppError{Code: CodeNotFound}
returned := &AppError{Code: CodeNotFound, Message: "user not found"}
if errors.Is(returned, sentinel) {
    // Match! Codes are equal.
}

This allows a single custom error type to match multiple sentinel values without exporting every variant. The benefit: API stability. You define one exported type (AppError) with a Code field, and callers write sentinels at their own scope. If you add new error codes later, existing code doesn't break.

Extracting a typed error from deep in a wrapped chain is exactly what errors.As does — and for the common case you do not write a custom As at all. Add Unwrap() so your wrapper exposes the error it holds, and errors.As fills in your variable by assignability:

type RetryableError struct {
    Err           error
    RetryAfterSec int
}
 
func (e *RetryableError) Error() string {
    return e.Err.Error()
}
 
// Unwrap exposes the inner error so errors.Is/As can traverse the chain.
func (e *RetryableError) Unwrap() error { return e.Err }
 
// No custom As needed — errors.As extracts *RetryableError from anywhere
// in the chain by walking Unwrap.
var retryErr *RetryableError
if errors.As(err, &retryErr) {
    time.Sleep(time.Duration(retryErr.RetryAfterSec) * time.Second)
    // retry the operation
}

A custom As is only for the rare case of populating a target type your error does not directly implement. It is easy to get wrong: errors.As hands the method the pointer to the destination, so it receives a **RetryableError (not a *RetryableError) and must assert that level before assigning — which is why Unwrap() plus the built-in matching is the better default.

In practice, custom Is and As are rarely used together. Use Is to match semantically (by code or category); for extraction, Unwrap() plus the built-in errors.As covers almost every case.

Production Checklist

  • Never discard errors with _. Even if you're sure it can't fail, handle it explicitly.
  • Use errors.Is for sentinel matching, not ==.
  • Wrap with %w, not %v to preserve the error chain.
  • Avoid string matching on error messages; use sentinel errors or custom types.
  • Do not log and return the same error; one layer logs (usually the outermost), others just return.
  • Implement custom Is/As when a single error type needs to match multiple semantic conditions.
  • Use errors.Join for cleanup scenarios where multiple errors can occur independently.
  • Test error paths explicitly; a function that can return an error should have a test that verifies the error is returned and its type/message is correct.

Wrapping vs Typed Errors: The Decision Rule

Engineers reach for custom types too early and end up with a sprawl of single-use error structs that nobody else in the codebase actually inspects. The decision rule is narrower than most teams realise: wrap by default, escalate to a typed error only when callers must read structured fields. A wrapped sentinel already carries identity, a stack of contextual messages, and a chain that errors.Is traverses for free. A typed error only earns its keep when at least one caller needs to extract a field — a status code, a field name, a retry hint — and branch on the value of that field, not on the error's identity alone. [Go 1.13 error wrapping]

Apply this filter at the point of definition. If you can describe the failure with a sentinel and a wrapping message, do not introduce a struct.

// Wrap-only: the caller never reads structured fields, just branches on identity.
var ErrAccountLocked = errors.New("account locked")
 
func (s *AuthService) Login(ctx context.Context, email, password string) (*Session, error) {
    user, err := s.users.FindByEmail(ctx, email)
    if err != nil {
        return nil, fmt.Errorf("login %q: %w", email, err)
    }
    if user.LockedUntil.After(time.Now()) {
        return nil, fmt.Errorf("login %q: %w", email, ErrAccountLocked)
    }
    if !user.PasswordMatches(password) {
        return nil, fmt.Errorf("login %q: %w", email, ErrInvalidCredentials)
    }
    return s.issueSession(ctx, user)
}
 
// Typed: the caller reads RetryAfter to decide a backoff window, not just identity.
type RateLimitError struct {
    Limit      int
    Window     time.Duration
    RetryAfter time.Duration
}
 
func (e *RateLimitError) Error() string {
    return fmt.Sprintf("rate limit %d/%s exceeded; retry after %s",
        e.Limit, e.Window, e.RetryAfter)
}
 
func (s *AuthService) checkRateLimit(ip string) error {
    hits, window := s.limiter.Inspect(ip)
    if hits > 5 {
        return &RateLimitError{
            Limit:      5,
            Window:     window,
            RetryAfter: window - time.Since(s.limiter.WindowStart(ip)),
        }
    }
    return nil
}

ErrAccountLocked is a one-liner because the caller's only question is "is this the locked-account case?". RateLimitError is a struct because the HTTP layer must read RetryAfter to set the Retry-After response header — a string sentinel cannot carry that integer. The rule keeps the error surface small and prevents the common anti-pattern of struct-per-failure.

Structured Error Logging With slog

Logging an error as log.Println(err) flattens the wrapping chain into a single string, which is useless when you're searching logs for a specific failure mode across millions of requests. The Go 1.21 log/slog[Go log/slog package] package treats errors as structured attributes — emit the wrapped chain, the sentinel identity, and any typed fields as separate keys so the log pipeline can index them.

import (
    "errors"
    "log/slog"
)
 
// logErr emits one structured record with the error chain decomposed
// into searchable attributes — sentinel name, typed fields, full chain.
func logErr(ctx context.Context, msg string, err error) {
    attrs := []slog.Attr{slog.String("err", err.Error())}
 
    // Tag known sentinels so dashboards can group by failure mode.
    switch {
    case errors.Is(err, ErrNotFound):
        attrs = append(attrs, slog.String("err.kind", "not_found"))
    case errors.Is(err, ErrAccountLocked):
        attrs = append(attrs, slog.String("err.kind", "account_locked"))
    case errors.Is(err, context.DeadlineExceeded):
        attrs = append(attrs, slog.String("err.kind", "deadline_exceeded"))
    }
 
    // Extract typed fields when present.
    var rl *RateLimitError
    if errors.As(err, &rl) {
        attrs = append(attrs,
            slog.Int("err.rate_limit.limit", rl.Limit),
            slog.Duration("err.rate_limit.retry_after", rl.RetryAfter),
        )
    }
 
    slog.LogAttrs(ctx, slog.LevelError, msg, attrs...)
}

The output is one JSON line per error with err.kind, err.rate_limit.retry_after, and the original message. Three rules govern the pattern: log at the outermost layer only (HTTP middleware, message-handler boundary), never log and return — pick one; emit the sentinel identity as a separate field, not parsed from the message string; and pass context.Context so trace IDs propagate into the same record.

gRPC Status Code Mapping

At the RPC boundary you must translate Go errors into gRPC status codes[gRPC docs]codes.NotFound, codes.PermissionDenied, codes.DeadlineExceeded. Doing this inline in every handler produces drift; centralise the mapping in one translator that the server interceptor calls for every response.

import (
    "errors"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)
 
// toStatus maps a domain error to a gRPC status. Sentinels first
// (cheap identity check), then typed errors for structured detail.
func toStatus(err error) error {
    if err == nil {
        return nil
    }
    switch {
    case errors.Is(err, ErrNotFound):
        return status.Error(codes.NotFound, err.Error())
    case errors.Is(err, ErrUnauthorized):
        return status.Error(codes.PermissionDenied, err.Error())
    case errors.Is(err, context.DeadlineExceeded):
        return status.Error(codes.DeadlineExceeded, err.Error())
    case errors.Is(err, context.Canceled):
        return status.Error(codes.Canceled, err.Error())
    }
 
    var rl *RateLimitError
    if errors.As(err, &rl) {
        st := status.New(codes.ResourceExhausted, err.Error())
        // Attach Retry-After equivalent as a structured detail.
        return st.Err()
    }
 
    var val *ValidationError
    if errors.As(err, &val) {
        return status.Error(codes.InvalidArgument, val.Error())
    }
 
    // Default: do not leak internal details to the client.
    return status.Error(codes.Internal, "internal error")
}

Wire toStatus into a grpc.UnaryServerInterceptor so every handler returns translated errors automatically — handlers stay focused on business logic and never import the status package directly. The default branch matters: returning the raw error string from an unknown failure leaks stack traces, file paths, and database error fragments into the wire response. Map unknown to codes.Internal with a generic message and let slog capture the real error server-side. The translator is also the natural place to record an err.code metric so dashboards can graph the rate of NotFound vs Internal vs DeadlineExceeded and alert when the internal-error ratio crosses a threshold.

Frequently Asked Questions

What is the difference between errors.Is and errors.As in Go?

errors.Is checks whether any error in the chain matches a specific sentinel value, while errors.As finds the first error in the chain that matches a specific type and extracts it into a target variable for inspection.

How do you wrap errors in Go without losing the original error?

Use fmt.Errorf with the %w verb (e.g., fmt.Errorf("loading user: %w", err)) to add context while preserving the original error for inspection with errors.Is and errors.As.

When should you use custom error types instead of sentinel errors in Go?

Use custom error types when errors need to carry structured data like HTTP status codes, field names, or retry hints. Use sentinel errors when the error identity alone is sufficient for callers to branch on.

Why does Go use explicit error returns instead of exceptions?

Go treats errors as values returned from functions, making every error path visible in the source code. There is no hidden control flow or stack unwinding, so you always know exactly where an error can occur and what happens when it does.

Keep Reading

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next