Skip to content

Wrapping Errors

When a function calls another and it fails, returning the raw error loses context. The caller sees “database error” but has no idea which operation triggered it, which request it belonged to, or at what layer it occurred. Wrapping adds a layer of context — what were you trying to do? — while preserving the original error so it can still be inspected.

The idiomatic pattern in Go:

if err != nil {
return fmt.Errorf("description: %w", err)
}

The %w verb is the key. It wraps the original error inside the new one, keeping the full chain intact for callers who need to inspect it.

flowchart LR
  A["ErrNotFound (sentinel)"] -->|wrapped by queryDB| B["queryDB: %w"]
  B -->|wrapped by getUser| C["getUser: %w"]
  C -->|"errors.Is(err, ErrNotFound)"| D["true -- walks the whole chain"]
Each %w adds a link; errors.Is walks every link looking for a match

fmt.Errorf creates a new error whose message incorporates other values — but how it incorporates an error argument depends on the verb you choose:

  • %v formats the error’s message string and embeds it as plain text. The original error type is gone. The chain is cut.
  • %w records the original error by value inside the new error. The message still contains the original message, but the original is also accessible programmatically.
base := errors.New("connection refused")
// %v — message only, chain is severed
e1 := fmt.Errorf("dial: %v", base)
fmt.Println(errors.Is(e1, base)) // false
// %w — wraps, chain is preserved
e2 := fmt.Errorf("dial: %w", base)
fmt.Println(errors.Is(e2, base)) // true

Use %w when you want callers upstream to be able to identify or extract the original error. Use %v only when you intentionally want to discard the error type — for example, when logging and the chain is irrelevant.

errors.Is — checking identity through a chain

Section titled “errors.Is — checking identity through a chain”

errors.Is(err, target) walks the entire unwrap chain of err and returns true if any error in the chain matches target. Matching is done by identity (==) by default, not by message string.

var ErrNotFound = errors.New("not found")
err := fmt.Errorf("getUser: %w", fmt.Errorf("queryDB: %w", ErrNotFound))
fmt.Println(errors.Is(err, ErrNotFound)) // true — found deep in the chain

This works regardless of how deeply the target is nested. You define a sentinel error once (a package-level var) and check for it anywhere in the call stack.

errors.As — extracting a type from the chain

Section titled “errors.As — extracting a type from the chain”

errors.As(err, &target) also walks the chain, but instead of checking identity, it checks type. If any error in the chain can be assigned to target’s type, it performs the assignment and returns true.

var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field) // access fields on the concrete type
}

Use errors.As when you have a custom error type that carries extra information — field names, HTTP status codes, retry hints — and you need to access those fields, not just check that the error exists.

errors.AsType — the generic form (Go 1.26)

Section titled “errors.AsType — the generic form (Go 1.26)”

Go 1.26 added errors.AsType[T], a type-safe alternative to errors.As. It returns the matched value and a bool, so there is no pointer-to-pointer argument to get wrong and the compiler checks T:

if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println(ve.Field)
}

Prefer AsType in new code; errors.As remains for older toolchains. (go fix can migrate existing call sites.)

Any struct that implements Error() string satisfies the error interface. Custom types let you carry structured context alongside the message:

type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %q %s", e.Field, e.Message)
}

To make a custom error type part of a wrapping chain, implement Unwrap() error:

type AppError struct {
Code int
Err error
}
func (e *AppError) Error() string { return fmt.Sprintf("code %d: %v", e.Code, e.Err) }
func (e *AppError) Unwrap() error { return e.Err }

With Unwrap in place, errors.Is and errors.As can see through AppError to reach whatever it wraps.

errors.As needs a pointer to the concrete type

Section titled “errors.As needs a pointer to the concrete type”

errors.As(err, target)’s second argument must be a non-nil pointer to a variable whose type either implements error or is an interface. Get this wrong and errors.As panics instead of returning false — a deliberate choice, since a bad target is almost always a coding mistake, not a runtime condition worth silently ignoring.

The single most common version of this mistake is passing the pointer variable itself instead of its address:

var ve *VErr
errors.As(err, ve) // BUG: passing ve, not &ve

Verified on 1.26.5, this panics:

panic: errors: target must be a non-nil pointer

ve is a nil *VErr. Passing it directly hands errors.As a pointer of the right kind but no addressable storage to write a match into — errors.As needs &ve (a **VErr) so it has somewhere to store the result. A target whose pointee type doesn’t implement error at all panics with a different, equally real message:

var n int
errors.As(err, &n) // BUG: *int does not implement error
panic: errors: *target must be interface or implement error

errors.Join (Go 1.20) combines several errors into one value whose Unwrap() []error method exposes all of them. errors.Is and errors.As walk every branch, so a single errors.Is call can match any of the joined errors:

joined := errors.Join(ErrDisk, ErrNet)
fmt.Println(errors.Is(joined, ErrDisk)) // true
fmt.Println(errors.Is(joined, ErrNet)) // true

