Error wrapping, sentinel errors, errors.Is and errors.As
Errors in Go are values. That is the first half of the story and most tutorials stop there. The second half, the part that matters in production, is how you propagate errors through ten layers of function calls without losing context, and how you check for specific failures upstream without doing string matching on .Error() output.
Since Go 1.13 the language has a real error-wrapping story: %w, errors.Is, errors.As. Senior interviews assume you know it. This lesson is that story end to end.
The pre-1.13 dark ages
Before Go 1.13, the only way to attach context to an error was to wrap it in a string.
if err != nil {
return fmt.Errorf("loading config: %s", err) // err is gone
}The original error is now a substring of a new error. There is no way for the caller to ask "is this really an os.ErrNotExist underneath?" except by parsing the string. Libraries like github.com/pkg/errors plugged this gap with their own Wrap and Cause functions. Then Go 1.13 absorbed the pattern into the standard library and pkg/errors became unnecessary.
%w: error wrapping done right
Use %w instead of %s in fmt.Errorf and the original error is preserved as the "cause".
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err)
}The returned error is a new error with a new message, but errors.Unwrap (and errors.Is / errors.As) can see through it to the original. This is the foundation of every other tool in this lesson.
Interview trap: "What does
fmt.Errorfwithout%wdo?" The answer interviewers want: "It creates a brand-new error and drops the original.errors.Isanderrors.Ascannot see through it. Always use%wwhen you are wrapping,%sonly when you genuinely want a new error with no chain."
errors.Is: identity through wrappers
errors.Is(err, target) walks the chain of wrapped errors and asks "does any error in this chain equal target?".
_, err := os.Open("/etc/config.yaml")
if errors.Is(err, os.ErrNotExist) {
// even if err was wrapped 5 times, we still match
}Compare this to the pre-1.13 alternative of strings.Contains(err.Error(), "no such file"). Brittle, locale-dependent, breaks on the next Go release. errors.Is is the answer.
Sentinel errors are package-level error values you compare against. The standard library is full of them: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled.
var ErrUserNotFound = errors.New("user not found")
func (s *Store) Get(id int) (*User, error) {
if !exists(id) {
return nil, ErrUserNotFound
}
...
}
// caller:
u, err := store.Get(id)
if errors.Is(err, ErrUserNotFound) {
return http.StatusNotFound
}errors.As: extracting a typed error
When the caller needs more than identity, when they want the actual fields of a structured error, use errors.As.
type ValidationError struct {
Field string
Msg string
}
func (v *ValidationError) Error() string { return v.Field + ": " + v.Msg }
var vErr *ValidationError
if errors.As(err, &vErr) {
return badRequest(vErr.Field, vErr.Msg)
}errors.As walks the chain and assigns the first error of the matching type into the pointer you provide. Returns true if found. This is how you handle PostgreSQL errors (assert to *pq.Error to read the code), HTTP errors (assert to *url.Error), and your own custom error types.
Senior rule:
errors.Isfor "is it this specific sentinel?".errors.Asfor "is it this type, give me the value". Never use type assertions (err.(*MyErr)) for wrapped errors, they do not walk the chain.
Custom error types: when worth it
Sentinel errors are cheap and great for identity. Custom error types are worth it when the error carries data the caller actually uses.
type RateLimitError struct {
RetryAfter time.Duration
Limit int
}
func (r *RateLimitError) Error() string {
return fmt.Sprintf("rate limited, retry after %s", r.RetryAfter)
}The caller can extract RetryAfter with errors.As and decide how long to back off. That data would be impossible to expose via a sentinel error.
If your error has zero fields beyond the message, use a sentinel. If it has fields the caller will read, use a type. Do not invent custom error types for every package; the standard library has only a handful.
errors.Join: error trees
Go 1.20 added errors.Join for when one operation produces multiple errors that all matter.
var errs []error
for _, op := range ops {
if err := op.Do(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...) // nil if all entries are nilThe joined error's Error() method prints each sub-error on its own line. errors.Is and errors.As correctly walk into joined trees. Use it for batch operations, validation passes, parallel fan-outs where you want to surface every failure not just the first.
The "don't just return err" question
A senior interview classic: "What is wrong with if err != nil { return err }?".
The answer: nothing, at the bottom of the call stack. Everything, in the middle. As an error propagates up, each layer should add what it was trying to do.
// bad: no context, debugging nightmare
if err := db.Query(q); err != nil {
return err
}
// good: caller knows where and why
if err := db.Query(q); err != nil {
return fmt.Errorf("loading invoice %s: %w", id, err)
}When the error surfaces in your logs as loading invoice INV-2046: querying invoices table: connection refused, you can read the stack trace from the message alone. No debugger needed.
What NOT to do
Three anti-patterns that wreck the error chain.
One: fmt.Errorf("...%v", err) or %s. Loses the chain.
Two: errors.New(err.Error()) or fmt.Errorf("%s", err.Error()). You took a structured error and turned it into a string, then back into an error. errors.Is and errors.As see nothing.
Three: ignoring errors with _. The compiler will not warn you. The reviewer should.
b, _ := io.ReadAll(resp.Body) // production bug waiting to happenError handling terms
- %w verb
- fmt.Errorf wrap verb. Preserves the original error as the cause so errors.Is and errors.As can see it.
- errors.Is
- Walks the error chain and returns true if any wrapped error equals the target. For sentinel comparisons.
- errors.As
- Walks the chain and assigns the first matching error into the target pointer. For typed error extraction.
- sentinel error
- A package-level error value like io.EOF used as a known identity. Compared via errors.Is.
- errors.Join
- Combines multiple non-nil errors into one. Added in Go 1.20. errors.Is/As walk into joined trees.
- Unwrap
- Method on an error type that returns the wrapped error. fmt.Errorf with %w implements it automatically.
Senior rule: every error returned across a function boundary either adds context with
%wor is a sentinel/typed error the caller is expected to handle. Barereturn erris acceptable only at the lowest layer.
Common questions
Q.When should I use a sentinel error vs a custom error type?›
Q.What is the difference between fmt.Errorf with %w and with %v?›
Watching quietly. Tap me if you want a tip.
Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.
Try this (0 of 2 done)
- 1
Predict the output.
hint
errors.Is unwraps %w to find the sentinel error.
show answer
// already in the editor - 2
What does it print if you change %w to %v?
hint
%v formats the error as a string but does NOT wrap. errors.Is cannot find the chain.
show answer
return fmt.Errorf("user %d: %v", id, ErrNotFound)