Code of the Day
IntermediateInterfaces & errors

The error interface

Go's built-in error type is a simple interface — learn to create, wrap, and inspect errors idiomatically.

GoIntermediate11 min read
Recommended first
By the end of this lesson you will be able to:
  • Explain why error is an interface in Go
  • Create errors with errors.New and fmt.Errorf
  • Distinguish sentinel errors from ad-hoc errors
  • Use errors.Is to test for a specific sentinel error in an error chain
  • Use errors.As to extract a concrete error type from a chain

Every function that can fail in Go returns an error as its last return value. This is not a convention enforced by the compiler — it is a culture enforced by the community, and it works because error is just an . Understanding the interface lets you craft, wrap, and inspect errors at every level of your call stack.

The error interface

The error interface is defined in the builtin package and contains exactly one method:

type error interface {
    Error() string
}

Any type with an Error() string method satisfies it. That simplicity means you can create errors anywhere and adapt any type into one.

errors.New and fmt.Errorf

The standard library gives you two quick ways to make an error value:

import (
    "errors"
    "fmt"
)

// A fixed-message error
err1 := errors.New("something went wrong")

// An error with formatted context
name := "config.yaml"
err2 := fmt.Errorf("cannot open file %q: %w", name, err1)

fmt.Errorf with the %w verb is the standard way to add context while keeping the original error accessible. The %w is different from %v: it wraps the error, which lets errors.Is and errors.As walk the chain. Using %v just embeds the string — the chain is broken.

Always use %w (not %v) when wrapping an error with fmt.Errorf. Using %v buries the original error as plain text, so errors.Is and errors.As cannot find it later.

Sentinel errors

A sentinel error is an exported package-level var that callers can compare against. The io package uses this pattern:

// Defined in package io
var EOF = errors.New("EOF")

// Used by callers
n, err := r.Read(buf)
if err == io.EOF {
    // reached end of stream
}

Sentinel errors communicate which failure occurred, not just that a failure occurred. Define your own when the caller needs to branch on a specific condition:

var ErrNotFound = errors.New("not found")

func findUser(id int) (*User, error) {
    // ...
    return nil, ErrNotFound
}

By convention, sentinel error variable names start with Err.

errors.Is — walking the chain

Direct equality (err == ErrNotFound) breaks when the error has been wrapped. errors.Is walks the full chain:

wrapped := fmt.Errorf("loading profile: %w", ErrNotFound)
fmt.Println(errors.Is(wrapped, ErrNotFound))   // true

errors.Is calls Unwrap() on each error in the chain until it finds a match or exhausts the chain. Because of this, you should always use errors.Is rather than == when checking for sentinel errors.

errors.As — extracting a typed error

errors.As is the error equivalent of a type assertion. It walks the chain and, if it finds an error of the target type, stores it in the provided pointer:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("failed path:", pathErr.Path)
}

This is how you access fields on a concrete error type without knowing where in the chain it sits. We will build custom error types in the next lesson to see exactly why this matters.

Think of errors.Is as asking "is this specific value somewhere in the chain?" and errors.As as asking "is there a value of this type somewhere in the chain?" They are complementary, not interchangeable.

Handling vs propagating

When you receive an error, you have two choices: handle it (take recovery action and return success) or propagate it (add context and return it up the stack). Most functions do the latter:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loadConfig: %w", err)
    }
    // ...
}

Each layer wraps the error with a short prefix that names the function. When the error eventually reaches a log or user message, the full chain reads like a breadcrumb trail: "loadConfig: cannot open file "config.yaml": no such file or directory".

Check your understanding

Knowledge check

  1. 1.
    You want to wrap an existing error err with fmt.Errorf so that errors.Is can still find it. Which format verb do you use?
  2. 2.
    Using == to compare a wrapped error against a sentinel like ErrNotFound will always work correctly.
  3. 3.
    By convention in Go, sentinel error variable names should start with which prefix?

Do it yourself

Create a simple lookup(m map[string]int, key string) (int, error) that returns a sentinel ErrNotFound when the key is absent. Wrap it in a second function that calls lookup and wraps the error with fmt.Errorf. In main, use errors.Is to check for ErrNotFound.

go run main.go

Where to go next

You've seen how to create and inspect errors. The next lesson goes deeper: custom error types — structs that implement error and carry rich context for callers who want more than a string.

Finished reading? Mark it complete to track your progress.

On this page