Errors are values: wrapping, sentinels, and the if err != nil discipline
Go errors are plain values: handle every err or discard it on purpose. Wrap with %w so errors.Is/As survive layering — == breaks the moment anyone wraps. Pick sentinel, typed, or opaque errors by how much callers must couple. panic is for bugs; swallowed errors detonate far away.
A user-profile API had run for a year on one comparison: if err == sql.ErrNoRows meant “no profile, return 404.” Then a teammate did the right thing — added context to errors in the repository layer: return fmt.Errorf("get profile %d: %w", id, err). Tests passed; the wrapping even made logs nicer. In production, every request for a missing profile started returning 500. The == check compared a wrapped error against the bare sentinel, got false, and the handler fell through to “database failure.” Alerting paged at 2 a.m. for what was actually “user typed a wrong username.” The fix was one token — errors.Is(err, sql.ErrNoRows) — but the lesson is structural: the moment any layer wraps, identity comparison dies, and Go’s error chain only works if every layer agrees to walk it.
Errors are values, and that’s a design position
When you first write Go after a language with exceptions, the if err != nil blocks feel like noise. They’re not — they’re the entire point. In Go, error is just an interface — one method, Error() string — and an error is an ordinary value you return, inspect, store in a slice, or compare. The if err != nil ritual that newcomers mock is the point: every failure path is visible in the code that takes it. Go rejected exceptions deliberately. With exceptions, any line can transfer control to a handler pages away, so reading a function tells you nothing about its exit paths; with error values, control flow is exactly what’s written. The cost is repetition — if err != nil { return ... } everywhere — and Go accepts that cost on purpose: boring, explicit failure handling reads the same in every codebase, and the three lines are where you add context, not just bounce the error upward.
func loadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %q: %w", path, err)
}
cfg, err := parse(raw)
if err != nil {
return nil, fmt.Errorf("parse config %q: %w", path, err)
}
return cfg, nil
}Each return annotates the error with what this layer was doing, so the final message reads like a causal chain: load config "/etc/app.yaml": open /etc/app.yaml: permission denied. No stack trace needed — the chain is the trace, in domain language.
Wrapping: %w builds a chain, errors.Is and errors.As walk it
fmt.Errorf with the %w verb doesn’t just format — it stores the wrapped error, giving the new error an Unwrap() error method. Stack enough layers and you have a linked list of errors. Two functions traverse it: errors.Is(err, target) walks the chain asking “is any link this exact value?” — the wrap-safe replacement for ==, and the one-token fix for the Hook’s 500s. errors.As(err, &target) walks the chain asking “is any link this type?” and copies the match out, so you can reach typed data (a *pgconn.PgError code, a *os.PathError path) without knowing how many layers wrapped it.
_, err := repo.GetProfile(ctx, id)
if errors.Is(err, sql.ErrNoRows) { // survives any number of %w wraps
return nil, ErrProfileNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return nil, ErrDuplicate // typed data, extracted through the chain
}The trap is %v vs %w: %v flattens the error into text and discards the chain — errors.Is returns false through it. Use %w when callers may need to inspect the cause; use %v deliberately when you want to seal the error at a boundary so callers can’t couple to internals. That choice is API design: every %w makes the wrapped error part of your contract — change sql.ErrNoRows to a different driver later and callers’ errors.Is checks silently stop matching.
Sentinel vs typed vs opaque: three coupling levels
There are three idioms, ordered by how much the caller must know. Sentinel errors — package-level values like io.EOF, sql.ErrNoRows, or your own var ErrNotFound = errors.New("not found") — are cheap (a pointer compare via errors.Is) and let callers branch on identity, but they’re permanent public API and carry no data. Typed errors — structs implementing error, like *os.PathError — carry fields (which path? which code?) and are matched with errors.As, at the cost of exporting a type callers now construct expectations around. Opaque errors — just return error, expose nothing — minimize coupling: callers can only log or propagate, and you stay free to change everything behind the message. The senior default is opaque-first: export a sentinel or a type only when a caller demonstrably needs to branch on that failure, because every exported error is a contract you’ll maintain forever.
▸Why this works
Why does Go not capture stack traces in errors by default? A stack trace tells you where the error surfaced; a wrap chain tells you what the program was trying to do — “load config /etc/app.yaml: open: permission denied” is readable by an operator who has never seen the code. Traces also cost: capturing one means walking the stack at error-creation time, which hurts on hot paths where errors are expected (io.EOF fires on literally every completed read — tracing it would tax every file copy in the program). Go’s bet is that errors are values cheap enough to use for ordinary control flow, with context added explicitly where it’s meaningful. When you do need a trace — truly unexpected failure — that’s what panic prints for free.
panic is for bugs; the real killer is the swallowed error
panic unwinds the stack running deferred functions until recover stops it — or the process exits. Its job is unrecoverable programmer error: index out of range, nil-map write, “this branch is impossible.” It is not a control-flow channel for expected failures — a missing row is not a panic. Use recover only at goroutine boundaries — an HTTP server’s per-request middleware, a worker-pool wrapper — to convert “one buggy request” into a 500 instead of a dead process. And the detail that kills services: recover only works inside a deferred function of the panicking goroutine. A panic in a goroutine you spawned with go ignores every recover in the parent — it crashes the whole process. Every go func() that runs third-party or request-dependent code needs its own deferred recover.
The most common production failure isn’t a panic at all — it’s the swallowed error:
data, _ := fetch(ctx, key) // err discarded: data is the ZERO VALUE
process(data) // nil-deref or empty result, far from the cause
if err := step(); err != nil {
log.Printf("step failed: %v", err)
// ...and execution continues as if it succeeded
}Discard with _ and the zero value flows downstream, detonating a nil dereference or a silently empty response in code that looks innocent — the crash site and the cause are in different files. Log-and-continue is subtler: the function keeps running with broken state, and the log line scrolls past unread. The discipline: every err is either handled (branched on, compensated), returned with %w context, or — rarely — discarded with _ plus a comment saying why that’s safe. Nothing else.
A repository's GetUser function wraps sql.ErrNoRows at the data layer. The service layer needs to tell 'no such user' from a real database error. Pick the right error idiom.
A handler checks err == sql.ErrNoRows to return 404. The repository layer is updated to return fmt.Errorf("get user: %w", err). What happens to missing-row requests?
An HTTP server has recovery middleware (deferred recover) around every handler. A handler spawns go enrichAsync(req), and that goroutine panics on a nil map write. What happens?
- 01How does error wrapping work mechanically, and why does == break while errors.Is keeps working?
- 02When do you use sentinel, typed, and opaque errors, and what are the rules for panic/recover?
Go treats errors as ordinary values returned from functions, and the if err != nil ritual is a deliberate design position: every failure path is written where it happens, in exchange for repetition that doubles as the place to add context. Wrapping with fmt.Errorf and the %w verb stores the cause and exposes it through Unwrap, building a causal chain that reads like a trace in domain language; errors.Is walks that chain to match sentinel values — the wrap-safe replacement for ==, whose failure under wrapping turned a repository-layer cleanup into production 500s — and errors.As walks it to find and extract typed errors with data, like a driver error code. The %v verb formats but severs the chain: a bug when callers still need the cause, a legitimate sealing move at API boundaries. Three idioms order the coupling: opaque errors keep callers at arm’s length and are the default; sentinels like sql.ErrNoRows let callers branch on identity but are forever-API carrying no data; typed errors carry fields at the cost of exporting a type. panic exists for unrecoverable programmer bugs, never expected failures; recover belongs only at goroutine boundaries such as per-request middleware, and it cannot cross goroutines — a panic in a spawned goroutine ignores the parent’s middleware and kills the process, so every go func running risky code carries its own deferred recover. The commonest production wound is self-inflicted: an error discarded with underscore or logged-and-ignored sends a zero value downstream to detonate far from the cause. Every err is handled, returned wrapped, or discarded with a written justification. Now when you see err == somePackage.ErrFoo in a code review, you know the question to ask: has any layer between the origin and this check added a %w wrap? If yes, that comparison is already broken.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.