1. Introduction
When you write small programs, it feels like it is enough to return fmt.Errorf("something went wrong") and print it in main. But as soon as the application has at least two layers (for example, "reading input" and "command handling logic"), errors start traveling upward. Without a system, they turn into a set of phrases where the program's behavior is determined by how you happened to word the text once.
The problem is not that the text is bad. The problem is that the code starts doing "magic" checks like strings.Contains(err.Error(), "not found"). This breaks at the first refactor of the wording, when translating to another language, or simply when a developer changes their mind. In Go, the standard library itself hints: if external code must distinguish situations, it should be a checkable contract, not guessing by a string.
Moreover, when you wrap errors via %w, you effectively make the internal cause part of the API — and that needs to be managed deliberately.
Four error classes: validation / notfound / io / internal
An error taxonomy is a small set of categories that the top level of an application can distinguish and make decisions by. Important: it is not "a thousand types for every sneeze"; on the contrary, it is an attempt to agree on a minimum. Imagine you are writing a doorman bot: it does not care what exactly broke down below; it cares whether to "let the person pass" and what to say.
We will fix 4 classes that often cover most applied scenarios well enough:
| Class | What it means semantically | What the top level typically does |
|---|---|---|
|
input data is invalid/incomplete | tell the user "fix the input", often without a detailed stack |
|
the requested object was not found | report "not found" (this is not a "system error") |
|
an input/output error | report "could not read/write", sometimes suggest retrying |
|
an unexpected problem inside the program | report "something broke", keep details for diagnostics |
The point is that these classes reflect behavior, not "where exactly it broke". At the top level (CLI/server/script), a program typically prints different messages and/or chooses different exit codes based on these coarse categories.
2. Implementation in Go: sentinel, Join, and wrapping
Sentinel errors as "labels"
So that a class can be checked via errors.Is, the most convenient approach is to define sentinel errors—values that live in one place and are used as targets for checks. It is important not to confuse this: we do not want to return them as the only error "with nothing else". We want them to be labels that can be found inside the chain of causes.
Let's make a base set:
package app
import "errors"
var (
ErrValidation = errors.New("validation error")
ErrNotFound = errors.New("not found")
ErrIO = errors.New("i/o error")
ErrInternal = errors.New("internal error")
)
We almost never show the text of these errors directly to anyone—it is mostly there so printing is not completely empty. The real value is that ErrValidation is the same value, and errors.Is(err, ErrValidation) will be a stable check.
Note an important idea: if you "create the same error" inside a function via errors.New("validation error"), it will not be considered the same one. Identical text does not make errors "equivalent in meaning" for code— the values will be different.
Class, context, and root cause
One of the most useful habits in Go is to separate roles. An error that bubbles up often must simultaneously:
- give the upper code a class (to choose behavior),
- give a human context (to understand what was being done),
- preserve the root cause (to avoid losing diagnostics).
And this can be done without "magic" and without custom error types—just by combining errors.Join and wrapping.
The idea is: first assemble "what matters for code" (class + cause), then add "what matters for humans" (operation context).
Schematically, it looks like this:
flowchart TD
A["low level: root cause (err)"] --> B["Join: ErrValidation + err"]
B --> C["wrap: 'parse age: %w'"]
C --> D["top level: errors.Is(..., ErrValidation)"]
This logic fits perfectly with how Go describes wrapping: by wrapping an error via %w, you preserve the cause for inspection via standard mechanisms.
Example: validation in parseID
Imagine we are writing a simple console utility (let it be our educational "mini task manager"), where the user enters a command and a task ID. At this stage of the course we still do not have structs and a complex model, so a task is just a string in a slice, and the ID is an index (a bit later we will make it nicer, but errors matter now).
The parseID function must distinguish "bad input" from everything else.
package app
import (
"errors"
"fmt"
"strconv"
)
func parseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse id: %w", errors.Join(ErrValidation, err))
}
if id < 0 {
return 0, fmt.Errorf("parse id: %w", ErrValidation)
}
return id, nil
}
There is a small trick here: in the branch with Atoi we preserve the root cause (the parsing error) via errors.Join. This means that at the top level you can do both a "class-based" check and, if you want, extract detailed cause information (you do not have to, but you can).
Example: notfound in getTask
Now let's write a function that "looks up a task" by ID. Since everything is still simple, a task is a string in a slice.
package app
import "fmt"
func getTask(tasks []string, id int) (string, error) {
if id < 0 || id >= len(tasks) {
return "", fmt.Errorf("get task %d: %w", id, ErrNotFound)
}
return tasks[id], nil
}
The key meaning: notfound is not necessarily a "system error". It is often a normal situation—just the result of a query: "there is no such thing". But the top level still must distinguish it from internal, because user messages and reactions will be different.
Example: io in readCommand
The IO class usually means "the outside world did not let us perform the action": reading input, writing output, working with the network/file, etc. At this stage of the course we are not working with files yet, but we already read from stdin. That means we can theoretically get an input error (for example, the stream is closed, unexpected format, environment issues).
Let's write a function that reads two words: a command and an argument. If reading failed, it is ErrIO. If the command is unknown, it is ErrValidation (invalid input).
package app
import (
"errors"
"fmt"
)
func readCommand() (string, string, error) {
var cmd, arg string
_, err := fmt.Scan(&cmd, &arg)
if err != nil {
return "", "", fmt.Errorf("read command: %w", errors.Join(ErrIO, err))
}
return cmd, arg, nil
}
Here we use the same combination again: class (ErrIO) + root cause (err) + context ("read command"). This allows the top level to avoid guessing what happened: "is the user's input invalid" or "was it impossible to read the data at all".
Example: internal in firstTask
internal is the most unpleasant class, but it is also the most honest. It means the program encountered a situation the developer did not account for as a normal behavior branch. For example, you assumed the task list is always initialized, but for some reason it is nil (or you received a weird state from another layer). At this stage of the course we are not building complex multilayer applications, but even here we can demonstrate the idea: internal is when "it is not the user's fault".
Let's make an artificial example: a function expects tasks to be non-empty, otherwise it treats it as an unexpected situation (roughly, "there should have been at least one default task").
package app
import "fmt"
func firstTask(tasks []string) (string, error) {
if len(tasks) == 0 {
return "", fmt.Errorf("first task: %w", ErrInternal)
}
return tasks[0], nil
}
Why not validation? Because the user might have nothing to do with it: it might be a bug in initialization code. The top level must be able to say: "this is a program failure," not "you entered something wrong."
3. Top level: choose behavior via errors.Is
Now let's assemble a small "error renderer"—a function that turns an error into a message. We are not discussing logging, exit codes, and other grown-up things yet. It is enough to demonstrate the main idea: choose behavior via errors.Is.
package main
import (
"errors"
"fmt"
"example.com/course/app"
)
func printUserMessage(err error) {
if errors.Is(err, app.ErrValidation) {
fmt.Println("Invalid input. Check the command and arguments.") // UX example
return
}
if errors.Is(err, app.ErrNotFound) {
fmt.Println("Not found.") // calm tone
return
}
if errors.Is(err, app.ErrIO) {
fmt.Println("I/O error. Please try again.") // external environment
return
}
fmt.Println("Internal application error.") // internal and everything else
}
The meaning of this function is fairly "psychological": the user should understand what exactly is required from them. With validation—fix the input. With notfound—maybe choose a different ID. With io—maybe retry or check the environment. With internal—the user usually cannot do anything (except "try again"), and it is also useful to communicate that honestly.
Nuance: wrapping makes the cause part of the API
Sometimes you want to wrap everything via %w, because "well, this is correct, we do not lose the cause". But there is a downside: if the upper-level code starts checking the specific root cause (for example, an error from another library), you will become "locked in" to that detail.
Go explicitly warns: if you are not ready to promise clients that an error from database/sql will be visible externally, you should not expose it through wrapping—otherwise it becomes part of the contract.
In taxonomy terms, this means a practical rule: you should almost always expose the class (validation/notfound/io/internal) and keep the root cause for diagnostics, but not encourage external code to build logic on it. The simplest approach is: let external code check only ErrValidation and friends, and treat everything else as "text and context for a human."
4. Typical mistakes when designing a taxonomy
Mistake #1: turning the taxonomy into an encyclopedia.
Beginners often start with the right idea ("let's classify errors") and then make 37 classes: ErrEmptyName, ErrNameTooShort, ErrNameContainsEmoji, ErrNameContainsEmojiButOnlyOnTuesdays... As a result, the top level still does not understand what to do with it, and the code sprawls. A taxonomy should answer the question "which behavior to choose," not "which exact check failed."
Mistake #2: mixing "class" and "context" in a single string.
When people write return fmt.Errorf("validation error: name must not be empty"), it looks fine. But for code it is just a string. If you want the top level to distinguish validation from internal, the class must be checkable via errors.Is, and the context must be human-readable text layered on top of that class.
Mistake #3: checking the class via err.Error() or strings.Contains.
This works exactly until the first change in wording. Today you wrote "not found", tomorrow you want "task not found", and the day after you add localization. String-based checks turn refactoring into a minefield. Checking via errors.Is(err, ErrNotFound) survives such changes calmly because it relies on values, not text.
Mistake #4: creating sentinel errors inside functions.
If you do errors.New("not found") every time in getTask, you get new values that will not match what external code checks. A sentinel should be a single value at the package level and used as a "target" for errors.Is.
Mistake #5: wrapping other people's errors via %w without thinking and thereby exposing implementation details.
Wrapping is an API decision: do you want to let external code reach the root cause? If you wrap everything via %w, external code can start checking specific errors from your internal libraries, and you will block your own ability to change things. Go emphasizes separately that this is a contractual decision: sometimes you should wrap, and sometimes, наоборот, you should intentionally not expose internals.
GO TO FULL VERSION