← Concepts & practices
Pattern Errors, results, and recovery

Go’s error protocol

Which failure is this—and what detail did it carry?

A note lookup can miss, time out, or reveal a bug. Go gives the caller one ordinary return value for all of them: error. The protocol becomes useful when you ask its two different questions—errors.Is for identity and errors.As for details—and preserve the answers with %w.

The judgment to keep

Use Is to recognize a category, As to extract a typed value, and %w whenever context must not erase the chain. Treat error conventions as a contract because Go will not exhaustively list the errors a function may return.

Go One lookup · one language protocol
Start with the caller

An error is a protocol, not a diagnosis.

A note page asks for note 17. If it is missing, the UI can show an empty state. If the note service is unavailable, it can offer a retry. If an invariant broke, the owning boundary needs to record and contain it. The next action depends on what happened, not just on the fact that a function returned non-nil.

Go keeps the ordinary call visible: note, err := readNote(id). The error interface only promises an Error() string method, so the message is available immediately. A caller that needs a stable branch must use the error protocol around that string.

The protocol has two questions: is this the category I recognize, and does it contain the fields I need?

Read the smallest useful callGo · a value and an error
protocol.go · sentinel
func readNote(id int) (Note, error) {
	if id == 17 {
		return Note{}, ErrNoteMissing
	}
	return Note{ID: id, Title: "Trip notes"}, nil
}

The zero Note is ignored when err != nil. That pairing is a convention worth keeping consistent: a successful result is useful, and the error explains why the result is not.

Ask two questions

Is finds identity. As finds a value.

The canonical form starts with a sentinel. It is cheap to compare and useful when the caller only needs a category. Add context with fmt.Errorf("...: %w", err), then let errors.Is walk the chain.

The richer form gives a failure its own fields. A NoteMissingError can answer errors.Is(err, ErrNoteMissing) through a custom Is method and answer errors.As with the missing note’s ID. The two checks complement each other.

Canonical

Sentinel + errors.Is

Recognize a category through a preserved wrap chain.

protocol.go
func loadNote(id int) (Note, error) {
	note, err := readNote(id)
	if err != nil {
		return Note{}, fmt.Errorf("load note %d: %w", id, err)
	}
	return note, nil
}

func classify(err error) string {
	if errors.Is(err, ErrNoteMissing) {
		return "show empty state"
	}
	return "report unknown failure"
}

func flattenContext(id int, err error) error {
	return fmt.Errorf("load note %d: %v", id, err)
}
Twin

Typed error + errors.As

Keep the cheap category check and extract fields at the boundary.

protocol.go · typed error
type NoteMissingError struct {
	ID int
}

func (e *NoteMissingError) Error() string {
	return fmt.Sprintf("note %d is missing", e.ID)
}

func (e *NoteMissingError) Is(target error) bool {
	return target == ErrNoteMissing
}

func readRichNote(id int) (Note, error) {
	switch id {
	case 17:
		return Note{}, &NoteMissingError{ID: id}
	case 99:
		return Note{}, ErrUnavailable
	default:
		return Note{ID: id, Title: "Trip notes"}, nil
	}
}

func loadRichNote(id int) (Note, error) {
	note, err := readRichNote(id)
	if err != nil {
		return Note{}, fmt.Errorf("load note %d: %w", id, err)
	}
	return note, nil
}

func toHTTP(err error) (int, string) {
	if errors.Is(err, ErrNoteMissing) {
		var missing *NoteMissingError
		if errors.As(err, &missing) {
			return 404, fmt.Sprintf("note %d is gone", missing.ID)
		}
		return 404, "that note is gone"
	}
	if errors.Is(err, ErrUnavailable) {
		return 503, "try again later"
	}
	return 500, "unexpected failure"
}
The protocol’s two questions
NeedUseReturns
Is this the category?errors.IsBoolean recognition through the chain.
What fields does it carry?errors.AsA matching typed value to inspect.
Should context preserve either?%wAn unwrap link for Is and As.
See the complete programCopyable source plus invocation
protocol.go
package main

import (
	"errors"
	"fmt"
)

var (
	ErrNoteMissing = errors.New("note missing")
	ErrUnavailable = errors.New("note service unavailable")
)

type Note struct {
	ID    int
	Title string
}

func readNote(id int) (Note, error) {
	if id == 17 {
		return Note{}, ErrNoteMissing
	}
	return Note{ID: id, Title: "Trip notes"}, nil
}


func loadNote(id int) (Note, error) {
	note, err := readNote(id)
	if err != nil {
		return Note{}, fmt.Errorf("load note %d: %w", id, err)
	}
	return note, nil
}

