Wrapping Errors
Why wrap errors?
Section titled “Why wrap 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"]
fmt.Errorf and %w
Section titled “fmt.Errorf and %w”fmt.Errorf creates a new error whose message incorporates other values — but how it incorporates an error argument depends on the verb you choose:
%vformats the error’s message string and embeds it as plain text. The original error type is gone. The chain is cut.%wrecords 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 severede1 := fmt.Errorf("dial: %v", base)fmt.Println(errors.Is(e1, base)) // false
// %w — wraps, chain is preservede2 := fmt.Errorf("dial: %w", base)fmt.Println(errors.Is(e2, base)) // trueUse %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 chainThis 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 *ValidationErrorif 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.)
Custom error types
Section titled “Custom error types”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 *VErrerrors.As(err, ve) // BUG: passing ve, not &veVerified on 1.26.5, this panics:
panic: errors: target must be a non-nil pointerve 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 interrors.As(err, &n) // BUG: *int does not implement errorpanic: errors: *target must be interface or implement errorerrors.Join and multiple %w
Section titled “errors.Join and multiple %w”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)) // truefmt.Println(errors.Is(joined, ErrNet)) // truefmt.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.
Custom Is and As methods
Section titled “Custom Is and As methods”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.
Receiver choice affects errors.As
Section titled “Receiver choice affects errors.As”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 chainvar asPtr *PtrErrerrors.As(perr, &asPtr) // true
err := fmt.Errorf("op: %w", ValueErr{msg: "x"}) // ValueErr in the chainvar asValuePtr *ValueErrerrors.As(err, &asValuePtr) // false -- chain holds ValueErr, not *ValueErrvar asValue ValueErrerrors.As(err, &asValue) // true -- matches the value typeThere 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.
%w with a nil error
Section titled “%w with a nil error”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 // nilerr := 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.
No stack traces
Section titled “No stack traces”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)} Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet calls errors.As or wraps multiple errors, check for these before trusting it:
- Is
errors.Ascalled 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 returnsfalse. - Does code call
errors.Unwrap(err)expecting to reach every error inside anerrors.Joinor multi-%wresult?Unwrap() []erroris a different shape — onlyerrors.Is/errors.Aswalk it. - Does a custom error type’s receiver (value vs pointer) match the pointer-ness of every
errors.Astarget used against it elsewhere in the codebase? - Does
fmt.Errorf("...: %w", err)ever run whenerrcould benil? Check the caller, not just the line itself —go vetwill 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).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| fmt.Errorf(“%w”, err) | wraps with context, errors.Is/As work | more verbose than a bare return err |
| errors.Is() through the chain | matches a sentinel at any depth | slower than == (has to traverse the chain) |
| custom error type | carries 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 chain | easy to confuse the two verbs |
Common Misconceptions
Section titled “Common Misconceptions”- 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.Unwrapstrips off only one wrapper per call, and returnsnilfor an error whose method isUnwrap() []errorrather thanUnwrap() error— for example the result oferrors.Joinor anfmt.Errorfwith more than one%w. To walk the whole chain or find a match, useerrors.Is/errors.Asinstead. - %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.