07 - Errors¶
What this session is¶
About an hour. You'll learn how Go handles things going wrong - opening a file that doesn't exist, parsing a number from text that isn't a number, network calls that fail. Go's approach is different from most other languages and one of the things that makes Go code recognizable on sight.
The big idea: errors are values¶
In many languages (Python, Java, JavaScript, Ruby), when something goes wrong the language throws an exception that flies up the call stack until someone catches it. If nobody catches it, the program crashes.
Go does not do this. In Go, a function that can fail returns an extra value: an error. The caller is expected to check it.
result, err := SomethingThatCanFail()
if err != nil {
// handle the failure
return err
}
// use result, now that we know it's safe
That if err != nil { ... } block is the most-typed pattern in all of Go. Some people love it; some people complain about it. Either way, you can't read Go code without it.
A real example¶
The standard library has strconv.Atoi, which converts a string to an integer ("Atoi" = "ASCII to integer"). It might fail, because "hello" isn't a number. So it returns two things:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n)
n2, err := strconv.Atoi("hello")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n2) // never reached
}
Type and run. Output:
What's happening:
strconv.Atoi("42")succeeds.errisnil(the special "nothing" value). We skip theifand usen.strconv.Atoi("hello")fails.erris a non-nil error value with a useful message. We enter theif, print it, andreturnfrom main - which ends the program.
That nil thing is new. nil is Go's word for "no value, nothing here." When a function returns (T, error):
- If everything was fine: the
Tis the real result anderrorisnil. - If something failed: the
Tis usually the zero value (don't trust it), anderroris the actual error.
You always check err != nil before trusting the other return value. Always. There are no exceptions to this rule for code you write.
What is an error, exactly? (the mental model)¶
Here's the part of Go that surprises people coming from other languages: error has almost no magic in it. The entire error type is defined like this in the standard library:
That's it. An error is any type that has a method called Error() returning a string. No special inheritance, no exception class to extend, no throws annotation, nothing. If your type has an Error() string method, your type IS an error and the compiler will accept it where an error is expected.
This is what people mean when they say "errors are values." Errors aren't a special control-flow mechanism - they're just regular values implementing a regular interface. You can store them in slices, send them through channels, compare them, pass them to functions, return them - anything you can do with any other value.
You met interfaces formally on page 05. The short version: an interface is a contract. The error contract is "I can tell you a string about what went wrong."
Here's the entire mechanism, demonstrated in one program:
package main
import "fmt"
// MyError is a custom error type - just a struct with an Error() method.
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Msg)
}
func doStuff() error {
return &MyError{Code: 404, Msg: "not found"}
}
func main() {
err := doStuff()
fmt.Println(err) // [404] not found
fmt.Println(err.Error()) // [404] not found -- same thing
fmt.Println(err == nil) // false
}
fmt.Println(err) automatically calls err.Error() to get a string to print. There's no magic - just the interface contract being satisfied.
This means errors can carry any structured data you want. Want an error that knows an HTTP status code? Add a Status int field. Want one that knows which field failed validation? Add a Field string. The error is a regular struct; you can put anything in it. The caller can inspect it (you'll see how, in Going deeper).
Try it: 1. Type the program above. Confirm both prints show [404] not found.
-
Add a second method:
func (e *MyError) IsRetryable() bool { return e.Code >= 500 }. Insidemain, doif me, ok := err.(*MyError); ok && me.IsRetryable() { ... }. (You're doing a type assertion - checking iferris actually a*MyErrorunderneath.) -
Try returning
errors.New("simple")instead of&MyError{...}. What changes? (Hint:errors.Newreturns an unexported type with just anError()method.)
Making your own errors¶
You'll write functions that can fail. They need to return errors.
The simplest way:
import "errors"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
errors.New("...") creates a brand-new error with the given message. Use it for simple cases.
The fancier way uses fmt.Errorf, which is like fmt.Sprintf but produces an error:
import "fmt"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}
return a / b, nil
}
The advantage: you can include variables in the error message, exactly like Println.
Wrapping errors with %w¶
Often a function fails because another function it called failed. You want to include the deeper error in your own error message:
import (
"fmt"
"strconv"
)
func parseAge(input string) (int, error) {
n, err := strconv.Atoi(input)
if err != nil {
return 0, fmt.Errorf("parsing age %q: %w", input, err)
}
if n < 0 {
return 0, fmt.Errorf("age cannot be negative, got %d", n)
}
return n, nil
}
The %w placeholder wraps the original error inside your new one. Why this matters:
- The error message includes everything: "parsing age \"hello\": strconv.Atoi: parsing \"hello\": invalid syntax". Helpful when debugging.
- Other code can later unwrap it (
errors.Is,errors.As) to check whether a specific underlying error happened. You'll see this in real codebases.
For now, the rule: when you return an error that was caused by another error, wrap it with %w.
The wrap chain (the mental model)¶
Each %w wrap creates a new error that points at the inner error - like a linked list:
your error ──Unwrap()──▶ intermediate error ──Unwrap()──▶ original error ──Unwrap()──▶ nil
"validating config" "parsing line 12" "unexpected '}' at col 5"
This is how the chain actually works in the runtime. A wrapped error is a struct with two fields: a message and an inner error. Its Unwrap() method returns the inner error. Call Unwrap() repeatedly and you walk back through the whole history until you hit nil.
You almost never call Unwrap() directly. Two helpers in the errors package walk the chain for you:
import "errors"
// Is the original error (or anything in the chain) a specific sentinel?
if errors.Is(err, io.EOF) { ... }
// Is the original error (or anything in the chain) a specific type?
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("bad path:", pathErr.Path)
}
errors.Is walks the chain and returns true if any link in it equals the target. errors.As walks the chain and returns true if any link in it is the requested type (and fills in the pointer with it).
Run the wrap-chain demo yourself:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
inner := fmt.Errorf("lookup user 42: %w", ErrNotFound)
outer := fmt.Errorf("load profile: %w", inner)
fmt.Println(outer)
// load profile: lookup user 42: not found
fmt.Println(errors.Unwrap(outer))
// lookup user 42: not found
fmt.Println(errors.Unwrap(errors.Unwrap(outer)))
// not found
fmt.Println(errors.Is(outer, ErrNotFound))
// true -- errors.Is walked the entire chain
}
That last line is the whole point. Even though outer is two levels of wrapping above ErrNotFound, errors.Is(outer, ErrNotFound) returns true because it walked the chain. You compare against the root cause; you don't have to know how deep it's buried.
Try it: 1. Type the program. Confirm the output.
-
Replace one
%wwith%vand re-run. What doeserrors.Is(outer, ErrNotFound)return now? Why? (Hint:%vdoesn't wrap; it just flattens to a string. The chain breaks at that point.) -
Define a second sentinel
var ErrTemporary = errors.New("temporary"). Wrap both into a single error witherrors.Join(ErrNotFound, ErrTemporary). Checkerrors.Isagainst each - both return true. (You'll seeerrors.Joinagain in Going deeper.)
The pattern, end-to-end¶
A function that does several things, any of which can fail:
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading %s: %w", path, err)
}
cfg, err := parseConfig(data)
if err != nil {
return Config{}, fmt.Errorf("parsing %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("validating %s: %w", path, err)
}
return cfg, nil
}
Three things, three if err != nil blocks, three wrapped error messages. Every error tells you which step failed and what happened underneath. This shape - try a step, check, maybe wrap and return - is the rhythm of Go.
It is verbose. People who come from exception-based languages often dislike this initially. The argument for it: every failure path is right there in the code, visible and explicit, not hidden in a throws clause or a catch block somewhere else.
What about panics, and why no exceptions?¶
You'll occasionally see panic("message") in Go code. A panic is a program-level crash - used for "this should never happen, the program is broken." Examples: dividing by zero (the language panics for you), trying to read index 99 of a 3-element slice, dereferencing a nil pointer.
Don't use panic for normal failure cases. "File not found" isn't a panic; it's an error. Panic is for "the world is broken and there's no recovering." You'll see it in init code (a service can't start without its config) and in development assertions ("this case should be impossible - if it happens, I want to know loudly").
There's a way to catch panics (recover) but you'll almost never need it. Use errors for almost everything.
Why doesn't Go just use exceptions? Worth a moment because it explains the if-err-not-nil discipline.
Exception-based languages (Python, Java, JavaScript) treat failures as a separate control-flow channel: a function that raises an exception transfers control to whatever try/catch is nearest up the stack - invisibly, from the calling code's point of view. The advantages are concise happy-path code and centralized error handling. The disadvantages are that:
- Every function call is potentially an unexpected exit point. You can't tell by reading the code what might throw.
- The control flow is hidden. A function 10 frames down can raise an exception that resurfaces in a handler you wrote without knowing about that function.
- Resource cleanup is fragile without language support like
try-with-resourcesorusingblocks.
Go's authors decided that the tradeoff was wrong: the verbosity of if err != nil is worth paying for control flow that's visible at the call site. Every place a function might fail is right there in the code. There are no surprise exits.
You don't have to agree with the choice. You do have to live with it when you write Go.
(Panic exists for the cases exceptions handle in other languages - truly unrecoverable program bugs. The difference is that Go strongly discourages routinely using it. Errors are the default mechanism; panic is the escape hatch.)
Try it: 1. Trigger a panic deliberately: arr := []int{1, 2, 3}; fmt.Println(arr[10]). Run it. Read the stack trace.
- Compare with what happens when you forget to handle an error: replace your
if err != nilblock with just_ = err. The program may keep running on bad data, producing wrong results silently. Which failure mode would you rather debug - the loud crash with a stack trace, or the silent corruption?
Going deeper¶
Production Go error handling uses a few more tools than the basics. Material below is drawn from the Go team's own blog posts (links at the end).
Sentinel errors and errors.Is¶
A sentinel error is a package-level error value that callers can compare against:
package mystore
import "errors"
var ErrNotFound = errors.New("item not found")
func Lookup(id string) (Item, error) {
if id == "" {
return Item{}, ErrNotFound
}
// ...
}
The caller:
item, err := store.Lookup(id)
if errors.Is(err, mystore.ErrNotFound) {
// handle "not found" specifically
return nil
}
if err != nil {
return err
}
errors.Is walks the wrapping chain - if err is ErrNotFound, or wraps it, or wraps something that wraps it, it returns true. You'll see this everywhere: io.EOF, sql.ErrNoRows, context.Canceled, os.ErrNotExist. The pattern is universal: define a sentinel, wrap it when adding context, compare with errors.Is.
Try it: Build a tiny store package with var ErrNotFound = errors.New("not found"). Have Lookup return fmt.Errorf("lookup %q: %w", id, ErrNotFound). In main, call Lookup and use errors.Is. Confirm it returns true even though the error message is "lookup \"x\": not found", not just "not found".
Custom error types and errors.As¶
When you need more than a yes/no - say, "what HTTP status should this map to?" or "which field failed?" - use a typed error:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
The caller extracts the type:
err := DoStuff()
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
fmt.Println("message: ", ve.Message)
}
errors.As is the typed cousin of errors.Is. It walks the wrap chain looking for an error whose dynamic type matches the target, and fills in the pointer when it finds one.
Try it: Define a RetryableError type with a RetryAfter time.Duration field and an Error() method. In a caller, use errors.As to extract it and print the retry delay. The point: errors aren't just messages - they can carry structured data the caller can use.
A custom Is method for template-style matching¶
Beyond the default "equal to this value" check, you can define your own Is(target error) bool method on a custom error type to implement custom matching. The Go 1.13 blog gives this example: match any error whose Path and User non-zero fields agree with the target.
type Error struct {
Path string
User string
}
func (e *Error) Error() string {
return fmt.Sprintf("error path=%s user=%s", e.Path, e.User)
}
// "Template match": non-zero target fields must match.
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
return (t.Path == "" || e.Path == t.Path) &&
(t.User == "" || e.User == t.User)
}
Then callers can ask:
err := &Error{Path: "/etc/shadow", User: "alice"}
errors.Is(err, &Error{User: "alice"}) // true -- User matches; Path ignored
errors.Is(err, &Error{Path: "/etc/shadow"}) // true -- Path matches; User ignored
errors.Is(err, &Error{User: "bob"}) // false
This is the Go team's idiomatic pattern for "match a family of errors by partial criteria." Most code won't need it; when you do, it's the right shape.
Try it: Add a Reason string field to the Error struct above. Extend the Is method to also treat empty Reason in the target as a wildcard. Verify that errors.Is(err, &Error{Reason: "denied"}) matches only when reason is "denied".
Wrapping is an API decision, not a reflex¶
This is one of the most important lines from the Go 1.13 blog and worth re-reading slowly:
Wrapping an error makes that error part of your API. Committing to it in the future is required.
Once you write return fmt.Errorf("...: %w", sql.ErrNoRows), callers can write errors.Is(err, sql.ErrNoRows). You can never change the underlying storage mechanism (SQL → key-value store → cache → whatever) without breaking those callers. They depend on you returning that specific sentinel forever.
Three guidelines:
- Wrap to expose - when the inner error is genuinely useful to callers, and you're willing to keep it in your API contract.
- Don't wrap to hide - when the inner error is an implementation detail. Use
%v(which formats the error into the string but does not set up anUnwrap()link): - Don't wrap pointlessly - if you're not adding context, just
return err.
Try it: Write a function getUserAge(id) (int, error) that internally calls db.Query. Try two versions: one with %w and one with %v. In the caller, try errors.Is(err, sql.ErrNoRows). The first matches; the second doesn't. The first locks you in; the second leaves you free.
Wrap the sentinel even when there's no extra context¶
A subtle pattern from the Go team:
Why wrap a sentinel with no added context? Because it forces callers to use errors.Is instead of ==:
// Caller-A using == (works only because ErrPermission was returned directly):
if err == mypkg.ErrPermission { ... }
// Caller-B using errors.Is (works regardless of wrapping):
if errors.Is(err, mypkg.ErrPermission) { ... }
If you start by returning the sentinel directly and later decide to add wrapping (e.g., fmt.Errorf("user %q: %w", u.Name, ErrPermission)), every caller using == silently breaks. By wrapping from the start, you future-proof the API.
In other words: even an "empty" %w wrap is a deliberate API contract that says "compare me with errors.Is, not ==."
Try it: Write a tiny package with ErrPermission returned both ways. Write two callers - one with ==, one with errors.Is. Now modify the package to wrap the sentinel with extra context. Run both callers. The == one breaks; the errors.Is one keeps working. Lesson learned at no cost.
Behavior interfaces - extend errors with extra methods¶
Sometimes you don't want callers to know the concrete type of an error - you just want them to ask a question about it. The pattern: define an interface that extends error with extra methods.
The most famous example is net.Error from the standard library:
package net
type Error interface {
error
Timeout() bool // Is the failure a timeout?
Temporary() bool // Is the failure temporary (retry might succeed)?
}
Any network error in the standard library that satisfies this contract can be queried by callers:
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
time.Sleep(time.Second)
continue // retry
}
The caller doesn't care whether the error came from *net.OpError, *net.DNSError, or anywhere else - it just asks "are you temporary?" The error answers via its method.
You can build your own behavior interfaces the same way:
type retryable interface {
Retryable() bool
}
// In your package:
type myErr struct{ msg string; retry bool }
func (e *myErr) Error() string { return e.msg }
func (e *myErr) Retryable() bool { return e.retry }
// In any caller:
var r retryable
if errors.As(err, &r) && r.Retryable() {
// retry
}
The advantage over a typed errors.As check: callers don't need to import your error type. The interface is the contract.
Try it: Build a httpError type with a Status() int method. Have it satisfy a statuser interface defined in a separate package. Make the caller use errors.As(err, &s) (where s is of interface type) to ask for the status without knowing the concrete error type.
errors.Join for multiple errors¶
Sometimes you want to keep going through a batch and report everything at the end:
import "errors"
func processAll(items []Item) error {
var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...) // nil if errs is empty
}
errors.Join (Go 1.20+) bundles many errors into one. Two useful behaviors:
errors.Isanderrors.Asboth check every joined error, in order. If any one matches, the call returns true.- The joined value's
Error()method returns the inner errors' messages concatenated with newlines.
err := errors.Join(ErrTimeout, ErrNotFound)
errors.Is(err, ErrTimeout) // true
errors.Is(err, ErrNotFound) // true
fmt.Println(err)
// timeout
// not found
Try it: Write validateUser(u User) error that checks five fields, returning errors.Join of all the individual errors. In the caller, use errors.Is to ask if any specific validation failed. Compare the developer experience to returning the first error found.
When panic is actually correct¶
The "never panic" advice has a handful of exceptions:
- Program invariant broken. Your function takes a non-nil arg and got nil. The caller violated the contract. Panic; let them fix their bug. (Don't wrap this in a returned error - bugs aren't errors.)
- Init failure with no recovery. Your service can't start without a config file. Returning an error from
init()isn't possible, and continuing with bad state is worse. MustXxxconstructors. A common idiom:regexp.MustCompile,template.Must. They panic on invalid input. Use them only when the input is a compile-time constant that you've verified by reading the code.- Truly unrecoverable runtime state. "The disk filled while writing the WAL and I have no way to roll back." This is the same case as init failure - there's nothing useful to do but stop.
recover exists for one good reason: catching panics at a goroutine boundary so they don't crash your whole server. HTTP server frameworks use it for that. You'll probably never write recover yourself.
Try it: Read the source of regexp.MustCompile (pkg.go.dev/regexp#MustCompile). It's three lines. Now grep your favorite Go open-source project for Must. Notice how rare it is - and how the cases where it appears all share the same shape: "compile-time constant input, no recovery needed."
Where this material came from¶
- Working with Errors in Go 1.13 - the canonical reference for
%w,errors.Is,errors.As, customIsmethods, and the wrapping-is-an-API-decision principle. - Error handling and Go - the older, philosophical piece. The "errors are values" framing, custom error types, the
net.Error-style behavior-interface pattern. errorspackage docs - forerrors.Joinand the chain-walking helpers.
Exercise¶
In a new file parse.go:
Write a function parsePositive(s string) (int, error) that:
- Parses
sas an integer usingstrconv.Atoi. - If
Atoifails, returns the error wrapped with%wand a message like"parsePositive %q: %w". - If the parsed number is less than or equal to zero, returns an error like
"parsePositive: number must be positive, got %d"(usefmt.Errorf, no wrap because there's no underlying error to wrap). - If everything is fine, returns the number and
nil.
In main, call parsePositive with each of these inputs and print the result: - "42" → expect 42, no error - "hello" → expect parse error - "-5" → expect "must be positive" error - "0" → expect "must be positive" error - "100" → expect 100, no error
Loop over them with a slice: inputs := []string{"42", "hello", "-5", "0", "100"}.
What you might wonder¶
"Why isn't this try/catch like other languages?" Design choice. Exceptions hide control flow - a function call may secretly jump to a handler 10 frames up the stack. Go's authors decided that making every failure path explicit was worth the extra typing. You don't have to agree with the choice; you do have to read the code.
"What's nil?" Go's "no value" for types that can be absent - errors, pointers (page 08), maps that haven't been made yet, slices that haven't been initialized, channels. nil compared with == lets you check for "nothing here." if err != nil literally means "if there is an error."
"Is if err != nil really every other line?" Roughly, yes. Modern Go has a few patterns to reduce it (helpers, error groups), but expect to see and write the pattern often. You'll stop noticing it in a few weeks.
"What's %q?" A fmt placeholder that prints a value in quoted form: "hello" rather than hello. Useful in error messages so you can tell whitespace and empty strings apart.
Done¶
You can now: - Recognize that Go functions that can fail return (T, error). - Write the if err != nil { return ..., err } pattern. - Create errors with errors.New and fmt.Errorf. - Wrap one error inside another with %w. - Tell errors apart from panics (and know when to use each).
You've now seen Go's most distinctive idiom. Real Go code is mostly: data shaping (slices, maps, structs), decisions and loops, function composition, and error checking. You have all five.
Next page: pointers - how Go lets you share data and let functions mutate things.
Next: Pointers and memory → 08-pointers-and-memory.md