Skip to content
C Codeloom
Go

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.

·5 min read · By Yash Kesharwani
Intermediate 10 min read

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 equals target. 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.

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.