Errors are values: putting failure in the signature
Most languages handle failure with a hidden second control-flow path — an exception that unwinds the stack from somewhere the caller never sees. Go made the opposite choice: an error is an ordinary return value of an ordinary type, handled with ordinary code. This lesson shows why that choice is a direct application of the team-up bargain, how it is built entirely out of the one-method interface you met in Lesson 05, and how to wrap, inspect, and classify errors without ever inventing an exception system. The famous if err != nil is the tax you pay to make the unhappy path visible in the type — and visibility, here, is the whole product.
if err != nil where another language has zero — but it is paid by the writer, in lines, not by the reader, in archaeology. That is the team-up trade in its purest form.error is nothing more than a one-method interface; everything here is just Lesson 05 applied to failure.New capability: design APIs whose failure modes are visible in their signatures; wrap errors to build a debuggable chain without losing the original; and tell callers apart with
errors.Is/errors.As — plus know precisely why panic is not an exception and when (rarely) to reach for it.error is just an interface, and that "return and check" falls straight out of that. (3) Build the real toolkit: wrapping with %w, sentinel vs typed errors, and errors.Is/errors.As. (4) Draw the hard line between errors (expected, in the signature) and panic (bugs, not a control-flow tool) — and account honestly for what the verbosity costs.1 · The problem with the invisible second path
Consider a function in an exception-based language with the signature User loadUser(int id). Read it cold: how can it fail? The signature says it returns a User — and lies, because it can also throw. To learn what it can throw you must read its body, then read everything its body calls, then everything those call, all the way down — because an exception thrown five frames deep sails up through every intermediate function that didn't catch it. The failure modes are real, but they are not in the type. They are scattered across a call graph, and the only honest way to know them is to have read the whole graph or to have been burned in production.
That is precisely the cost Lesson 00 said Go was built to eliminate: complexity the reader pays for understanding code they didn't write. Three things make exceptions expensive at team scale, and Go's designers named all three:
User; the truth is "a User, or a stack-unwind to whoever catches." You cannot see a function's failure modes by reading its declaration — the single place a reader most wants to trust.throw deep in a library can resume execution in a catch in main, skipping every frame between. To reason about what runs after a call, you must know what every callee might raise — the opposite of local reasoning.Go inverts each. Failure is a value of type error, returned alongside the result, so it sits in the signature. Control flow stays local — a function returns to its caller, always, with no hidden resumption. And the default flips: ignoring a returned error is something you must do on purpose — linters like errcheck flag an error you ignore implicitly, and an explicit _ is the sanctioned opt-out (errcheck only flags those blank discards under its opt-in -blank mode). The unhappy path stops being an afterthought and becomes part of the design you are forced to look at.
2 · error is just an interface — so "return and check" is inevitable
There is no special "error machinery" in Go. The entire mechanism is one interface in the standard library:
type error interface {
Error() string
}
That is the whole definition. By Lesson 05, anything with an Error() string method is an error, implicitly, no declaration needed — and an error value is a (type, data) pair just like any interface value. The conventional last return slot is therefore not a language feature; it is an idiom built on a plain interface:
func loadUser(id int) (*User, error) {
row, err := db.Query(id)
if err != nil {
return nil, err // failure is returned, not thrown
}
return parse(row) // parse also returns (*User, error)
}
// the caller is forced to look at it:
u, err := loadUser(42)
if err != nil {
return fmt.Errorf("loading user 42: %w", err)
}
use(u)
Two consequences follow from "it's just an interface," and both matter. First, the nil error means success convention: the zero value of any interface is nil (Lesson 05's (type, data) pair with both halves empty), so checking err != nil is checking "did the callee put a concrete error type in there?" Second — and this is the trap Lesson 05 warned about — a typed nil is not a nil interface. Returning a *MyError that happens to be nil through an error return makes a non-nil interface value (its type half is set), so err != nil is true even though "nothing went wrong":
func bad() error {
var e *MyError = nil // typed nil pointer
return e // interface gets type=*MyError, data=nil -> NOT nil!
}
// if err := bad(); err != nil { ... } // this branch RUNS. classic bug.
The fix is to return the literal nil for success, never a typed nil pointer through an error slot. This is not an error-handling quirk — it is the interface model of Lesson 05 showing through, which is exactly why we put interfaces before errors in the spine.
How cheap is the happy path? An error return is one extra interface-sized result — two machine words (16 bytes on 64-bit): a type pointer and a data pointer. When the error is nil, both words are zero; the if err != nil check is a single pointer comparison against zero. There is no exception table, no stack-unwinding metadata, no cost paid only on the rare failure. You pay a couple of words and one branch on every call — flat, predictable, the bargain's "small and known" price made literal.
3 · Wrapping: a debuggable chain without a stack trace
Returning the raw error upward loses context: "connection refused" tells you nothing about what was trying to connect or why. The instinct from exception-land is "attach a stack trace." Go's answer is smaller: each layer that returns an error wraps it with one sentence of context, building a chain that reads like a sentence from the outside in.
The tool is fmt.Errorf with the %w ("wrap") verb, added in Go 1.13. %w records the wrapped error so it can be recovered later; %v would only format its text and discard the link.
// each layer adds context and preserves the cause:
return fmt.Errorf("loading user %d: %w", id, err)
// prints as: loading user 42: query failed: connection refused
// \____outer____/ \__middle__/ \____root____/
The result is a linked chain of errors — the human-readable assembly of the messages serves the role a stack trace plays elsewhere, but it is built from the semantic path ("loading user → query → connect") rather than the call stack, and it is composed deliberately by the code rather than captured automatically. The chain is walkable: errors.Unwrap peels one layer, and the errors.Is/errors.As functions below walk it for you.
4 · Telling failures apart: sentinel vs typed errors
A caller often needs to react differently to different failures: a missing key is fine, retry; a permission denied is fatal, stop. Comparing error strings is fragile (a reworded message breaks callers). Go offers two stable patterns, and the choice between them serves question 3 — how does this fail, and what does the caller need to react to the failure? — by distinguishing whether the caller needs an identity or needs data.
var ErrNotFound = errors.New("not found"). Callers compare against the one canonical value. Carries no extra data — it is a named constant-like marker. Examples: io.EOF, sql.ErrNoRows.Error(), e.g. type ValidationError struct { Field string; Msg string }. The caller recovers the value to read the field that failed, not just the fact that validation failed.Both must survive wrapping — a caller five layers up should still recognize "this was, at root, a not-found." That is what errors.Is and errors.As do: they walk the %w chain from §3 so you compare against the cause, never the topmost wrapper.
// errors.Is — does any error in the chain equal this sentinel?
if errors.Is(err, sql.ErrNoRows) {
return defaultUser, nil // expected; recover
}
// errors.As — is any error in the chain THIS type? if so, bind it:
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("bad field %q: %s", ve.Field, ve.Msg) // read its data
}
if err == ErrNotFound breaks the moment any layer wraps the error with %w — it compares only the outermost value. err.(*ValidationError) (a bare type assertion, Lesson 06) does the same and panics on the wrong type. strings.Contains(err.Error(), "not found") couples callers to message wording. Always use errors.Is/errors.As; they walk the chain and never panic.| You want to… | Use | Why |
|---|---|---|
| Add context, keep the cause | fmt.Errorf("...: %w", err) | %w links; %v would flatten to text and break Is/As |
| Match a known sentinel | errors.Is(err, ErrFoo) | walks the chain; survives wrapping |
| Recover a typed error's data | errors.As(err, &target) | walks the chain; binds the value; never panics |
| Create a simple marker | errors.New("...") | cheapest error; identity only, no data |
| Deliberately discard a failure | v, _ := f() | explicit _ — you chose to ignore it, on the record |
5 · panic is not an exception
Go does have a stack-unwinding mechanism — panic unwinds, running deferred functions, and recover (only meaningful inside a defer) can stop it. It looks like exceptions, so people reach for it as exceptions. Resist that. panic is for bugs and impossible states — programmer errors the program cannot sensibly continue past — not for expected failures a caller should handle. The dividing question is exactly question 3: is this a failure the caller can anticipate and reasonably respond to? Then it is an error in the signature. Is it a violated invariant that means the code is wrong? Then it is a panic.
- File not found, network refused, bad user input, timeout, key missing.
- The caller can and should decide what to do.
- Returned and checked; never unwinds the stack.
- This is the default; reach for it ~always.
- Index out of range, nil-map write, "this switch case is unreachable."
- The program is in a state its author swore couldn't happen.
- Unwinds, running
defers; crashes the process if not recovered. - Rare. The legitimate
recoveruse is a server keeping one bad request from killing every other goroutine — convert the panic to an error at the boundary and log it.
Why does the standard library follow this so strictly? Because a panic used as control flow reintroduces the exact invisible second path Go deleted in §1 — failure that leaves the signature and resumes nonlocally. Using panic for ordinary errors is smuggling exceptions back into a language that paid real costs to remove them. The mechanism exists for genuine "this should be impossible" moments and for the one boundary pattern (recover at the top of a request handler); everywhere else, the error value is the answer.
if err != nil { return ... }, and reading a Go file you skim past a lot of it. Go 1.13's wrapping and errors.Is/As improved composition but not the line count, and a proposed try/?-style shorthand was rejected precisely because hiding the check would re-hide the failure. You are trading writer-side keystrokes for reader-side visibility — that is the bargain, stated as a tax you can feel in every file.Checkpoint exercise
errors.Is; one that carries data ("field X invalid") becomes a typed error read with errors.As; and each layer wraps with %w so the cause survives. Finally, find any place you'd be tempted to panic (or throw) for an expected failure and convert it to a returned error — then check: is anything left that should panic? If so, it's a real invariant violation, and that's the correct use.Where this points next
We have now finished the composition movement: values (02), methods (04), interfaces (05), embedding (06), and failure-as-a-value (07) are all built on the same small, explicit machinery. The next movement is the one Go is famous for — concurrency — and it leans on everything you just learned. Lesson 08 introduces the goroutine and the G-M-P scheduler: what makes a goroutine cost ~2 KB instead of an OS thread's ~1 MB, why a million of them is fine, and what you pay for that. Errors stay central there: a function running in a goroutine can't return an error to a caller that has already moved on, so "how does a concurrent failure get back into someone's signature?" becomes the live question — and channels (Lesson 09) will be the answer.
error is an ordinary value returned alongside the result and handled with ordinary code. The entire mechanism is one interface — type error interface { Error() string } — so everything you know from Lesson 05 applies, including the typed-nil-is-not-nil trap; the happy path costs only two words and one branch, with no exception tables. You build a debuggable chain by wrapping with %w, distinguish failures with sentinel errors (identity, via errors.Is) or typed errors (data, via errors.As) that both survive wrapping, and reserve panic for genuine bugs because using it as control flow smuggles exceptions back in. The transferable habit is question 3 — how does this fail, and is the failure in the signature? — and the principle is the bargain at its barest: pay the writer-side verbosity tax to buy the reader the one thing exceptions stole, the ability to see how code fails by reading its declaration.Interview prompts
- Why did Go reject exceptions? (§1 — exceptions move failure out of the signature, make control flow nonlocal, and default to silent propagation; all three are complexity the reader pays for, so they failed the "complexity must be earned" half of the bargain.)
- What is an
errorin Go, mechanically? (§2 — just the one-method interfaceinterface { Error() string }; an error value is a (type, data) pair like any interface, so "return and check" and "nil means success" fall straight out of Lesson 05's model.) - A function returns a typed nil pointer through an
errorreturn and the caller'serr != nilfires anyway — why? (§2 — the interface's type half is set even though the data half is nil, so the interface value is non-nil; the fix is to return the literalnil, never a typed nil pointer.) - What does
%wdo that%vdoesn't, and why does it matter? (§3 —%wrecords the wrapped error so the cause is recoverable and the chain is walkable byerrors.Is/As;%vflattens to text and severs the link, breaking matching.) - Sentinel vs typed error — when each? (§4 — sentinel when the caller only needs identity ("was this not-found?", like
io.EOF), checked witherrors.Is; typed when the failure carries data the caller must read, recovered witherrors.As.) - Why use
errors.Is/Asinstead of==or a type assertion? (§4 —==and bare assertions only inspect the outermost error and break once any layer wraps with%w(and the assertion panics on a mismatch);Is/Aswalk the whole chain and never panic.) - When is
panicappropriate, and why isn't it Go's exception system? (§5 — only for bugs/impossible states (and a recover-at-the-request-boundary pattern); using it for expected failures reintroduces the invisible nonlocal path Go deliberately removed, so it's smuggling exceptions back in.)