Chapters 6, 7 and 10 introduced error, %w and context.Context in a few paragraphs each. This chapter is the full treatment: how to design errors so callers can act on them, how to wrap and inspect them, where panics belong, and how context carries cancellation, deadlines and causes through a call chain. Everything here is in the standard library.

1. Errors are values — and part of your API

func Open(name string) (*File, error)

The error return is a promise about which failures the caller can tell apart. Decide that first, then pick one of three error styles:

Style Looks like Caller can… Use when
Opaque errors.New("disk full") created inline only log / retry blindly the caller cannot do anything specific
Sentinel var ErrNotFound = errors.New("not found") errors.Is(err, ErrNotFound) there is a small, fixed set of outcomes (io.EOF, sql.ErrNoRows)
Typed type ValidationError struct{ Field, Reason string } errors.As(err, &ve) and read fields the caller needs data (which field, which HTTP status, retry-after)

Rules that follow from "errors are API":

  • Exported sentinels are named ErrXxx; exported error types are named XxxError.
  • Return error values, not bool + message strings, and never nil pointers to typed errors (see pitfall 1 in §8).
  • Don't make callers parse err.Error(). If they need a piece of information, expose it as a field or a sentinel.

2. Wrapping: add context once per layer

Each layer that returns an error should add what it knows and nothing else:

func loadInt(key string) (int, error) {
    raw, err := lookup(key)
    if err != nil {
        return 0, fmt.Errorf("loadInt %s: %w", key, err)
    }
    ...
}

Output reads like a stack of nouns: loadInt timeout: key "timeout" not found.

  • %w wraps: the result has an Unwrap() error method, so errors.Is and errors.As can still see the original.
  • %v flattens: the message is kept but the chain is cut. Use it only when you deliberately hide an internal error from callers (and document that).
  • Since Go 1.20 one fmt.Errorf may contain several %w verbs; the result wraps all of them.
  • Do not both log an error and return it. Handle it once: either return it (wrapped) to someone who will log it, or log it and stop propagating.

Wrapping with a deferred function

When a function has many return points, a named result keeps the wrapping in one place:

func process(path string) (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("process %s: %w", path, err)
        }
    }()
    ...
}

3. Inspecting: errors.Is, errors.As, errors.Unwrap

if errors.Is(err, ErrNotFound) { ... }        // identity, through the chain

var ve *ValidationError
if errors.As(err, &ve) { use(ve.Field) }       // type, through the chain
  • errors.Is(err, target) walks the chain (depth-first) and returns true if any link == target or has an Is(error) bool method that says so.
  • errors.As(err, &target) walks the chain and copies the first link whose type matches into target. target must be a pointer to the error type — for a pointer-receiver error type that means a pointer to a pointer (var ve *ValidationError; errors.As(err, &ve)).
  • Go 1.26 adds the generic errors.AsType[*ValidationError](err) which returns (value, ok); prefer it when your go.mod allows, otherwise errors.As is identical in behaviour.
  • errors.Unwrap(err) returns one level; you rarely need it directly.

Letting a typed error match a sentinel

type NotFoundError struct{ Key string }

func (e *NotFoundError) Error() string        { return "key " + strconv.Quote(e.Key) + " not found" }
func (e *NotFoundError) Is(target error) bool { return target == ErrNotFound }

Now errors.Is(err, ErrNotFound) is true for a *NotFoundError anywhere in the chain, while errors.As still gives callers the key. An Is method should compare shallowly — never call errors.Is or Unwrap inside it.

4. Several failures at once: errors.Join

Validation, shutdown and fan-out code often collect independent errors:

var errs []error
for _, key := range keys {
    if _, err := loadInt(key); err != nil {
        errs = append(errs, err)
    }
}
return errors.Join(errs...)   // nil when errs is empty

The joined error prints each cause on its own line, and errors.Is / errors.As search all branches. A type that wraps several errors implements Unwrap() []error instead of Unwrap() error.

errors.Join is also the right tool for "close everything and report every failure":

