Errors
Go's most distinctive design decision. Verbose, explicit, and the reason generated Go rarely swallows a failure.
An error in Go is just a value implementing one method:
type error interface {
Error() string
}That is the entire mechanism. No exceptions, no stack unwinding, no invisible control flow.
The basic shape#
func loadUser(id string) (*User, error) {
row, err := db.Query(id)
if err != nil {
return nil, fmt.Errorf("load user %s: %w", id, err)
}
return row, nil
}Add context at every level and you get a message that describes the whole path:
handle request: load user 42: query users: connection refused%w versus %v#
fmt.Errorf("load user: %w", err) // wraps — errors.Is / errors.As can see through
fmt.Errorf("load user: %v", err) // formats — the chain is brokenThe printed message is identical. The difference only appears when a caller tries to inspect the error, which is why this bug survives review. errorlint in golangci-lint catches it.
Sentinel errors#
var ErrNotFound = errors.New("not found")
func find(id string) (*User, error) {
if !exists(id) {
return nil, fmt.Errorf("find %s: %w", id, ErrNotFound)
}
...
}
if errors.Is(err, ErrNotFound) { // works through any depth of wrapping
return http.StatusNotFound
}Use errors.Is, never ==. A direct comparison fails as soon as anything in the chain wraps the error, and the failure is silent.
Custom error types#
When callers need data, not just identity:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %s: %s", e.Field, e.Reason)
}
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("bad field: %s", ve.Field)
}errors.As finds the first error in the chain of that type and assigns it.
Joining errors#
err := errors.Join(err1, err2, err3) // nil entries are ignoredUseful for validating many fields and reporting all the failures at once, rather than making the user fix them one at a time. errors.Is works across all of them.
Handle once#
The most common style mistake is logging an error and also returning it, at every level. The result is one failure reported five times.
// wrong — logged here and again by every caller
if err != nil {
log.Printf("failed: %v", err)
return err
}
// right — add context, return, let the top of the stack decide what to do
if err != nil {
return fmt.Errorf("load config: %w", err)
}Log where you handle. Wrap everywhere else.
Ignoring an error#
Explicit, and greppable:
_ = f.Close() // deliberate: read-only file, close error is not actionableThe blank identifier is the only way to discard an error, which means every discarded error is visible in a diff. errcheck will flag the ones you did not mark.
When to panic#
Almost never in library code. Panic is for programmer errors that make continuing meaningless — an impossible state, a failed invariant at startup.
var tmpl = template.Must(template.ParseFiles("index.html")) // fine: startup, unrecoverableRecovering from a panic is for the top-level boundary of a server, so one bad request does not take down the process — and even then, log it and treat it as a bug.
Common questions#
Is if err != nil not exhausting?#
It is verbose, and it makes every failure path visible at the point it can happen. That trade looks better the more code you are reviewing rather than writing — you can see what happens when something fails without leaving the function.
Should every error be wrapped?#
Wrap when you can add context the caller does not have — which id, which file, which operation. Do not wrap just to prepend the function name; that adds noise without information.
Sentinel error or custom type?#
Sentinel when callers only need to know which error it is. Custom type when they need data from it. Both work through errors.Is and errors.As at any wrapping depth.
Get the Go agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.