IndexAdvanced Go PatternsPart 03

Error Handling

Wrapping, sentinels, errors.Is/As, and custom error types.

Feb 16, 20265 min readErrorsPart 03 of 11

if err != nil is the most mocked thing about Go and, done well, one of its quiet strengths. Errors are values. That means you can wrap them, inspect them, build them, and route on them with ordinary code — no special exception machinery, no invisible control flow.

This post is about doing that well: adding context without losing machine-readability, and knowing when to use a sentinel, a custom type, or just a wrapped string.

Errors are values

An error is just an interface with one method:

Code
type error interface {
    Error() string
}

That's the whole contract. Anything with an Error() string method is an error. This simplicity is why everything below is just regular Go, not framework magic.

Add context as errors travel up

The cardinal sin is returning a bare error from deep in the stack. By the time it surfaces, "connection refused" tells you nothing about what was trying to connect or why. Wrap errors with context as they propagate.

Use fmt.Errorf with the %w verb — the w is for "wrap":

Go
func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading config %q: %w", path, err)
    }
    cfg, err := parse(data)
    if err != nil {
        return nil, fmt.Errorf("parsing config %q: %w", path, err)
    }
    return cfg, nil
}

Now the final message reads like a breadcrumb trail:

Code
loading config "app.yaml": open app.yaml: no such file or directory

%w is special: it doesn't just format the error's text, it embeds the original error so it can be recovered later with errors.Is and errors.As. Using %v instead would flatten it to a string and sever that chain — so reach for %w whenever you want callers to be able to inspect the cause, and %v only when you deliberately want to obscure it.

fmt.Errorf("…: %w", err) builds a chain — Is/As walk it back "loading config"outermost wrap "open app.yaml"os layer syscall.ENOENTroot cause %w %w errors.Iserrors.As Unwrap() each layer until a match (or nil)
Each %w nests the cause; Is / As unwrap back down to it

Conventions worth following: lowercase, no trailing punctuation, no "failed to" / "error" noise (the wrapping already implies failure). "parsing config" — not "Failed to parse config!".

Sentinel errors: when callers need to branch on a specific error

Sometimes a caller needs to react to a particular error — "if the key is missing, use a default." A sentinel is a predeclared error value you can compare against:

Go
var ErrNotFound = errors.New("not found")

func (c *Cache) Get(key string) (Value, error) {
    v, ok := c.data[key]
    if !ok {
        return Value{}, ErrNotFound
    }
    return v, nil
}

Callers check with errors.Is, not ==. errors.Is walks the whole %w chain, so it works even when the sentinel has been wrapped several layers deep:

Go
v, err := cache.Get("user:42")
if errors.Is(err, ErrNotFound) {
    v = defaultValue // handle the specific case
} else if err != nil {
    return err       // some other failure
}

io.EOF and sql.ErrNoRows are the canonical examples. Sentinels are great, but they couple callers to your exact value, so expose them sparingly — only for conditions callers genuinely need to distinguish.

Custom error types: when the error carries data

A sentinel is a fixed value. When the error needs to carry information — a field name, an HTTP status, a retry hint — define a type:

Go
type ValidationError struct {
    Field string
    Value any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid value %v for field %q", e.Value, e.Field)
}

Callers recover the typed error with errors.As, which (like errors.Is) walks the wrap chain and, on a match, fills in your variable:

Go
var verr *ValidationError
if errors.As(err, &verr) {
    // We now have the typed error and its fields.
    log.Printf("field %s rejected", verr.Field)
    http.Error(w, verr.Error(), http.StatusBadRequest)
}

Make your custom type implement Unwrap() if it wraps another error, so the chain stays intact:

Go
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err } // keeps errors.Is/As working

Is vs As — the one-line distinction

  • errors.Is(err, target) — "is this error (or anything it wraps) equal to this sentinel value?" Use for sentinels.
  • errors.As(err, &target) — "is this error (or anything it wraps) of this type?" If so, extract it. Use for custom types.
What does the caller need from the error? caller's need just branch? need its data? sentinel + errors.Isvar ErrX = errors.New(…) typed + errors.Astype XError struct {…}
Sentinel vs typed error — pick by what the caller must do

Joining multiple errors

Modern Go lets you combine several errors into one — perfect for validating many fields or cleaning up multiple resources without bailing on the first failure:

Go
func (f *Form) Validate() error {
    var errs []error
    if f.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if f.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    return errors.Join(errs...) // nil if errs is empty
}

errors.Is/As work across a joined error too — they check every branch.

When to wrap, when to handle, when to ignore

  • Wrap and return when you can't handle the error here but want to add context for whoever can. This is the common case in library and middle-layer code.
  • Handle when you can actually do something: retry, fall back, use a default. Handle as close to the cause as makes sense.
  • Log or return — not both. Logging an error and returning it means it gets logged again up the stack, producing duplicate noise. Decide who owns it: typically the top-level handler logs; everyone below returns.
  • Don't ignore silently. _ = f.Close() is occasionally fine, but at least think about it. For deferred closes that can fail meaningfully (writers!), capture the error:
Go
func writeFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer func() {
        // Capture the close error if we don't already have one.
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()
    _, err = f.Write(data)
    return err
}

(Named return err makes the deferred capture possible.)

The takeaway

The takeaway

Errors are values, so handle them with ordinary code. Wrap with %w to add context while preserving the chain; use errors.Is for sentinels callers branch on and errors.As for typed errors that carry data; implement Unwrap so your custom types stay inspectable; and use errors.Join to collect many at once. The discipline that pays off most: add context on the way up, handle at the right layer, and never log-and-return the same error twice.