Lesson 21 of 30 advanced 5 min read

Before this:Functions & error handling

Error-handling patterns

Key takeaways Go returns errors as values, and real programs add structure. Wrap an error with fmt.Errorf("...: %w", err) to add context while keeping the original. Compare against a sentinel with errors.Is, and extract a custom error type with errors.As. Define your own error types when callers need more than a message. These patterns turn if err != nil into errors that are useful, not just present.

The functions & errors lesson introduced the error interface. This one covers the patterns that make errors carry real information as they travel up a call stack.

Wrapping with %w

As an error bubbles up, each layer should add context — where it happened — without discarding the cause. The %w verb wraps:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("load config %s: %w", path, err)
    }
    // ...
}

The message becomes load config scan.json: open scan.json: no such file or directory — a breadcrumb trail — and the wrapped error stays reachable underneath. Use %w (wrap) when callers may inspect the cause; use %v (format) when you only want the text.

Sentinel errors and errors.Is

A sentinel is a named error value that callers can test against:

var ErrNoLock = errors.New("decoder: no signal lock")

func decode(f Frame) error {
    if f.SNR < 6 {
        return ErrNoLock
    }
    return nil
}

if errors.Is(err, ErrNoLock) {   // matches even through wrapping
    retry()
}

errors.Is walks the whole wrapped chain, so a deeply wrapped ErrNoLock is still found. That is why you test with errors.Is rather than err == ErrNoLock.

Custom error types and errors.As

When callers need data about the failure — not just its identity — define a type that implements error:

type DecodeError struct {
    FreqHz float64
    Symbol int
}

func (e *DecodeError) Error() string {
    return fmt.Sprintf("decode failed at %.3f MHz, symbol %d", e.FreqHz/1e6, e.Symbol)
}

Pull it back out with errors.As, which finds the first error of that type in the chain and assigns it:

var de *DecodeError
if errors.As(err, &de) {
    log.Printf("bad symbol %d at %.3f MHz", de.Symbol, de.FreqHz/1e6)
}
Tool Question it answers Example
fmt.Errorf("...: %w", err) How do I add context? open capture: %w
sentinel + errors.Is Is this that error? errors.Is(err, io.EOF)
custom type + errors.As What are the details? errors.As(err, &de)

When to wrap, when to handle

Not every error needs wrapping. A rule of thumb: handle an error where you can do something about it (retry, default, log-and-continue), and wrap-and-return it everywhere else so the layer that can act still has the context it needs. Wrapping once per boundary keeps messages informative without turning them into noise.

Quick check: what does wrapping with %w preserve that %v does not?

Recap

  • Wrap with fmt.Errorf("...: %w", err) to add context and keep the cause.
  • Define a sentinel and test it with errors.Is (works through wrapping).
  • Define a custom error type and extract it with errors.As for details.
  • Handle where you can act; wrap-and-return everywhere else.

Next up: proving your code works with Go’s built-in testing package.

Frequently asked questions

What does %w do in fmt.Errorf?

It wraps an underlying error inside a new one while preserving the original for later inspection. fmt.Errorf(“open capture: %w”, err) adds context to the message and keeps err reachable, so errors.Is and errors.As can still find it further up the stack.

When do I use errors.Is versus errors.As?

Use errors.Is to test whether an error chain contains a specific sentinel value, like errors.Is(err, io.EOF). Use errors.As to pull a specific error type out of the chain so you can read its fields, like errors.As(err, &pathErr). Is asks which value; As asks which type.