func (s *Server) Close() error {
    return errors.Join(s.db.Close(), s.cache.Close(), s.listener.Close())
}

5. Panics belong at boundaries

panic is for programmer errors — an impossible state, an index that the code itself guaranteed to be valid, a nil dependency at start-up. Expected failures (missing file, bad input, timeout) are errors, never panics.

recover only works in a deferred function in the same goroutine, so there are exactly three places it makes sense:

  1. The top of a goroutine you start, so one bad task doesn't kill the process.
  2. An HTTP / RPC middleware, turning a panic into a 500 and a log line.
  3. A plugin or test harness that runs untrusted callbacks.
func safely(name string, fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("%s: recovered from panic: %v", name, r)
        }
    }()
    fn()
    return nil
}

Two cautions: a recovered panic has already unwound the stack, so any state the panicking code was mutating may be half-updated; and recovering runtime errors such as concurrent map writes hides data races instead of fixing them. Run go test -race (Chapter 10) rather than papering over them.

6. context.Context: cancellation that travels

A Context is an immutable node in a tree. Deriving a child (WithCancel, WithTimeout, WithDeadline, WithValue) never changes the parent; cancelling a parent cancels every descendant.

ctx, cancel := context.WithTimeout(context.Background(), 75*time.Millisecond)
defer cancel()                       // always: releases the timer and the child

err := slowWork(ctx, 10)             // returns early with ctx's error

Respecting a context

Every loop or blocking step in long-running code checks ctx.Done():

func slowWork(ctx context.Context, steps int) error {
    for i := 1; i <= steps; i++ {
        select {
        case <-ctx.Done():
            return fmt.Errorf("slowWork stopped at step %d: %w", i, context.Cause(ctx))
        case <-time.After(30 * time.Millisecond):
        }
    }
    return nil
}

Standard-library calls that can block already take a context — http.NewRequestWithContext, db.QueryContext, net.Dialer.DialContext, exec.CommandContext — so passing ctx down is usually all you need.

What the error tells you

After… ctx.Err() context.Cause(ctx)
deadline passed context.DeadlineExceeded same, unless WithDeadlineCause set one
cancel() context.Canceled same
cancelCause(err) (Go 1.20) context.Canceled err — the reason you supplied

Return context.Cause(ctx) from your own code so callers see "operator pressed stop" instead of a bare "context canceled"; both errors.Is(err, context.Canceled) checks still work because Cause returns ctx.Err() when no cause was given.

Values: request-scoped only

type requestIDKey struct{}                       // unexported key type: no collisions

ctx = context.WithValue(ctx, requestIDKey{}, "req-42")
id, _ := ctx.Value(requestIDKey{}).(string)

Carry request IDs, auth principals and trace spans — things that are about the request and cross API boundaries. Do not use values for optional parameters, loggers or database handles; those belong in function arguments or struct fields.

The four rules (from the context package documentation)

  1. ctx is the first parameter, named ctx. Never store it in a struct.
  2. Never pass nil; use context.TODO() when you genuinely have nothing yet.
  3. Always call the cancel function you were given (defer cancel()), even on the success path; go vet reports leaks.
  4. Contexts are safe for concurrent use — share one across goroutines freely.

Newer helpers worth knowing

  • context.WithCancelCause / WithTimeoutCause / WithDeadlineCause (1.20, 1.21): attach a reason.
  • context.AfterFunc(ctx, f) (1.21): run f when ctx is done — handy for closing a connection that doesn't take a context.
  • context.WithoutCancel(ctx) (1.21): keep the values but detach from cancellation, e.g. for audit logging that must finish even if the request was abandoned.
  • signal.NotifyContext(ctx, os.Interrupt): the idiomatic root context for a server that should shut down cleanly on Ctrl-C (Chapter 13 uses it).

7. Putting it together: an HTTP handler

