Custom Errors & errors.Is/As
Reviewed & published by Brayan K
Because error is just an interface, you can create sentinel errors and custom error types, wrap them with %w to add context, and then inspect the chain with errors.Is and errors.As to react to exactly what went wrong.
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
- See that error is an interface with one method
- Define sentinel errors with errors.New
- Build custom error types implementing Error()
- Add context by wrapping with %w
- Detect a sentinel anywhere with errors.Is
- Extract a typed error with errors.As
1️⃣ Sentinel errors and errors.Is
A sentinel is a package-level error you compare against, created with errors.New. When you return it from deep in the call stack, wrap it with fmt.Errorf("...: %w", ErrNotFound) to add context while keeping it detectable. Callers use errors.Is, which walks the entire wrap chain — so it matches even through several layers.
package main
import (
"errors"
"fmt"
)
// A SENTINEL error: a package-level variable you compare against.
var ErrNotFound = errors.New("not found")
func lookup(id int) error {
if id == 0 {
// Wrap the sentinel with %w so callers can still detect it, while
// adding context about WHERE it happened.
return fmt.Errorf("lookup id=%d: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := lookup(0)
fmt.Println("error:", err)
// errors.Is walks the whole wrap chain looking for the sentinel —
// so it matches even though err is a wrapped fmt.Errorf value.
if errors.Is(err, ErrNotFound) {
fmt.Println("matched ErrNotFound? true")
}
// A non-error case: nil never matches.
fmt.Println("lookup(5) is nil?", lookup(5) == nil)
}2️⃣ Custom error types and errors.As
When the caller needs data about the failure — a field name, a code — define a struct and give it an Error() string method; that's all it takes to satisfy the error interface. To pull it back out of a wrap chain, use errors.As, which finds an error of that type and assigns it so you can read its fields.
package main
import (
"errors"
"fmt"
)
// A CUSTOM error type carries structured data. It satisfies the error
// interface by implementing Error() string.
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q failed rule %q", e.Field, e.Rule)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Rule: "non-negative"}
}
return nil
}
func main() {
// Wrap it so there's context around the structured error.
err := fmt.Errorf("validating user: %w", validateAge(-1))
fmt.Println("error:", err)
// errors.As finds an error of a SPECIFIC TYPE in the chain and assigns
// it, so you can read its fields.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("structured: field=%s rule=%s\n", ve.Field, ve.Rule)
}
}3️⃣ Reacting to different failures
Real code uses several sentinels and routes on which one occurred — for example mapping internal errors to HTTP status codes. A switch with errors.Is cases reads cleanly and still works through wrapping.
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
)
// openDoc returns different sentinels for different failures, wrapped with
// context. The caller decides what to do based on WHICH sentinel.
func openDoc(name string) error {
switch name {
case "missing.txt":
return fmt.Errorf("open %s: %w", name, ErrNotFound)
case "secret.txt":
return fmt.Errorf("open %s: %w", name, ErrPermission)
default:
return nil
}
}
func classify(name string) string {
err := openDoc(name)
switch {
case err == nil:
return "ok"
case errors.Is(err, ErrNotFound):
return "404 — " + err.Error()
case errors.Is(err, ErrPermission):
return "403 — " + err.Error()
default:
return "500 — " + err.Error()
}
}
func main() {
for _, n := range []string{"report.txt", "missing.txt", "secret.txt"} {
fmt.Printf("%-12s -> %s\n", n, classify(n))
}
}🎯 Your Turn
Wrap a sentinel and then detect it. Fill in the two blanks marked ___, then run it.
package main
import (
"errors"
"fmt"
)
var ErrEmpty = errors.New("empty input")
func parse(s string) error {
if s == "" {
// 1) Wrap ErrEmpty with %w so errors.Is can still find it
return fmt.Errorf("parse: %w", ___) // 👉 ErrEmpty
}
return nil
}
func main() {
// 🎯 YOUR TURN — fill in the blanks marked ___
err := parse("")
// 2) Detect the sentinel anywhere in the chain
if errors.___(err, ErrEmpty) { // 👉 Is
fmt.Println("input was empty")
}
// ✅ Expected output:
// input was empty
}Common Errors (and the fix)
❌ Comparing wrapped errors with == — fails after %w.
❌ Wrapping with %v when you meant to keep the error detectable.
✅ Use %w so Is/As can still see the original.
❌ Passing a value instead of a pointer to errors.As — it panics ("target must be a non-nil pointer").
✅ Declare var e *MyError and call errors.As(err, &e).
❌ Type-asserting the error (err.(*MyError)) — misses it when wrapped.
✅ Use errors.As, which unwraps the chain.
🧠 Quick Recall
e := fmt.Errorf("x: %w", ErrA); e == ErrA ?
false — e is a new wrapper. But errors.Is(e, ErrA) is true, because it unwraps the chain.
need the error's Code field — Is or As?
errors.As — it extracts the typed error so you can read fields. errors.Is only answers yes/no for a value.
🎯 Mini-Challenge: Extract a Status Code
Make fetch return a wrapped *HTTPError with code 503, then let the errors.As block in main pull out and print just the code.
package main
import (
"errors"
"fmt"
)
// HTTPError is a custom error carrying a status code.
type HTTPError struct {
Code int
Msg string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("http %d: %s", e.Code, e.Msg)
}
func fetch(url string) error {
// 🎯 MINI-CHALLENGE
// Return an *HTTPError{Code: 503, Msg: "service unavailable"} WRAPPED
// with fmt.Errorf("fetch %s: %w", url, ...). Then in main use errors.As
// to pull out the *HTTPError and print just its Code.
//
// ✅ Expected output:
// status code: 503
return nil
}
func main() {
err := fetch("https://example.test")
var he *HTTPError
if errors.As(err, &he) {
fmt.Println("status code:", he.Code)
}
// your job: make fetch return a wrapped *HTTPError so the line above runs
}🎉 Lesson Complete!
- ✅ error is an interface — any type with Error() string qualifies
- ✅ Sentinels (var ErrX = errors.New(...)) signal a specific condition
- ✅ Custom error types carry structured data
- ✅ Wrap with %w to add context while staying detectable
- ✅ errors.Is matches a value; errors.As extracts a type
Practice quiz
What is the error interface in Go?
- interface{ Error() string }
- interface{ String() string }
- interface{ Err() error }
- a built-in struct type
Answer: interface{ Error() string }. Any type with an Error() string method satisfies the error interface.
Which verb wraps an error so errors.Is can still find it through the chain?
- %v
- %s
- %w
- %e
Answer: %w. %w in fmt.Errorf keeps a link to the original error; %v would flatten it to text and lose the link.
After err := fmt.Errorf("x: %w", ErrNotFound), what is err == ErrNotFound?
- true
- false
- a compile error
- a panic
Answer: false. Wrapping creates a new wrapper value, so == is false even though errors.Is(err, ErrNotFound) is true.
Which function detects a specific sentinel error value anywhere in the wrap chain?
- errors.As
- errors.Is
- errors.Unwrap
- errors.New
Answer: errors.Is. errors.Is walks the chain comparing against a value; use it for sentinels.
Which function extracts a typed error so you can read its fields?
- errors.Is
- errors.New
- errors.As
- fmt.Errorf
Answer: errors.As. errors.As finds an error of a given TYPE in the chain and assigns it to your variable.
How do you create a sentinel error?
- var ErrX = errors.New("...")
- var ErrX = fmt.Sprintf("...")
- type ErrX error
- var ErrX = panic("...")
Answer: var ErrX = errors.New("..."). A sentinel is a package-level value made with errors.New that callers compare against.
errors.As(err, target) requires target to be...
- a value of the error type
- a non-nil pointer to a variable of the error type
- a string
- nil
Answer: a non-nil pointer to a variable of the error type. Pass a pointer like &ve where var ve *MyError; a value (or nil) makes As panic.
Why use errors.Is instead of == for a wrapped sentinel?
- == is slower
- == fails after wrapping; Is unwraps the chain
- == is not allowed on errors
- they are identical
Answer: == fails after wrapping; Is unwraps the chain. After %w the value is a new wrapper, so == is false; errors.Is unwraps step by step and matches.
If you wrap with %v instead of %w, errors.Is(err, ErrX) will...
- still return true
- return false because the link is lost
- panic
- not compile
Answer: return false because the link is lost. %v only prints text and drops the chain link, so errors.Is can no longer find the original.
Is or As — you need the error's Code field?
- errors.Is
- errors.As
- either works
- neither
Answer: errors.As. errors.As extracts the typed error so you can read fields; errors.Is only gives a yes/no for a value.
Continue this course
- Previous: File I/O (os, bufio, io)
- Next: Generic Constraints & Type Sets — comparable, constraints.Ordered, and custom constraints with type sets
Frequently asked questions
Why is error an interface, and what does that buy me?
error is just interface{ Error() string }. Because it's an interface, ANY type with an Error() string method is an error — so you can attach structured data (a status code, a field name, a retry hint) to your errors and still pass them around as a plain error. Callers can later inspect the concrete type with errors.As when they need those details.
What is a sentinel error and when should I use one?
A sentinel is a package-level error value, like var ErrNotFound = errors.New("not found"), that callers compare against to detect a specific condition. Use one when the only thing the caller needs to know is 'this particular thing happened' — no extra data. Detect it with errors.Is, never with ==, so it still matches after wrapping.
What does wrapping with %w actually do?
fmt.Errorf("context: %w", err) creates a new error that adds your message AND keeps a link to the original. That link is what errors.Is and errors.As follow when they walk the chain. Use %w (not %v) whenever you want the caller to still be able to detect or extract the underlying error; use %v if you deliberately want to hide it.
What's the difference between errors.Is and errors.As?
errors.Is(err, target) asks 'is this specific error VALUE somewhere in the chain?' — used for sentinels. errors.As(err, &target) asks 'is there an error of this TYPE in the chain?' and, if so, assigns it to your variable so you can read its fields — used for custom error types. Rule of thumb: Is compares against a value, As extracts a type.
Should Error() be on a pointer or value receiver?
Usually a pointer receiver — func (e *MyError) Error() string — because you typically return &MyError{...} and errors.As needs a pointer to a pointer (var me *MyError; errors.As(err, &me)). Be consistent: if Error() is on *MyError, then *MyError is the error type, and you must return the pointer, not a value.
Why not just compare errors with == after wrapping?
Once you wrap an error with %w, the value is a new fmt.wrapError, so err == ErrNotFound is false even though ErrNotFound is inside. errors.Is unwraps the chain step by step and compares at each level, so it correctly reports a match. Always use errors.Is for sentinels and errors.As for types instead of == and type assertions.