func classify(err error) string {
	if errors.Is(err, ErrNoteMissing) {
		return "show empty state"
	}
	return "report unknown failure"
}

func flattenContext(id int, err error) error {
	return fmt.Errorf("load note %d: %v", id, err)
}


type NoteMissingError struct {
	ID int
}

func (e *NoteMissingError) Error() string {
	return fmt.Sprintf("note %d is missing", e.ID)
}

func (e *NoteMissingError) Is(target error) bool {
	return target == ErrNoteMissing
}

func readRichNote(id int) (Note, error) {
	switch id {
	case 17:
		return Note{}, &NoteMissingError{ID: id}
	case 99:
		return Note{}, ErrUnavailable
	default:
		return Note{ID: id, Title: "Trip notes"}, nil
	}
}

func loadRichNote(id int) (Note, error) {
	note, err := readRichNote(id)
	if err != nil {
		return Note{}, fmt.Errorf("load note %d: %w", id, err)
	}
	return note, nil
}

func toHTTP(err error) (int, string) {
	if errors.Is(err, ErrNoteMissing) {
		var missing *NoteMissingError
		if errors.As(err, &missing) {
			return 404, fmt.Sprintf("note %d is gone", missing.ID)
		}
		return 404, "that note is gone"
	}
	if errors.Is(err, ErrUnavailable) {
		return 503, "try again later"
	}
	return 500, "unexpected failure"
}


type Observation struct {
	Style      string
	Wrapping   string
	Recognized bool
	Status     int
	Body       string
	Detail     string
}

func observe(style string, wrapping string) Observation {
	var err error
	if style == "typed" {
		_, err = loadRichNote(17)
	} else {
		_, err = loadNote(17)
	}
	if wrapping == "%v" {
		err = flattenContext(17, err)
	}
	status, body := toHTTP(err)
	return Observation{
		Style:      style,
		Wrapping:   wrapping,
		Recognized: status != 500,
		Status:     status,
		Body:       body,
		Detail:     detailFor(style, wrapping, status),
	}
}

func detailFor(style string, wrapping string, status int) string {
	if wrapping == "%v" {
		return "%v kept the sentence but discarded the unwrap chain, so the boundary sees an unknown error."
	}
	if style == "sentinel" {
		return "errors.Is recognizes the sentinel through %w, but there is no typed value for errors.As to extract."
	}
	if status == 404 {
		return "A typed error answers both questions: errors.Is matches the category and errors.As extracts the note ID."
	}
	return "The error protocol keeps identity and details available to the boundary."
}

func runExample() []Observation {
	return []Observation{
		observe("sentinel", "%w"),
		observe("typed", "%w"),
		observe("typed", "%v"),
	}
}


func main() {
	for _, observation := range runExample() {
		fmt.Printf("%s with %s: recognized=%t status=%d body=%s\n", observation.Style, observation.Wrapping, observation.Recognized, observation.Status, observation.Body)
	}
}

Save it as protocol.go and run go run protocol.go. It prints the three cases the lab below compares: a sentinel wrapped with %w, a typed error wrapped with %w, and the same typed error formatted with %v.

The wrapper that looks helpful but breaks the contract%v versus %w

fmt.Errorf("load note: %v", err) makes a readable sentence but returns a new error with no unwrap link. A higher caller can no longer use errors.Is or errors.As to reach the original value. Use %v when you deliberately want text only; use %w when identity or details remain part of the contract.

Follow the chain

Same message. Different information survives.

Choose a sentinel or a typed error, then wrap it with no context, %w, or %v. The lab asks both protocol questions and shows the response a boundary could legitimately produce.

errors.Is

matched

The category matches.

A typed error can answer which category and what details it contains.

errors.As

matched

The handler can extract note ID 17.

A typed error can answer which category and what details it contains.
Boundary response 404 · note 17 is gone

Is identifies the category and As supplies the ID for a useful response.

The controls simulate the Go wrap chain locally; nothing is saved.
Read the boundary call siteGo · classify, extract, translate
protocol.go
type Observation struct {
	Style      string
	Wrapping   string
	Recognized bool
	Status     int
	Body       string
	Detail     string
}

func observe(style string, wrapping string) Observation {
	var err error
	if style == "typed" {
		_, err = loadRichNote(17)
	} else {
		_, err = loadNote(17)
	}
	if wrapping == "%v" {
		err = flattenContext(17, err)
	}
	status, body := toHTTP(err)
	return Observation{
		Style:      style,
		Wrapping:   wrapping,
		Recognized: status != 500,
		Status:     status,
		Body:       body,
		Detail:     detailFor(style, wrapping, status),
	}
}

