Lesson 1 of 5
Errors Are Values
"Values can be programmed, and since errors are values, errors can be programmed." — Rob Pike, "Errors are values" (The Go Blog, 2015)
No decision in Go has been argued about more than this one. A function that can fail returns its failure as an ordinary return value, and the caller checks it with an if. There is no throw, no catch, no stack unwinding to a handler three frames up. The result is the most recognisable line in the language — if err != nil — and the most common complaint about it: that real Go code is a third error checks, that it buries the interesting logic in ceremony.
The complaint is worth taking seriously, and this chapter does. But it usually rests on a misreading: that if err != nil is Go's error feature. It is not — it is the visible consequence of a deeper choice, that a failure is a value of an ordinary type, subject to every tool the language already has. You can store an error in a struct field, put it in a slice, pass it, compare it, wrap it with context or deliberately discard it. None of that requires new syntax, because an error is not a special kind of event — it is data.
That is what Rob Pike meant above, and it is the shift this chapter is built around. Once errors are values, error handling becomes ordinary programming, and the repetition critics point at is often a sign the programmer has not noticed they are allowed to program it. The argument against is real: an exception makes the happy path shorter to write, and Go took the opposite trade deliberately. Whether that was correct is a judgement, not a fact — and by the end you should be able to argue it either way, which is exactly what a senior interviewer is listening for.
Every Go function that can fail returns an extra value, and every caller writes three lines to check it. What does the language buy with that verbosity that a try/catch language does not have — and what does it give up?
The interface is one method
error is not built into the language as a keyword. It is an ordinary interface, declared in the universe block, with exactly one method:
type error interface {
Error() string
}That is the whole definition. Anything with an Error() string method is an error — which, by the rule from Part I, means satisfaction is discovered, not declared. You write the method, and your type is an error everywhere one is wanted.
Click Run to execute the code.
Read the output against the code. Line 3 prints value 42 outside [1,10] — fmt never sees a RangeError, it sees an error, asks it for its string, and prints that. Line 4 prints <nil>: check(7) succeeded, and a nil error formats rather than crashing. Line 6 is the point of the section — a slice of errors needs no special machinery, because []error is just a slice.
Two deliberate habits are visible there. The sentinel ErrEmpty is a package-level var whose name begins with Err, the universal Go convention and the next chapter's subject. And check returns error, not *RangeError, even though it only ever returns that one concrete type — an apparently pointless loss of type information whose justification is the bug in this chapter's final section.
Consider two ways a lab reports a failed blood test. In the first, the technician runs into the doctor's office mid-consultation and announces the failure out loud — everything stops, the doctor deals with it now, and if the doctor is out, the announcement carries on up the corridor to whoever is available. That is an exception: it interrupts, it propagates by itself, and whoever receives it may be several rooms from the person who ordered the test. In the second, the failure comes back as a line on the result sheet, in the same envelope a success would have arrived in, and sits in the in-tray until the doctor reads it. Nothing is interrupted and nothing propagates on its own — if the doctor files the sheet unread, the failure is genuinely lost. That is a Go error. It is more work per test, and its advantage is that the sheet is an object in the world: you can staple it to the file, photocopy it, note which patient and which machine, sort a stack of them, hand it to a colleague. You cannot staple a shout to anything.
The (value, error) pair
The mechanism that makes this practical is multiple return values, which Go had from the beginning. A function returning something that might fail returns two values — the result and an error — and the convention is absolute: the error is last.
Click Run to execute the code.
The shape of that loop is the shape of most Go you will ever read: call, check, handle, continue. Notice what it does to the layout — the happy path runs down the left margin, unindented, in the order it actually happens, while every failure is handled next to the call that produced it and then leaves. That is the trade, stated precisely: you pay in lines to buy locality. In a try/catch language the happy path is shorter, but a reader asking "what happens if this call fails?" has to look somewhere else, and often cannot tell from the call site whether it can fail at all. In Go the answer is on the next three lines, always. Whether that is too high a price is the argument — and it is an argument about a trade-off, not an oversight.
One detail in parseAge is worth more than it looks: the %w verb wraps the error from strconv, producing a new error whose message includes the old one and which still contains the original for inspection. The %v you might reach for gives you the text and throws the value away. The next section is built on that difference.
Go shipped in 2009 with errors as values and essentially nothing else — no wrapping, no inspection, just errors.New and the Error() string method. For a decade the community filled the gap itself, and the gap was real: everyone wanted to add context to an error without destroying the ability to check what it originally was. Dave Cheney's github.com/pkg/errors became the de-facto standard with its Wrap and Cause functions, and for years the first line of many Go files imported a third-party package just to handle errors. Rob Pike's 2015 post, quoted above, landed in the middle of the complaints about verbosity, and its argument was not "you are wrong, it is not verbose" but something sharper: repetitive error checking is usually a design smell in your code, because errors are values and values can be programmed. Go 1.13, in 2019, finally brought the idea into the standard library as %w, errors.Is and errors.As — deliberately minimal even then. A try built-in floated for Go 2 around the same time was rejected after a large public backlash, which is why the language you are learning still checks errors with an if.
Adding context without losing the original
An error travelling up through five layers is nearly useless if each layer returns it unchanged: you get no such file or directory and no idea which file, which request, or which subsystem. It is equally useless if each layer replaces it with its own message, because then the root cause is gone. %w lets each layer add its own context while keeping everything underneath intact.
Click Run to execute the code.
The output is the argument. The wrapped error reads load user 7: store get "u7": not found — three layers of context in one line, each adding only what it knew — and errors.Is(err, ErrNotFound) is still true two levels of wrapping later, so a caller at the top can react to a missing record without parsing strings. The last two lines show the cost of getting it wrong: the %v version prints an identical-looking message, yet errors.Is returns false. Every caller that wanted to distinguish "not found" from "database on fire" has silently lost the ability, and nothing hints anything is broken until the day someone needs to branch on the cause.
Two rules follow, both about audience. Wrap with %w when the error crosses a boundary and the layer above might reasonably want to know what failed underneath — most of the time. Do not wrap when the underlying error is an implementation detail you would rather not promise: once callers can see through to a *pq.Error, they will branch on it, and you cannot change your database without breaking them. Wrapping is part of your API surface — the subject of this part's last chapter.
Two habits will save you most error-handling grief, and both are about writing the message. First: an error message is read by someone who cannot see your code, so it should say what was being attempted, not what failed internally — load user 7: store get "u7": not found is useful, lookup failed is not. Second, the one people get wrong for years: do not capitalise the message and do not end it with punctuation. Go's convention is lower-case, no full stop, no exclamation mark, because error strings get embedded in other error strings. fmt.Errorf("load user: %w", err) where err is Disk failed! gives you load user: Disk failed! — a capital and a bang mid-sentence. Write messages as fragments that read correctly when something else is prepended, because something usually will be. The linters check this, and now you know why.
Errors are values, so program them
Here is the part the verbosity argument usually misses. If you find yourself writing the same if err != nil after eight consecutive operations, that is not the language forcing repetition on you — it is a design you can change, using nothing but the tools you already have. The canonical demonstration is Rob Pike's: put the error in a value and let the value remember.
Click Run to execute the code.
Look at what that bought: four operations, one check — and not by ignoring errors, which is the cheap way to shorten error handling. The failure is caught where it happens, remembered precisely, and reported once with full context; the fourth write is skipped rather than corrupting the output. It is the same technique bufio.Writer and encoding/json use, available to you because an error is a value a struct can hold.
The principle generalises: when error handling gets repetitive, look for the value you are failing to keep. Sometimes it is a latch, as here; sometimes a []error accumulated across a batch and joined at the end with errors.Join; sometimes a small helper so the repetitive part happens once. All three are ordinary programming applied to error values — Pike's point exactly. The technique has a cost: a latched error is reported later than it occurred, and the no-ops in between confuse debugging if you forget the latch is there. It suits a run of operations that share a fate, and nothing else.
This pattern earns its keep anywhere you emit a long sequence of writes that share a destination: rendering a CSV export, streaming a response body, building a payload field by field. A payment integration is the sharpest example, because it combines both halves of this chapter. The outbound call needs the latch — a dozen fields written into a request, one check before sending. The inbound result needs the opposite discipline: a failed charge is not an exception, it is a value you must store, because a customer will ask about it next week and "the call threw" is not an answer. Teams that handle payments well keep the error itself — the provider's code, the reference, the attempt number — in a database row, not just a log line. That is only possible because the error was data all along, and it is the reason to prefer a custom error type carrying fields over a formatted string: &ChargeError{Ref: "ch_19", Code: "insufficient_funds"} can be persisted, counted and queried; errors.New("charge failed: insufficient funds") can only be read.
The trap in the interface
One bug in this area is famous enough that you should expect to be asked about it, and it follows from something you already know: an interface value is a pair — a type and a value. It is nil only when both halves are nil. Put a nil pointer of a concrete type into an interface, and the interface is not nil, because its type half is set.
Click Run to execute the code.
Line 2 is the trap, in the cleanest form it comes in: p == nil is true, and v == nil is false, for the same pointer. The interface is a box with a label, and putting an empty thing into a labelled box does not give you an unlabelled box.
Now the version that bites people, which involves error. Suppose a function is declared to return your concrete type rather than the interface:
// BAD: returns the concrete type.
func validate(f *Form) *ValidationError {
if f.Name != "" {
return nil // a nil *ValidationError
}
return &ValidationError{"name required"}
}
// The caller assigns it to an error:
func handle(f *Form) error {
return validate(f) // nil *ValidationError -> NON-NIL error!
}
// And now this is true even when validation passed:
if err := handle(f); err != nil { /* always taken */ }validate returns a nil *ValidationError; assigning it to error produces an interface whose type half is *ValidationError, which is not nil; and every caller's if err != nil fires on success. It is a nasty bug because the code reads correctly, the nil is real, and the test that catches it has to check the success path specifically.
The fix is a rule with no exceptions worth the trouble: a function that can fail returns error, the interface — never a concrete error type. Then the nil you return is the interface's nil, and everything behaves. If a caller needs the concrete type, they can assert for it — the next chapter's subject.
The block above deliberately uses Validator rather than error, out of honesty about the tool you are holding. This lesson's Go runs in a browser interpreter that wraps the error interface in its own internal type, flattening exactly this distinction: run the error version here and err == nil comes back true, the opposite of real Go. With any other interface, as shown, the runtime is correct and the mechanism identical. Take line 2 as the lesson, transfer it to error on faith, and to watch the error version fire, run it under a real Go toolchain.
Common Mistakes
Ignoring an error with
_because the compiler complained. The blank identifier says "I have decided this failure is acceptable" — occasionally true, usually not.defer f.Close()on a file you wrote to is the classic: the close is where the buffered write actually fails, so discarding it loses data silently.Using
%vwhere%wbelonged. Both produce the same-looking message, but%vthrows the original value away, soerrors.Isanderrors.Asstop working for every caller above you. Nothing fails now; it fails months later.Returning a concrete error type instead of
error. A nil*MyErrorin anerrorinterface is not nil, so the caller'sif err != nilfires on success. Declare the return type aserrorand the problem cannot arise.Capitalising error messages or ending them with punctuation. Error strings get embedded in other error strings, so
Failed to open file.becomesload config: Failed to open file.— capital and full stop mid-sentence. Write a lower-case fragment.Comparing error messages with string equality.
if err.Error() == "not found"compiles, passes an inattentive review, and breaks the day someone adds context or a library rewords it. Compare values witherrors.Is, never text.Returning a half-filled result alongside a non-nil error. When
err != nilthe other returns are meaningless, so return their zero values — a partly populated struct invites someone to use it.Wrapping everything reflexively. Wrapping is an API promise: once callers can see the underlying error they will branch on it, and you can no longer change your storage layer without breaking them.
Handling an error twice — logging it and returning it. The caller logs it too, so one failure is reported at three layers with nothing marking them as one event. Handle it or return it, never both.
Creating a sentinel with
errors.Newinside the function that returns it. Every call produces a different value, soerrors.Iscan never match. Sentinels are package-levelvars, created once — the mistake the next chapter opens on.Treating repetitive error checks as unavoidable. When eight operations each need the same three-line check, a latch, an accumulated
[]error, or a small helper collapses it. The repetition is a design you can change.
Interview Corner
error type in Go, and how do you create one?error is an ordinary interface with a single method, Error() string — not a keyword, and satisfied by any type with that method. The simplest way to create one is errors.New("some message"). To include runtime values, use fmt.Errorf("parse %q: %w", s, err), which formats like Printf and, with %w, keeps the wrapped error inspectable. If the caller needs structured data rather than a message — a field name, a status code — define a struct type with an Error() string method, so the information survives as data instead of being flattened into text. Functions that can fail return the error last, and the caller checks it with if err != nil.*MyError and the caller sees a non-nil error even when the function succeeded. What happened?*MyError, and assigning it to an error interface produced a non-nil interface value. An interface is a pair of a type and a value, and it equals nil only when both halves are nil. Returning a typed nil sets the type half to *MyError, so the interface is not nil even though the pointer inside it is, and every if err != nil above fires on success. The fix is to declare the return type as error, so the nil you return is the interface's own nil; if you must keep the concrete signature, return an explicit nil literal on the success path. This is not specific to errors — the same trap exists for any interface, and error is just where it costs the most.if err != nil is criticised as boilerplate. Defend the design — and say where the criticism lands.try proposal shows the core team took it seriously. More importantly, the pattern makes it easy to be lazy rather than wrong — if err != nil { return err } compiles, looks like handling, and adds no context, so unhelpful errors are the path of least resistance in a way a stack trace is not. My position is that the trade suits long-lived services where reading matters more than writing, and that the strongest argument against Go here is not the if — it is that the language gives you no help noticing an error you dropped.(value, error) signature and handle every case at the call site.%w actually stores.Hands-on
Four exercises covering the whole chapter: a (value, error) function that validates its input, a wrapper that adds context with %w so the sentinel stays findable, an error-latching collector in the Pike pattern, and a batch runner that accumulates failures rather than stopping at the first. The fourth is worth slowing down on — returning all the failures from a batch is a different design from returning the first, and both are correct depending on who is calling.
Click Run to execute the code.
Summary
erroris an ordinary interface with one method,Error() string, satisfied by discovery like any other interface — not a keyword, not a special construct.- A failure is a value, not an event. An error can be stored, put in a slice, passed, compared, persisted or discarded, using only the tools the language already has.
- The convention is
(value, error)with the error last, and when the error is non-nil the other results are meaningless — return their zero values rather than half-filling them. if err != nilbuys locality. Every call that can fail says so in its type, and the handling sits next to the call rather than in a handler elsewhere; the cost is lines, and that cost is real.%wkeeps the wrapped error's value;%vkeeps only its text. Using%vwhere%wbelonged silently destroys every caller's ability to inspect the cause, and nothing fails until someone needs to.- Add context that names the attempt, and write messages as lower-case fragments with no trailing punctuation, because they get embedded inside other messages.
- Wrapping is an API promise. Exposing the underlying error invites callers to branch on it, constraining what you can change later — wrap where the layer above benefits, not reflexively.
- A nil pointer in an interface is not a nil interface, because an interface is a (type, value) pair that is nil only when both halves are — which is why a failing function must be declared to return
error, never a concrete error type. - Repetitive error checks are usually a design you can change — a latched error, an accumulated
[]errorjoined at the end, or a small helper collapses the repetition without ignoring anything. - Handle an error or return it, never both. Logging and returning the same failure reports one event at three layers, which is how log files stop being useful.
Everything in this chapter treated an error as a single value you check for nil. But real programs ask harder questions of a failure — is this the "not found" I know how to recover from, or something I should pass on? What was the original cause, four layers down? The vocabulary for that is the sentinel and the wrapped chain, and the tools are errors.Is and errors.As. The next chapter is Sentinels, Wrapping & Inspection.
It is worth noticing what Go's choice actually optimises for, because it is not what people usually say. The common defence is that explicit errors are safer, and that is only half true — nothing stops you writing if err != nil { return err } forever and shipping a program whose failures say not found with no indication of what was not found. Go does not make good error handling automatic; it makes it visible. Every place a failure can happen is marked in the source, which means the quality of a codebase's error handling is legible to anyone reading it, including you in six months. An exception-based codebase can be excellent or negligent and look identical either way. That is the real trade, and it is a trade about review rather than correctness: Go moved the cost of error handling from the debugger to the diff. Whether that is worth three lines per call depends on how long your program has to live and how many people have to read it — which is why the argument never resolves, and why the honest answer in an interview is a trade-off rather than a verdict.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.