fmt.Errorf accepts more than one %w in the same call, also since Go 1.20, which produces the same kind of multi-error chain:

err := fmt.Errorf("save failed: %w, %w", ErrDisk, ErrNet)

Both forms mean errors.Unwrap(err) — which only understands the single-error Unwrap() error shape — returns nil for them. Use errors.Is/errors.As to inspect a joined or multi-%w error; never assume errors.Unwrap alone will reach every branch.

errors.Is and errors.As check for an Is(target error) bool or As(target any) bool method on each error in the chain before falling back to their default identity/type check. This lets a type override what counts as a match:

// Is lets errors.Is treat every ValueErr as equal, regardless of msg --
// useful when the exact message varies but the caller only cares
// "was it this kind of error?"
func (e ValueErr) Is(target error) bool {
_, ok := target.(ValueErr)
return ok
}

With this method in place, errors.Is(err, ValueErr{msg: "anything"}) reports true for any ValueErr in the chain, not just one with an identical msg field. Define a custom As the same way when the default type-match isn’t precise enough — for example, to match against an underlying error code stored in a field rather than the wrapper’s own type.

The concrete type that ends up in the error chain is whatever type actually implements error — and that depends on the receiver you chose. A pointer receiver means only *T implements error, so the chain holds *T and errors.As needs a **T target. A value receiver means T implements error directly, and wrapping a T value (not &T{}) puts a T in the chain — errors.As with a **T target finds nothing:

perr := fmt.Errorf("op: %w", &PtrErr{Code: 7}) // *PtrErr in the chain
var asPtr *PtrErr
errors.As(perr, &asPtr) // true
err := fmt.Errorf("op: %w", ValueErr{msg: "x"}) // ValueErr in the chain
var asValuePtr *ValueErr
errors.As(err, &asValuePtr) // false -- chain holds ValueErr, not *ValueErr
var asValue ValueErr
errors.As(err, &asValue) // true -- matches the value type

There is nothing wrong with either receiver choice on its own; the bug is writing the errors.As target as if the error had been wrapped as a pointer when it was wrapped as a value, or vice versa.

Wrapping a nil error is legal — fmt.Errorf does not panic — but it produces a message you almost certainly did not intend:

var inner error // nil
err := fmt.Errorf("op failed: %w", inner)
fmt.Println(err) // op failed: %!w(<nil>)

%w requires a non-nil error argument to record; handed nil, fmt falls back to its usual bad-verb formatting and embeds %!w(<nil>) in the message. go vet’s printf check cannot catch this case — inner’s static type is error, and vet has no way to know its value is nil at compile time. Guard with if err != nil before wrapping, the same discipline used everywhere else.

The standard errors package never records where an error was created or which goroutine touched it — Error() returns a string, and that is the entire contract. Wrapping with %w builds a chain of messages and causes, not a call stack. When you need positional information, you add it explicitly: capture runtime.Caller or runtime/debug.Stack() at the point of creation and store it as a field, or reach for a third-party package built for this (github.com/pkg/errors popularized errors.WithStack, predating errors.Is/errors.As in the standard library). This is a deliberate trade-off — cheap, composable errors that cost nothing until you decide you need more.