func detailFor(style string, wrapping string, status int) string {
	if wrapping == "%v" {
		return "%v kept the sentence but discarded the unwrap chain, so the boundary sees an unknown error."
	}
	if style == "sentinel" {
		return "errors.Is recognizes the sentinel through %w, but there is no typed value for errors.As to extract."
	}
	if status == 404 {
		return "A typed error answers both questions: errors.Is matches the category and errors.As extracts the note ID."
	}
	return "The error protocol keeps identity and details available to the boundary."
}

func runExample() []Observation {
	return []Observation{
		observe("sentinel", "%w"),
		observe("typed", "%w"),
		observe("typed", "%v"),
	}
}

The boundary checks the category before it tries to read rich details. A sentinel-only error can still become a useful 404; it cannot include a note ID it never carried.

Make the boundary explicit

Choose the question before choosing the helper.

A good error handler knows whether it needs a category, details, or both. The protocol is small on purpose; the discipline is deciding what the next caller is allowed to depend on.

A wrapped error should still match ErrNoteMissing.
The HTTP body needs the missing note’s ID.
Add context without breaking the caller’s matching code.
Feedback stays on this page; it is not saved.
A production boundary

Translate the protocol once, where the contract changes.

Inside the service, a package can own a sentinel or a typed error. At the HTTP boundary, map known cases to status and body, and keep the original error for logs. The frontend should receive a response contract, not a Go error string and not a promise that a pointer type traveled over the network.

Unknown errors stay unknown. A default 500 is not a failure to classify; it is an ownership decision that prevents a defect from being presented as a missing note or a safe retry.

Package

Wrap with context

Preserve identity and details with %w.

Handler

Is, then As

Recognize the category and extract only fields the response owns.

Client

Render a contract

Use status and validated data; never parse the original sentence.

Build UIs?The UI consumes the translated result of the error protocol.

Where it already is in your components

A page with empty, retry, and generic states is already a consumer of error categories. Keep those cases in the request layer so every button handler does not invent its own string match.

When you have to own it

When Go serves a browser or another service, define the wire code and payload at the handler. When the UI needs a field such as a note ID, make that field part of the typed error or the translated response; errors.Is alone cannot provide it.

Recognize it elsewhere

The protocol is small enough to travel through a codebase.

os.IsNotExist

The standard library’s file errors are a familiar example of category recognition. A caller can ask whether a path is absent without parsing the operating system’s sentence.

context.Canceled

Cancellation is a sentinel-shaped category with a clear caller action: stop work and avoid turning cancellation into an alarming generic failure.

fmt.Errorf

Context belongs near the operation that has useful names and IDs. The %w verb keeps that context additive instead of destructive.

Read Go’s error-wrapping design notes ↗
The parts to watch

Good protocol code still needs boundaries.

Is and As are not interchangeable

errors.Is answers a boolean category question. errors.As takes a pointer to a target and fills it when a value in the chain has the requested type. Use the first for sentinels; use the second for fields.

Wrapping is part of the API

Changing %w to %v is not only a wording change. It changes what callers can discover above that function. Treat the decision like any other contract change.

A custom Is method needs a narrow rule

A typed error can report that it matches a sentinel, but the rule should describe a real category. A broad “everything is not found” method makes the boundary lie and makes debugging more difficult.

Go does not give you exhaustiveness

The compiler checks that a value implements error; it does not check every concrete error a function might return. Document conventions, test boundary mappings, and keep the recognized categories small enough to review.

Make the call

Use the simplest protocol that preserves the next decision.

Use a sentinel when identity is the whole contract. Add a typed error when a boundary needs fields. Wrap with %w while context is useful and the caller may need to recognize what is underneath. At the public edge, translate once and stop exposing private error shapes.

Category only

Start with a sentinel.

Let errors.Is keep the branch stable.

Category plus fields

Add a typed error.

Keep Is compatibility and use As for details.

Service boundary

Map to a response.

Return a public code and payload; preserve unknown failures internally.

Take the idea with you

Ask “which error?” before asking “what text?”

Go’s error protocol is deliberately less magical than a checked union. That leaves room for conventions, but it also leaves the design responsibility with the package and the boundary: preserve what callers are allowed to know, and no more.

Why
Give the caller a stable recovery category.
What
Use Is for identity, As for details, and %w for preserved context.
Constraint
The package that returns the error owns its sentinels and types.
Fallback
An error neither check recognizes stays a generic 500.
Reconsider when
The internal error shape becomes a public wire contract.
Connections to follow nextRelated lessons
Explore more concepts & practices →