func (s *Server) getItem(w http.ResponseWriter, r *http.Request) {
    item, err := s.store.Get(r.Context(), r.PathValue("id"))
    switch {
    case err == nil:
        writeJSON(w, http.StatusOK, item)
    case errors.Is(err, ErrNotFound):
        http.Error(w, "not found", http.StatusNotFound)
    case errors.Is(err, context.Canceled):
        return                                    // client went away; nothing to send
    default:
        var ve *ValidationError
        if errors.As(err, &ve) {
            http.Error(w, ve.Error(), http.StatusBadRequest)
            return
        }
        s.log.Error("getItem", "err", err)        // handled here, once
        http.Error(w, "internal error", http.StatusInternalServerError)
    }
}

r.Context() is cancelled when the client disconnects, so every database call under it stops early for free.

8. Pitfalls

  1. A typed nil is not nil. Returning var e *MyError = nil through an error return gives a non-nil interface. Return a literal nil on success.
  2. Comparing with == instead of errors.Is. Works until someone wraps the error one layer up.
  3. errors.As with the wrong target. For func (e *MyError) Error(), the target is *MyError, so pass &ve where ve is *MyError. errors.As panics if the target is not a pointer.
  4. Wrapping and logging the same error at every layer. Logs show the same failure five times; wrap or log, once.
  5. time.After inside a hot loop allocates a timer per iteration that lives until it fires; use time.NewTimer + Reset, or ctx with time.Sleep only in short demos.
  6. Forgetting cancel(). The child context, its timer and anything waiting on it live until the parent is cancelled — a goroutine leak.
  7. Storing a context in a struct. Requests then share lifetimes they shouldn't; pass ctx per call.
  8. Using context values as hidden parameters. Callers cannot see that a function silently needs a value; make it an argument.

9. Exercises

  1. errors.Is(fmt.Errorf("x: %v", io.EOF), io.EOF) — true or false, and why?
  2. Define type RateLimitError struct{ RetryAfter time.Duration }. Write a function that, given an arbitrary error, returns the retry delay if a RateLimitError is anywhere in the chain and 0 otherwise.
  3. validateConfig in the example joins two errors. Without changing it, how would a caller find out whether any of them was a *ValidationError?
  4. A goroutine started by your HTTP handler keeps running after the client disconnects. Which context should it have received, and what should it check?
  5. Why does context.Cause(ctx) return context.Canceled after a plain cancel() but a custom error after cancelCause(err)?

Answers

  1. False. %v formats the message but does not wrap, so the chain ends at the new error. With %w it would be true.
  2. go func retryDelay(err error) time.Duration { var rl *RateLimitError if errors.As(err, &rl) { return rl.RetryAfter } return 0 } (Give RateLimitError a pointer receiver Error() so the target type is *RateLimitError.)
  3. var ve *ValidationError; errors.As(err, &ve) — errors.As descends into every branch of a joined error (Unwrap() []error), so it finds the validation error even though a *NotFoundError comes second.
  4. It should have been started with a context derived from r.Context() (or from a detached context.WithoutCancel(r.Context()) only if the work must outlive the request). Inside its loop it must select on ctx.Done() and return.
  5. cancel() records no cause, so Cause falls back to ctx.Err(), which is context.Canceled. cancelCause(err) stores err as the cause; ctx.Err() is still context.Canceled (so existing errors.Is checks keep working) while context.Cause(ctx) returns the stored reason.

Run the example

go run ./part1-language/examples/14_errors_context

Expected output (timings may shift the "stopped at step" numbers by one):

1 err          : loadInt timeout: key "timeout" not found
  Is NotFound  : true
2 bad field    : retries - not an integer: three
3 joined       :
loadInt retries: invalid retries: not an integer: three
loadInt timeout: key "timeout" not found
  Is NotFound  : true | As Validation: true
4 boundary     : worker: recovered from panic: assignment to entry in nil map
5 timeout      : slowWork stopped at step 3: context deadline exceeded
  Is Deadline  : true
6 cause        : slowWork stopped at step 2: operator pressed stop
  ctx.Err()    : context canceled | Cause: operator pressed stop
7 request id   : req-42