Opening the Doll - A guide to Go's `errors.Is` and `errors.As`
In most programming languages, errors are explosive. They are exceptions that blow up and fly through your code until something catches them.
Go is different. Errors don't automatically bubble or unwind the stack - they move through your program like any other value. You return them, look at them, and decide what to do with them at every step.
What is an error in Go?
In Go, an error is any type that implements the error interface:
type error interface {
Error() string
}
If a value can describe itself as text, it can be treated as an error.
This simplicity is powerful, but it comes with a challenge: higher-level code usually needs more context than the original error provides.
Consider a server that fails to start. The error happens somewhere deep in the call stack:
func startServer() error {
err := loadConfig()
if err != nil {
return err // passing the error along
}
}
func loadConfig() error {
err := readFile()
if err != nil {
return err // passing the error along
}
}
func readFile() error {
file, err := os.Open("config.yaml")
if err != nil {
return err
}
defer file.Close()
}
When startServer() fails, you only see:
open config.yaml: no such file or directory
Where did this happen? During startup? Config loading? File reading? You're left guessing. The error lost context about where it happened and what was being done.
The Russian doll: wrapping errors
Go solves this with wrapping.
Think of Russian Matryoshka dolls: the innermost doll is the root cause; each outer doll adds a bit more context around it.
Instead of just forwarding errors, you can wrap them with helpful context using fmt.Errorf with %w:
func startServer() error {
err := loadConfig()
if err != nil {
return fmt.Errorf("starting server: %w", err)
}
}
func loadConfig() error {
err := readFile()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
}
func readFile() error {
file, err := os.Open("config.yaml")
if err != nil {
return err
}
defer file.Close()
}
Now when startServer() fails, you get a full story instead of a mystery:
starting server: loading config: open config.yaml: no such file or directory
You immediately see the chain. The server failed → because config loading failed → because the file couldn't be opened.
%w wraps the original error while keeping it intact, creating an error chain that can still be inspected.
┌─────────────────────────────────────────────────────────────┐
│ "starting server" │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ "loading config" │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ "open config.yaml: no such file or directory" │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Sometimes you need to look inside that chain. Maybe you want to check whether a specific error occurred, or pull out a particular kind of error so you can work with it. Go gives you two tools: errors.Is and errors.As.
errors.Is: checking identity
Sometimes you want to know whether an error matches a specific sentinel value - not "something similar," but this exact error:
var ErrNoInternet = errors.New("no internet connection")
errors.Is checks whether this error exists anywhere in the chain:
if errors.Is(err, ErrNoInternet) {
fmt.Println("Retry when the network returns")
}
Go walks the chain by:
- Comparing directly (
==). - Calling
Is(target error) boolif implemented. - Calling
Unwrap()repeatedly.
This is identity, not string comparison. Two errors with the same message are still different:
var ErrFoo = errors.New("something went wrong")
var ErrBar = errors.New("something went wrong")
errors.Is(ErrFoo, ErrBar) // false
errors.As: extracting types
Sometimes you care less about the exact value and more about what kind of error it is - especially if it carries useful data:
type UploadError struct {
FileName string
BytesSent int
Inner error
}
func (e *UploadError) Error() string {
return fmt.Sprintf("uploading %s", e.FileName)
}
func (e *UploadError) Unwrap() error {
return e.Inner
}
errors.As searches the chain for a matching type and assigns it to your variable:
var ue *UploadError
if errors.As(err, &ue) {
fmt.Printf("Sent %d bytes before failure of %s\n", ue.BytesSent, ue.FileName)
}
Go checks the chain by:
- Seeing if the error can be assigned to the target type.
- Calling
As(target any) boolif implemented. - Calling
Unwrap()repeatedly.
So:
errors.Isis about identity (is this that exact error?).errors.Asis about type and usefulness (can I treat this error like this type?).
Unwrapping errors
Every wrapped error must give Go a way to reach the error inside it. Any type that implements:
Unwrap() error
becomes part of the chain. fmt.Errorf("%w", err) does this for you automatically. If you create your own structured error types, you must implement Unwrap() yourself. Otherwise, the chain stops. Higher-level code can't inspect what's inside, and methods like errors.Is and errors.As won't work.
So wrap deliberately. Wrap when you add useful context. Don't wrap just to move the error along. Too much wrapping turns a helpful story into noise.
TL;DR
- Errors in Go are values, not exceptions.
- Wrap with
%wto add context without losing the original error. errors.Ischecks identity.errors.Asextracts a type so you can use it.- Implement
Unwrap()in custom errors if they wrap something else.
Once you understand wrapping and the difference between errors.Is and errors.As, Go's error handling becomes clearer, more intentional, and easier to reason about.
