Error Handling in Go: errors.Is, errors.As, and Wrapping
A practical guide to Go error handling: the error interface, sentinel values, custom types, wrapping with fmt.Errorf %w, and errors.Is and errors.As.
What you'll learn
- ✓How the error interface works in Go
- ✓When to use sentinel errors vs custom error types
- ✓How to wrap errors with fmt.Errorf and %w
- ✓How errors.Is and errors.As inspect wrapped chains
- ✓Idioms for returning, logging, and handling errors
Prerequisites
- •Familiar with [Go functions](/blog/go-functions)
- •Some experience with [Go variables and types](/blog/go-variables-and-types)
Go does not have exceptions. Errors are ordinary values returned alongside results, and idiomatic Go pushes you to think about failure modes explicitly. That clarity comes at the cost of verbosity, but the standard library has steadily added tools, most notably fmt.Errorf with %w, errors.Is, and errors.As, that make structured error handling concise and powerful.
The error interface
error is a built-in interface with a single method:
type error interface {
Error() string
}
Any type with that method is an error. The convention is to return errors as the last value:
func ReadAll(path string) ([]byte, error) {
// ...
return nil, nil
}
Callers check if err != nil and decide what to do: handle it, wrap and propagate it, or surface it to the user.
Creating simple errors
For one-off messages, use errors.New or fmt.Errorf:
package main
import (
"errors"
"fmt"
)
func div(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
_, err := div(10, 0)
if err != nil {
fmt.Println("error:", err)
}
}
fmt.Errorf is preferred when you want formatted output or wrapping.
Sentinel errors
A sentinel error is a package-level variable used as a comparable marker. The standard library defines several: io.EOF, sql.ErrNoRows, os.ErrNotExist.
package store
import "errors"
var ErrNotFound = errors.New("store: not found")
func Lookup(key string) (string, error) {
return "", ErrNotFound
}
Callers compare with errors.Is:
v, err := store.Lookup("k")
if errors.Is(err, store.ErrNotFound) {
// handle missing case
}
Sentinels are simple but expose part of your API surface; treat them as a contract that you cannot lightly change.
Custom error types
When you need to carry structured information, define a type that implements Error():
package main
import "fmt"
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Rule)
}
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Rule: "required"}
}
return nil
}
Use a pointer receiver so that two equally-valued errors do not accidentally compare equal as values. Callers extract the typed error with errors.As:
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
}
Wrapping with %w
When you propagate an error up the stack, you usually want to add context without losing the original. fmt.Errorf with the %w verb produces a wrapped error whose chain can be inspected later:
package main
import (
"errors"
"fmt"
"os"
)
func loadConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("loadConfig %q: %w", path, err)
}
return nil
}
func main() {
err := loadConfig("missing.toml")
fmt.Println(err)
fmt.Println("not exist?", errors.Is(err, os.ErrNotExist))
}
The printed message includes your context plus the underlying message; errors.Is walks the chain and matches os.ErrNotExist.
Only wrap with %w when you want callers to be able to detect the inner error. If the inner cause is an implementation detail, use %v instead to avoid leaking it into your API.
errors.Is vs errors.As
These two functions are the workhorses of structured handling:
errors.Is(err, target)walks the wrap chain and reports whether any error in it equalstarget. Use it for sentinel comparisons.errors.As(err, &target)walks the chain looking for an error assignable to the target pointer. Use it to extract a custom type.
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("op:", pathErr.Op, "path:", pathErr.Path)
}
Avoid err == SomeErr and type assertions on wrapped errors; they fail to traverse the chain.
Handling vs propagating
A rule of thumb: handle an error once. Either deal with it where it occurs, or wrap and return it. Logging an error and then returning it usually produces duplicate noise in your logs.
func fetchAndSave(url, path string) error {
data, err := fetch(url)
if err != nil {
return fmt.Errorf("fetch %q: %w", url, err)
}
if err := save(path, data); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
The top of the call stack, typically main or an HTTP handler, decides what to log or display.
A complete example
Putting it together:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type DBError struct {
Query string
Err error
}
func (e *DBError) Error() string { return fmt.Sprintf("db %q: %v", e.Query, e.Err) }
func (e *DBError) Unwrap() error { return e.Err }
func getUser(id int) error {
return &DBError{Query: "SELECT ...", Err: ErrNotFound}
}
func main() {
err := getUser(1)
fmt.Println(err)
if errors.Is(err, ErrNotFound) {
fmt.Println("user missing")
}
var dbe *DBError
if errors.As(err, &dbe) {
fmt.Println("failing query:", dbe.Query)
}
}
The custom type implements Unwrap(), so the wrap chain is intact for both Is and As.
Related reading
- Go install and first program if you are still setting up.
- Go control flow for the
if err != nilpatterns in context. - Go slices and maps for examples that frequently return errors from lookups.
Wrap up
Treat errors as part of your API design. Use sentinels for stable, comparable conditions; use custom types when you need structured data; and reach for fmt.Errorf with %w when adding context. Detect with errors.Is and extract with errors.As. Handle each error exactly once, and let the outermost layer decide how to present failures to humans or systems.