Error Wrapping (errors.Is/As, %w)
Reviewed & published by Brayan K
Go errors are just values, and since Go 1.13 you can wrap them to add context without losing the original. By the end you'll wrap with %w, match wrapped errors with errors.Is, extract custom error types with errors.As, and understand why == isn't enough.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Create sentinel errors with errors.New
- Add context by wrapping with fmt.Errorf and %w
- Match a wrapped error anywhere in the chain with errors.Is
- Extract a concrete error type with errors.As
- Unwrap one layer at a time with errors.Unwrap
- See why == only checks the outermost error
- Build custom error types that carry structured data
📊 The Error API
| Tool | Purpose |
|---|---|
| errors.New("x") | Create a simple sentinel error value |
| fmt.Errorf("...%w", e) | Wrap e with extra context |
| errors.Is(err, target) | Is target anywhere in the chain? |
| errors.As(err, &v) | Pull out a value of v's type |
| errors.Unwrap(err) | Remove exactly one wrap layer |
Use %w (wrap) when callers may want to inspect the cause; use %v (format only) when you just want the text and no link back.
1️⃣ Wrapping with %w
As an error travels up through your call stack, each layer can add context. The verb %w in fmt.Errorf wraps the original error: the message gains your context, but the original stays reachable underneath. Compare with %v, which only embeds the text and severs the link.
package main
import (
"errors"
"fmt"
)
// A sentinel error: a known value callers can compare against.
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
if id != 1 {
// %w WRAPS ErrNotFound, adding context but keeping the original
// reachable. %v would only embed the text and lose the link.
return fmt.Errorf("findUser %d: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := findUser(7)
fmt.Println(err) // findUser 7: not found
// errors.Is walks the wrap chain looking for a match.
if errors.Is(err, ErrNotFound) {
fmt.Println("yes, it was a not-found error")
}
}Wrapping composes — each layer adds context, and errors.Is still sees all the way down to the root cause.
package main
import (
"errors"
"fmt"
)
var ErrPermission = errors.New("permission denied")
func openFile() error {
return fmt.Errorf("open config: %w", ErrPermission)
}
func loadConfig() error {
if err := openFile(); err != nil {
// Wrap AGAIN, adding another layer of context.
return fmt.Errorf("loadConfig: %w", err)
}
return nil
}
func main() {
err := loadConfig()
fmt.Println(err) // loadConfig: open config: permission denied
// errors.Is sees through BOTH wraps to the sentinel underneath.
if errors.Is(err, ErrPermission) {
fmt.Println("matched ErrPermission deep in the chain")
}
}Your turn. Replace the blank with the verb that wraps the error so errors.Is can still find it.
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("timeout")
func call() error {
// 🎯 YOUR TURN — wrap ErrTimeout with context using %w.
return fmt.Errorf("call api: ___", ErrTimeout) // 👉 %w
}
func main() {
err := call()
fmt.Println(err) // call api: timeout
fmt.Println(errors.Is(err, ErrTimeout)) // true
}2️⃣ errors.Is vs ==
Before wrapping, people compared errors with ==. That only checks the outermost error, so a wrapped error never matches. errors.Is walks the entire chain, which is exactly what you want. errors.Unwrap peels off one layer if you ever need to step through manually.
package main
import (
"errors"
"fmt"
)
var ErrClosed = errors.New("closed")
func main() {
wrapped := fmt.Errorf("step 1: %w", ErrClosed)
// == compares the TOP error only — it does not see the wrapped one.
fmt.Println(wrapped == ErrClosed) // false
// errors.Is walks the whole chain — this is what you want.
fmt.Println(errors.Is(wrapped, ErrClosed)) // true
// errors.Unwrap pulls off ONE layer at a time.
fmt.Println(errors.Unwrap(wrapped) == ErrClosed) // true
}3️⃣ Custom Types & errors.As
Sometimes an error needs to carry data, not just a message — which field failed, an HTTP status, a retry-after time. Define a type with an Error() string method and it's an error. To get that concrete type back out of a wrap chain, use errors.As, which sets a pointer you provide.
package main
import (
"errors"
"fmt"
)
// A custom error type carries STRUCTURED data, not just a message.
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func validate(age int) error {
if age < 0 {
return fmt.Errorf("validate: %w", &ValidationError{Field: "age", Msg: "must be >= 0"})
}
return nil
}
func main() {
err := validate(-5)
// errors.As extracts the concrete type from anywhere in the chain,
// so you can read its fields.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("bad field %q: %s\n", ve.Field, ve.Msg)
}
}🧩 Reorder Challenge
These lines define a sentinel, wrap it with context, and check for it. Put them in the correct order:
A. fmt.Println("file was missing")
B. var ErrMissing = errors.New("missing")
C. err := fmt.Errorf("load: %w", ErrMissing)
D. if errors.Is(err, ErrMissing) {
E. }B, C, D, A, E. Declare the sentinel, wrap it with %w to add context, then test with errors.Is and print inside the matching if block. The sentinel must exist before you can wrap or match it.
🧠 Quick Recall
Predict the output before revealing the answer.
var ErrX = errors.New("x")
e := fmt.Errorf("ctx: %w", ErrX)
fmt.Println(e == ErrX, errors.Is(e, ErrX))false true — == compares only the outer wrapper, which is a new error value, so it's false. errors.Is walks the chain and finds ErrX, so it's true.
2. Does errors.Is match here?
var ErrX = errors.New("x")
e := fmt.Errorf("ctx: %v", ErrX) // note %v, not %w
fmt.Println(errors.Is(e, ErrX))false — %v only formats the text of ErrX into the message; it does not wrap, so there's no link for errors.Is to follow. You must use %w to wrap.
3. What does errors.As do here?
var ve *ValidationError
e := fmt.Errorf("x: %w", &ValidationError{Field: "name"})
fmt.Println(errors.As(e, &ve), ve.Field)true name — errors.As finds the *ValidationError in the chain, sets ve to point at it, and returns true. Then ve.Field reads "name".
Pro Tips
- 💡 Add context as you wrap — fmt.Errorf("loadConfig: %w", err) builds a readable breadcrumb trail from where the error happened to where you handle it.
- 💡 Don't start the context with "failed to" or "error". Convention is lowercase, no trailing punctuation, no "error:" prefix — the caller adds those.
- 💡 Use sentinels (errors.New) for "is it this?" checks and custom types for "tell me more about it" — Is for the former, As for the latter.
- 💡 Only wrap when callers benefit. If a caller would never inspect the cause, %v (no wrap) is fine and keeps the API surface smaller.
Common Errors (and the fix)
- errors.Is always returns false — you wrapped with %v instead of %w, so there's no chain to walk. Switch to %w.
- "second argument to errors.As must be a non-nil pointer" — pass the address of your target variable: errors.As(err, &ve), not errors.As(err, ve).
- Comparing errors with == after wrapping — it only checks the outer error. Use errors.Is for wrapped errors.
- "fmt.Errorf format %w has arg of wrong type" — %w requires an error value. Don't use %w for non-error arguments.
- Double-wrapping with %w twice in one Errorf — only one %w is allowed per fmt.Errorf call; use multiple calls if you must.
Frequently Asked Questions
Q: When should I use %w versus %v?
Use %w when a caller might want to detect the underlying cause with errors.Is or errors.As. Use %v when you only want the human-readable text and don't want to expose the cause as part of your API.
Q: What's the difference between errors.Is and errors.As?
errors.Is answers "is this specific error value in the chain?" — good for sentinels. errors.As answers "is there an error of this type?" and hands you the value so you can read its fields — good for custom error types.
Q: Should every function wrap the errors it returns?
Add context where it's useful — typically once per logical boundary (a package or major operation). Wrapping at every single call adds noise. Aim for a message trail that reads like a sentence describing what was being attempted.
Q: Can I implement Is or As on my own type?
Yes. Add an Is(target error) bool or As(target any) bool method and the standard functions will call it, letting you define custom matching (for example, treating any 404 as a not-found error).
Mini-Challenge: Wrapped Parser
No blanks — just a brief. Write a parser that wraps a sentinel error with context, then detect it from main with errors.Is. This is precisely how real Go programs propagate and classify failures.
package main
import (
"errors"
"fmt"
)
var ErrEmpty = errors.New("empty input")
func main() {
// 🎯 MINI-CHALLENGE: a parser with wrapped errors
// 1. Write parse(s string) (int, error). If s == "", return 0 and an
// error that WRAPS ErrEmpty using %w with the context "parse:".
// 2. In main, call parse("") and print the error.
// 3. Use errors.Is to confirm the error wraps ErrEmpty, and print
// "input was empty" if so.
//
// ✅ Expected output:
// parse: empty input
// input was empty
// your code here
}🎉 Lesson Complete!
- ✅ Wrap errors with fmt.Errorf("...: %w", err) to add context
- ✅ errors.Is walks the whole chain; == only checks the top
- ✅ errors.As extracts a concrete error type so you can read its fields
- ✅ errors.Unwrap removes one layer at a time
- ✅ Use sentinels for identity checks, custom types for structured data
- ✅ %v formats text without wrapping; %w keeps the link
Practice quiz
Which fmt.Errorf verb WRAPS an error so it stays reachable?
- %v
- %s
- %w
- %e
Answer: %w. %w wraps the error, keeping the original reachable; %v only embeds the text and severs the link.
What does errors.Is(err, target) check?
- Whether target appears anywhere in the wrap chain
- Only the outermost error
- The error's type
- If err is nil
Answer: Whether target appears anywhere in the wrap chain. errors.Is walks the entire wrap chain looking for a match, not just the outermost error.
Given wrapped := fmt.Errorf("step 1: %w", ErrClosed), what does wrapped == ErrClosed return?
- true
- a panic
- nil
- false
Answer: false. == compares only the top wrapper, which is a new value, so it is false. Use errors.Is to match wrapped errors.
For that same wrapped error, what does errors.Is(wrapped, ErrClosed) return?
- false
- true
- an error
- nil
Answer: true. errors.Is walks the chain and finds ErrClosed underneath the wrap, so it returns true.
If you wrap with %v instead of %w, will errors.Is find the original?
- No — %v does not create a link
- Yes
- Only one level deep
- Only for sentinels
Answer: No — %v does not create a link. %v only formats the text and does not wrap, so there is no chain for errors.Is to follow — it returns false.
What does errors.As(err, &v) do?
- Compares two errors
- Unwraps one layer
- Extracts a value of v's type from the chain and sets v
- Creates a sentinel
Answer: Extracts a value of v's type from the chain and sets v. errors.As finds an error of the target type in the chain and sets the pointer you pass, so you can read its fields.
What does errors.Unwrap(err) do?
- Walks the whole chain
- Removes exactly one wrap layer
- Returns the root cause
- Deletes the error
Answer: Removes exactly one wrap layer. errors.Unwrap peels off exactly one layer at a time.
How do you create a simple sentinel error?
- fmt.Errorf("x")
- new(error)
- error("x")
- errors.New("x")
Answer: errors.New("x"). errors.New("x") creates a sentinel error value callers can compare against with errors.Is.
How many %w verbs are allowed in a single fmt.Errorf call?
- Unlimited
- Exactly one
- Two
- Zero
Answer: Exactly one. Only one %w is allowed per fmt.Errorf call; use multiple calls if you must wrap more than one error.
Which tool is best for a 'tell me more about it' check on a custom error type?
- errors.Is
- == comparison
- errors.As
- errors.New
Answer: errors.As. errors.As pulls out the concrete custom type so you can read its fields; errors.Is is for sentinel identity checks.
Continue this course
- Previous: defer, panic & recover
- Next: Generics (Type Parameters) — Write reusable code with type parameters and constraints