ToolPopToolPop
Go · Lesson 16 of 22

Error wrapping, sentinel errors, errors.Is and errors.As

7 min readUpdated 25 Jun 2026

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.

go
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".

go
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.Errorf without %w do?" The answer interviewers want: "It creates a brand-new error and drops the original. errors.Is and errors.As cannot see through it. Always use %w when you are wrapping, %s only 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?".

go
_, 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.

go
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.

go
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.Is for "is it this specific sentinel?". errors.As for "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.

go
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.

go
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 nil

The 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.

go
// 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.

go
b, _ := io.ReadAll(resp.Body) // production bug waiting to happen

Error 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 %w or is a sentinel/typed error the caller is expected to handle. Bare return err is acceptable only at the lowest layer.

Common questions

Q.When should I use a sentinel error vs a custom error type?
A.Sentinel for simple checks ("was this NotFound?"). Custom type when the error carries fields the caller needs (which row failed, what HTTP status to map to, etc.). Both work with errors.Is/As.
Q.What is the difference between fmt.Errorf with %w and with %v?
A.%w wraps the error so errors.Is/As can find it in the chain. %v formats it as a string and DROPS the original. Always %w if you want callers to be able to inspect what failed.
Chai0/2 done

Watching quietly. Tap me if you want a tip.

Go Playground

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. 1

    Predict the output.

    hint

    errors.Is unwraps %w to find the sentinel error.

    show answer
    // already in the editor
  2. 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)