Ch. 25 · Go

Go Error Wrapping: %w, errors.Is and errors.As

How fmt.Errorf with %w builds an error chain in Go, when to use errors.Is versus errors.As, and how to design sentinel and typed errors.

~7 min readintermediateupdated Oct 6, 2026

“How do you handle errors in Go?” is asked in nearly every Go interview, and the answer “check if err != nil” is only the opening. The interviewer wants to hear how you add context without losing the cause, how a caller decides that an error means “not found” rather than “database down”, and what the difference is between errors.Is and errors.As. Those questions test whether you have designed error handling for a real service or only satisfied the compiler.

Since Go 1.13 the standard library has a small, precise wrapping model, extended in Go 1.20 to errors with several causes. This guide explains it end to end, with Go 1.23 examples.

Before you start

You need to know that error is a built-in interface with a single method, Error() string, and that functions return it as their last result. You should be able to create errors with errors.New and fmt.Errorf, and understand pointers and type assertions, since errors.As builds on them.

The short answer

Wrap an error with fmt.Errorf("context: %w", err) to add a message while keeping the original reachable through an Unwrap method. errors.Is(err, target) walks that chain and reports whether any error in it equals target, which suits sentinel values like sql.ErrNoRows or fs.ErrNotExist. errors.As(err, &target) walks the chain looking for an error of the target’s type and, if found, assigns it so you can read its fields. Use %v instead of %w when you deliberately do not want callers to depend on the underlying error.

How it works

An error wraps another if it has a method Unwrap() error, or since Go 1.20 Unwrap() []error. fmt.Errorf with a %w verb returns such an error. The result is a chain, or with multiple causes a tree, that the errors package traverses depth first.

_, err := os.Open("config.yaml")
err = fmt.Errorf("load config: %w", err)

fmt.Println(err)
// load config: open config.yaml: no such file or directory

fmt.Println(errors.Is(err, fs.ErrNotExist)) // true

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
	fmt.Println(pathErr.Op, pathErr.Path) // open config.yaml
}
go

Here the chain is three deep: the fmt wrapper, an *fs.PathError that records the operation and path, and the underlying syscall.Errno (ENOENT). errors.Is checks each error with == and also asks any error that has an Is(error) bool method; syscall.Errno uses that method to report that ENOENT matches fs.ErrNotExist. errors.As checks whether each error is assignable to the target’s type, or has an As(any) bool method that says yes.

The target passed to errors.As must be a non-nil pointer to a type that implements error or to an interface type. Passing pathErr instead of &pathErr panics, which is a quick way to fail an interview live-coding round.

Step-by-step walkthrough

Step 1: Define sentinel errors for conditions callers branch on

package store

var ErrNotFound = errors.New("store: not found")
var ErrConflict = errors.New("store: version conflict")
go

A sentinel is a package-level value that callers compare against with errors.Is. Use them for a small number of conditions with a clear meaning in your domain. Prefix the message with the package name so logs show where it came from.

Step 2: Add context at each layer with %w

func (s *Store) User(ctx context.Context, id int64) (User, error) {
	var u User
	err := s.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
	if errors.Is(err, sql.ErrNoRows) {
		return User{}, fmt.Errorf("user %d: %w", id, ErrNotFound) // translate to the domain
	}
	if err != nil {
		return User{}, fmt.Errorf("query user %d: %w", id, err)
	}
	return u, nil
}
go

Each layer adds what it knows: the ID, the operation, the input file. The final message reads like a breadcrumb trail, such as sync profile: user 42: store: not found. Do not repeat information the wrapped error already contains, and do not start messages with “failed to”, which becomes noise when chained.

Step 3: Use typed errors when callers need data

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}

var verr *ValidationError
if errors.As(err, &verr) {
	respondJSON(w, http.StatusBadRequest, map[string]string{"field": verr.Field, "reason": verr.Reason})
}
go

A sentinel answers “which condition?”; a typed error also carries details. Use a pointer receiver and return *ValidationError, then match with a *ValidationError target. Return it as error, never as the concrete pointer type, to avoid the typed-nil trap.

Step 4: Map foreign errors onto your sentinels with an Is method

type HTTPError struct{ Status int }

func (e *HTTPError) Error() string { return fmt.Sprintf("upstream returned %d", e.Status) }

func (e *HTTPError) Is(target error) bool {
	return target == ErrNotFound && e.Status == http.StatusNotFound
}
go

Now errors.Is(err, ErrNotFound) is true for an upstream 404, while other statuses keep their own identity. This keeps callers working with one vocabulary of errors regardless of whether the data came from a database or another service.

Step 5: Combine several errors when more than one thing failed

var errs []error
for _, f := range files {
	if err := f.Close(); err != nil {
		errs = append(errs, fmt.Errorf("close %s: %w", f.Name(), err))
	}
}
return errors.Join(errs...) // nil when errs is empty
go

