The Wrap Sheet
An error in Go starts life as a decent witness. errors.New("connection
refused") saw the host and knows the exact second. Then it gets passed up
through three packages, each adding a note for the log file, and somewhere
along the way somebody writes this:
return errors.New("failed to process order")
The witness is gone. The log now says an order failed, which has the investigative value of a parking ticket that just says “car.”
The fix is one character. fmt.Errorf has a wrapping verb, %w, and it
embeds the old error inside the new one:
return fmt.Errorf("process order %d: %w", orderID, err)
The outer error keeps its own message plus a pointer to the one underneath. Errors stack up like a tidy pile of parking tickets, each admitting fault and naming the previous holder of the fault.
Which is why this line, beloved by tutorials and veteran of a thousand codebases, eventually stops working:
if err == ErrNotFound {
== compares the outermost error only. The day somebody wraps ErrNotFound
in context (they will, because the log line demanded it), you are comparing an
envelope to a letter. It compiles. Tests that exercise the happy path stay
green. And in production, every not-found quietly becomes a 500.
errors.Is is the replacement. It checks the error, calls Unwrap(), checks
what comes back, and keeps walking until it finds your sentinel or runs out of
pile. Same story for types. The assertion err.(*fs.PathError) inspects
exactly one layer, the outermost. errors.As inspects all of them:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return pathErr.Path
}
The verb comes with a rule attached: use %w when callers need to identify
the underlying error, use %v when they don’t. Sounds reasonable. In practice
nobody knows what callers will need in eighteen months, so %v spreads
through a codebase the way uninitialized maps spread through a junior’s pull
request. Nothing crashes. No test fails. The messages in your logs look
perfectly normal. The chains are just gone, and with them every errors.Is
you were planning to write.
Worse is the deportation version:
if err != nil {
return errors.New("db error")
}
Nothing was wrapped here. The original error was questioned, escorted out of the building, and given no forwarding address. The replacement has none of the facts.
Since Go 1.20 the verb accepts more than one %w, and errors.Join stacks
several failures side by side:
err := errors.Join(errProfile, errSettings)
errors.Is checks every branch, so your error chain is a family tree now.
Which is exactly what you want for “the page half-loaded and both halves
failed” reports, and exactly what you don’t want to discover you don’t have,
at 3am, from a log line that says failed: failed.
The whole habit costs about four characters per error site. Wrap where it
matters and match with Is and As, and every error that reaches your logs
can still name names. A good error tells you what failed. A wrapped one
testifies about where, when, and with whom. Every investigation needs a wrap
sheet. Yours is one verb wide.