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 namedXxxError. - Return error values, not
bool+ message strings, and nevernilpointers 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.
%wwraps: the result has anUnwrap() errormethod, soerrors.Isanderrors.Ascan still see the original.%vflattens: 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.Errorfmay contain several%wverbs; 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== targetor has anIs(error) boolmethod that says so.errors.As(err, &target)walks the chain and copies the first link whose type matches intotarget.targetmust 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 yourgo.modallows, otherwiseerrors.Asis 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:
- The top of a goroutine you start, so one bad task doesn't kill the process.
- An HTTP / RPC middleware, turning a panic into a 500 and a log line.
- 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)
ctxis the first parameter, namedctx. Never store it in a struct.- Never pass
nil; usecontext.TODO()when you genuinely have nothing yet. - Always call the
cancelfunction you were given (defer cancel()), even on the success path;go vetreports leaks. - 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): runfwhenctxis 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
- A typed nil is not
nil. Returningvar e *MyError = nilthrough anerrorreturn gives a non-nil interface. Return a literalnilon success. - Comparing with
==instead oferrors.Is. Works until someone wraps the error one layer up. errors.Aswith the wrong target. Forfunc (e *MyError) Error(), the target is*MyError, so pass&vewhereveis*MyError.errors.Aspanics if the target is not a pointer.- Wrapping and logging the same error at every layer. Logs show the same failure five times; wrap or log, once.
time.Afterinside a hot loop allocates a timer per iteration that lives until it fires; usetime.NewTimer+Reset, orctxwithtime.Sleeponly in short demos.- Forgetting
cancel(). The child context, its timer and anything waiting on it live until the parent is cancelled — a goroutine leak. - Storing a context in a struct. Requests then share lifetimes they
shouldn't; pass
ctxper call. - Using context values as hidden parameters. Callers cannot see that a function silently needs a value; make it an argument.
9. Exercises
errors.Is(fmt.Errorf("x: %v", io.EOF), io.EOF)— true or false, and why?- Define
type RateLimitError struct{ RetryAfter time.Duration }. Write a function that, given an arbitraryerror, returns the retry delay if aRateLimitErroris anywhere in the chain and0otherwise. validateConfigin the example joins two errors. Without changing it, how would a caller find out whether any of them was a*ValidationError?- A goroutine started by your HTTP handler keeps running after the client disconnects. Which context should it have received, and what should it check?
- Why does
context.Cause(ctx)returncontext.Canceledafter a plaincancel()but a custom error aftercancelCause(err)?
Answers
- False.
%vformats the message but does not wrap, so the chain ends at the new error. With%wit would be true. go func retryDelay(err error) time.Duration { var rl *RateLimitError if errors.As(err, &rl) { return rl.RetryAfter } return 0 }(GiveRateLimitErrora pointer receiverError()so the target type is*RateLimitError.)var ve *ValidationError; errors.As(err, &ve)—errors.Asdescends into every branch of a joined error (Unwrap() []error), so it finds the validation error even though a*NotFoundErrorcomes second.- It should have been started with a context derived from
r.Context()(or from a detachedcontext.WithoutCancel(r.Context())only if the work must outlive the request). Inside its loop it mustselectonctx.Done()and return. cancel()records no cause, soCausefalls back toctx.Err(), which iscontext.Canceled.cancelCause(err)storeserras the cause;ctx.Err()is stillcontext.Canceled(so existingerrors.Ischecks keep working) whilecontext.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