errors.Join (Go 1.20) returns nil if every argument is nil, formats the messages on separate lines, and lets errors.Is and errors.As search every branch. fmt.Errorf("%w; %w", e1, e2) does the same with a single-line message.

Worked scenario

A user-profile API returns 500 for unknown users instead of 404. Support tickets pile up, and the error rate alarm fires every time a crawler probes old URLs.

// repository
if err := row.Scan(&u.ID, &u.Name); err != nil {
	return User{}, fmt.Errorf("scan user %d: %v", id, err) // %v flattens the chain
}

// handler
u, err := repo.User(ctx, id)
switch {
case errors.Is(err, sql.ErrNoRows):
	http.Error(w, "not found", http.StatusNotFound)
case err != nil:
	http.Error(w, "internal error", http.StatusInternalServerError)
}
go

There are two problems. The repository used %v, so sql.ErrNoRows is only text inside the message and errors.Is cannot find it: every miss becomes a 500. And even if it used %w, the HTTP handler would be depending on a database driver detail, which breaks the day the repository switches to a cache or another service.

The fix translates at the boundary and keeps the chain intact:

// repository
err := row.Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
	return User{}, fmt.Errorf("user %d: %w", id, store.ErrNotFound)
}
if err != nil {
	return User{}, fmt.Errorf("scan user %d: %w", id, err)
}

// handler
switch {
case errors.Is(err, store.ErrNotFound):
	http.Error(w, "not found", http.StatusNotFound)
case err != nil:
	log.Printf("get user: %v", err) // logged once, here
	http.Error(w, "internal error", http.StatusInternalServerError)
}
go

The handler now speaks the domain’s language, the 404 path works, and the full message, including the driver’s cause, still reaches the logs for real failures.

Common mistake

  • Matching on strings with strings.Contains(err.Error(), "not found"). Messages change; identities do not.
  • Using == on a wrapped error. err == ErrNotFound fails once anyone adds context. Use errors.Is.
  • Log and return. Each layer logging the same error produces five log lines for one failure. Handle it once: log at the top or return.
  • Wrapping everything with %w. Every wrapped error becomes part of your API contract. Use %v for causes callers should not depend on.
  • Passing a non-pointer to errors.As, which panics.

Verify the behavior

Extract the handler’s switch into statusFor(err error) int (adding a *ValidationError case for 400), and a table-driven test makes the contract explicit:

func TestStatusForError(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want int
	}{
		{"not found, wrapped twice", fmt.Errorf("profile: %w", fmt.Errorf("user 7: %w", store.ErrNotFound)), 404},
		{"validation", &ValidationError{Field: "email", Reason: "empty"}, 400},
		{"flattened with %v", fmt.Errorf("user 7: %v", store.ErrNotFound), 500},
		{"unknown", errors.New("boom"), 500},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := statusFor(tc.err); got != tc.want {
				t.Errorf("statusFor(%v) = %d, want %d", tc.err, got, tc.want)
			}
		})
	}
}
go

The third case documents that %v deliberately hides identity. Run with go test -run TestStatusForError -v.

Follow-up questions

When should you panic instead of returning an error? For programmer errors and impossible states, such as an invalid regular expression literal in regexp.MustCompile, not for expected failures like missing files or bad input.

Do Go errors carry stack traces? Not by default. Add context through wrapping, structured log attributes with log/slog, or tracing spans; third-party packages can attach stacks if you need them.

What does errors.Unwrap return for a joined error? nil. It only calls Unwrap() error; multi-error trees are traversed by Is and As.

How do you check for a timeout? errors.Is(err, context.DeadlineExceeded), or errors.As into a net.Error and call Timeout().

Interview exercise

Given the code below, what are the four lines of output?

var ErrTimeout = errors.New("timeout")

type OpError struct {
	Op  string
	Err error
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }

err := fmt.Errorf("sync user 42: %w", &OpError{Op: "fetch", Err: ErrTimeout})
fmt.Println(err)
fmt.Println(errors.Is(err, ErrTimeout))
var op *OpError
fmt.Println(errors.As(err, &op), op.Op)
fmt.Println(errors.Unwrap(errors.Unwrap(err)) == ErrTimeout)
go

Answer and reasoning

The output is sync user 42: fetch: timeout, then true, then true fetch, then true. The message concatenates each layer’s text. errors.Is unwraps the fmt wrapper, reaches the *OpError, unwraps that through its Unwrap method, and finds ErrTimeout. errors.As stops at the *OpError, assigns it to op, and its Op field is fetch. Two manual Unwrap calls peel off the fmt wrapper and then the OpError, leaving the sentinel itself, so == holds. The answer shows you can trace a chain by hand, which is how you debug a mapping that misbehaves.

Continue learning

Practise with the Go interview questions and the Go MCQs. Related notes: the Go nil error trap, Go context cancellation and Python exception chaining for a contrast with exception-based languages. Primary sources: the errors package documentation, the Go blog post working with errors in Go 1.13 and the Go 1.20 release notes for multi-error wrapping.

More in Go

esc