package main
import (
"errors"
"fmt"
)
var ErrDisk = errors.New("disk full")
var ErrNet = errors.New("network unreachable")
// PtrErr uses a pointer receiver -- the chain holds *PtrErr.
type PtrErr struct{ Code int }
func (e *PtrErr) Error() string { return fmt.Sprintf("code %d", e.Code) }
// ValueErr uses a value receiver -- the chain holds ValueErr, not *ValueErr.
type ValueErr struct{ msg string }
func (e ValueErr) Error() string { return e.msg }
// Is lets errors.Is treat every ValueErr as equal, regardless of msg --
// useful when the exact message varies but the caller only cares "was it
// this kind of error?"
func (e ValueErr) Is(target error) bool {
_, ok := target.(ValueErr)
return ok
}
func main() {
// errors.Join combines independent failures into one error whose
// Unwrap() []error exposes both -- errors.Is finds either one.
joined := errors.Join(ErrDisk, ErrNet)
fmt.Println(joined)
fmt.Println("joined is ErrDisk:", errors.Is(joined, ErrDisk))
fmt.Println("joined is ErrNet:", errors.Is(joined, ErrNet))
// fmt.Errorf accepts more than one %w since Go 1.20.
multi := fmt.Errorf("save failed: %w, %w", ErrDisk, ErrNet)
fmt.Println(multi)
// errors.Unwrap only understands Unwrap() error -- a multi-%w error
// implements Unwrap() []error instead, so this is nil.
fmt.Println("errors.Unwrap(multi):", errors.Unwrap(multi))
fmt.Println("multi is ErrNet:", errors.Is(multi, ErrNet))
// A custom Is method changes what errors.Is considers a match.
err := fmt.Errorf("op: %w", ValueErr{msg: "specific detail"})
fmt.Println("custom Is match:", errors.Is(err, ValueErr{msg: "different detail"}))
// Receiver choice decides which pointer-ness errors.As must ask for.
perr := fmt.Errorf("op: %w", &PtrErr{Code: 7})
var asPtr *PtrErr
fmt.Println("errors.As *PtrErr:", errors.As(perr, &asPtr))
var asValuePtr *ValueErr
fmt.Println("errors.As *ValueErr on value-receiver chain:", errors.As(err, &asValuePtr))
var asValue ValueErr
fmt.Println("errors.As ValueErr on value-receiver chain:", errors.As(err, &asValue))
// %w with a nil error is a vet-flagged formatting mistake, not a crash.
var nilErr error
fmt.Println(fmt.Errorf("wrapped: %w", nilErr))
}
package main
import (
"errors"
"fmt"
)
var ErrDatabase = errors.New("database error")
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %q %s", e.Field, e.Message)
}
func queryDB(id int) (string, error) {
if id < 0 {
return "", ErrDatabase
}
if id == 0 {
return "", &ValidationError{Field: "id", Message: "must be positive"}
}
return "Alice", nil
}
func getUser(id int) (string, error) {
name, err := queryDB(id)
if err != nil {
return "", fmt.Errorf("getUser %d: %w", id, err)
}
return name, nil
}
func main() {
_, err := getUser(-1)
if err != nil {
fmt.Println(err)
fmt.Println("is ErrDatabase:", errors.Is(err, ErrDatabase))
}
_, err = getUser(0)
if err != nil {
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("field=%q msg=%q\n", ve.Field, ve.Message)
}
}
name, err := getUser(1)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("name:", name)
}

When a generated snippet calls errors.As or wraps multiple errors, check for these before trusting it:

  • Is errors.As called with the pointer variable itself (errors.As(err, ve)) instead of its address (errors.As(err, &ve))? The first form panics at runtime; it never just returns false.
  • Does code call errors.Unwrap(err) expecting to reach every error inside an errors.Join or multi-%w result? Unwrap() []error is a different shape — only errors.Is/errors.As walk it.
  • Does a custom error type’s receiver (value vs pointer) match the pointer-ness of every errors.As target used against it elsewhere in the codebase?
  • Does fmt.Errorf("...: %w", err) ever run when err could be nil? Check the caller, not just the line itself — go vet will not flag this.
  • Does the code assume errors carry a stack trace or line number by default? The standard library never attaches one.

🐛 Spot the bug

type NetErr struct{ Op string }
func (e *NetErr) Error() string { return e.Op + ": timeout" }
func handle(err error) {
var ne *NetErr
if errors.As(err, ne) {
fmt.Println("net error on", ne.Op)
}
}
func main() {
handle(fmt.Errorf("dial: %w", &NetErr{Op: "dial"}))
}
Show the answer

ne is declared as var ne *NetErr — a nil *NetErr — and passed to errors.As directly instead of &ne. errors.As needs the address of the target variable so it can write the match into it; handed the nil pointer itself, it has nowhere to write. Verified on 1.26.5, this panics: panic: errors: target must be a non-nil pointer. The fix is errors.As(err, &ne).

What you getBenefitCost
fmt.Errorf(“%w”, err)wraps with context, errors.Is/As workmore verbose than a bare return err
errors.Is() through the chainmatches a sentinel at any depthslower than == (has to traverse the chain)
custom error typecarries structured context (field, code, status)you must maintain the type and implement Error()
%v vs %w in fmt.Errorf%v shows the message as a string, %w preserves the chaineasy to confuse the two verbs
  • wrapping an error at every layer always adds useful context — wrapping too deeply makes the error message hard to read; only wrap when the caller actually needs that context.
  • errors.Unwrap walks the whole chain for you — actually errors.Unwrap strips off only one wrapper per call, and returns nil for an error whose method is Unwrap() []error rather than Unwrap() error — for example the result of errors.Join or an fmt.Errorf with more than one %w. To walk the whole chain or find a match, use errors.Is/errors.As instead.
  • %w in fmt.Errorf only supports one error — Go 1.20+ supports fmt.Errorf("msg: %w and %w", e1, e2) to wrap multiple errors.
  • errors.Is compares the message string — errors.Is checks identity (==), not the message string — sentinel errors must be package-level vars.
What is the difference between %v and %w in fmt.Errorf?
errors.Is(err, ErrNotFound) returns true when:
What happens when this runs?
var ve *VErr
errors.As(err, ve) // note: not &ve
What does this print?
multi := fmt.Errorf("save failed: %w, %w", ErrDisk, ErrNet)
fmt.Println(errors.Unwrap(multi))
A type's Error() method has a pointer receiver: func (e *VErr) Error() string. What must an errors.As target be to match it?
To make a custom error type unwrappable, you implement: