Learn Go Series (#11) - Error Handling the Go Way

Words
2224
Reading
10 min
Listen
Play
1h

Learn Go Series (#11) - Error Handling the Go Way

go-banner.png

What will I learn

  • Why Go has no exceptions, and treats errors as ordinary values you return and check;
  • Creating errors with errors.New and fmt.Errorf, and adding context as they travel up;
  • Wrapping errors with %w, and matching them again with errors.Is and errors.As;
  • Sentinel errors (like io.EOF) and custom error types for structured failures;
  • When to handle an error, when to wrap and propagate it, and how to write good error messages;
  • Bundling several failures at once with errors.Join;
  • The panic/recover escape hatch -- what it is for, and why you almost never reach for it.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-10, especially the (result, error) shape from episode 3 and interfaces from episode 9;
  • The ambition to learn Go programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Go Series):

Learn Go Series (#11) - Error Handling the Go Way

Nothing divides opinion about Go like its error handling. There are no exceptions. A function that can fail returns an error as its last value, and the caller checks it -- which is why Go code is famously dotted with if err != nil. People arrive expecting to hate it and often leave appreciating it, because the cost is real (more typing) and so is the benefit: every place a program can fail is visible right there in the code, not hidden behind an invisible throw three layers down.

I have written a lot of code in languages with exceptions, and the thing that eventually won me over to Go's approach is this: in an exception-based language you never quite know, reading a function, which lines can blow up and which cannot. Any call might throw. In Go, the failure points are the lines with err in them, and only those lines. That predictability is worth a surprising amount when you are staring at unfamiliar code at two in the morning trying to work out where it went wrong. Having said that, the verbosity is not free, and later in the episode we will look at how wrapping keeps it from turning into noise.

Today we go from the basic check to adding context, wrapping and unwrapping, sentinel errors, custom error types, joining multiple failures, and finally the rarely-needed panic/recover. First, last episode's exercises.

Solutions to Episode 10 Exercises

Exercise 1 -- swap in place. Two pointers, one tuple assignment:

package main

import "fmt"

func swap(a, b *int) {
    *a, *b = *b, *a
}

func main() {
    x, y := 1, 2
    swap(&x, &y)
    fmt.Println(x, y) // 2 1
}

Exercise 2 -- a linked list length. Walk Next until nil:

package main

import "fmt"

type Node struct {
    Value int
    Next  *Node
}

func length(head *Node) int {
    n := 0
    for cur := head; cur != nil; cur = cur.Next {
        n++
    }
    return n
}

func main() {
    list := &Node{1, &Node{2, &Node{3, nil}}}
    fmt.Println(length(list)) // 3
    fmt.Println(length(nil))  // 0 -- empty list handled
}

Exercise 3 -- see it escape. One value escapes, one does not:

package main

import "fmt"

type Big struct{ data [3]int }

func makeBig() *Big { // its address is returned -> escapes to the heap
    return &Big{}
}

func sumLocal() int { // b is used only here -> stays on the stack
    b := Big{data: [3]int{1, 2, 3}}
    return b.data[0] + b.data[1] + b.data[2]
}

func main() {
    fmt.Println(makeBig() != nil, sumLocal())
    // `go build -gcflags='-m'` reports makeBig's &Big{} "escapes to heap"
    // and sumLocal's b "does not escape".
}

Now, errors.

An error is just a value

error is a built-in interface with one method: Error() string. A function returns an error as its last result; nil means success, non-nil means failure. The caller checks immediately with if err != nil. errors.New makes a simple error from a string:

package main

import (
    "errors"
    "fmt"
)

func readConfig(path string) error {
    if path == "" {
        return errors.New("empty config path")
    }
    return nil // success
}

func main() {
    if err := readConfig(""); err != nil {
        fmt.Println("failed:", err)
    }
    if err := readConfig("app.conf"); err == nil {
        fmt.Println("config ok")
    }
}

There is no hidden control flow: an error is a value returned like any other, and the if err != nil check is where you decide what to do. This is more verbose than try/catch, and in exchange the failure paths are all visible -- you can read a function top to bottom and see exactly where and how it can go wrong.

Because error is just an interface, it behaves like any other interface value we met in episode 9. Its zero value is nil, comparing against nil is the whole success check, and anything with an Error() string method satisfies it (implicitly, no implements keyword). That is the entire mechanism -- there is no magic runtime layer, no stack-unwinding machinery, nothing special baked into the language. A function that returns (T, error) is returning two ordinary values, and the second one happens to be an interface the standard library has agreed to use for failures. Keep that in mind and the rest of this episode is just conventions built on top of one tiny interface.

Adding context with fmt.Errorf

A bare "not found" is useless three layers up -- not found where, looking for what? As an error travels up the call stack, each layer should add context. fmt.Errorf builds an error with a formatted message, and it is how you annotate a failure with what you were trying to do:

package main

import "fmt"

func loadUser(id int) error {
    if id <= 0 {
        return fmt.Errorf("loadUser: invalid id %d", id)
    }
    return nil
}

func main() {
    if err := loadUser(-1); err != nil {
        fmt.Println(err) // loadUser: invalid id -1
    }
}

A good error message says what operation failed and with what inputs, in lower case and without a trailing full stop (Go convention, because errors get wrapped into larger sentences). fmt.Errorf("loadUser: invalid id %d", id) reads well on its own and reads well when a caller prefixes more context in front of it.

Wrapping with %w, matching with errors.Is

Here is the modern heart of Go error handling. The %w verb in fmt.Errorf wraps an error: it adds your context and keeps the original error retrievable underneath. Later, errors.Is unwraps the chain and checks whether a specific sentinel error is anywhere inside it -- so you can add context without losing the ability to react to the root cause:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found") // a sentinel: a known, comparable error

func lookup(key string) error {
    // wrap the sentinel: adds context, stays matchable via errors.Is
    return fmt.Errorf("lookup %q: %w", key, ErrNotFound)
}

func main() {
    err := lookup("alice")
    fmt.Println(err)                          // lookup "alice": not found
    fmt.Println(errors.Is(err, ErrNotFound))  // true -- found through the wrapping
}

The wrapped error prints as a readable sentence, but errors.Is(err, ErrNotFound) still returns true because it walks the wrap chain looking for that specific value. This is how you get both: rich, contextual messages for humans, and precise, programmatic matching for your code. Sentinels like io.EOF in the standard library are used exactly this way.

Custom error types and errors.As

Sometimes an error needs to carry data, not just a message -- which field was invalid, which HTTP status to return. For that you define a type implementing the error interface, and retrieve it from a wrap chain with errors.As, which finds an error of a given type and assigns it to your variable:

package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "invalid field: " + e.Field
}

func save() error {
    return fmt.Errorf("save step: %w", &ValidationError{Field: "email"})
}

func main() {
    err := save()
    var ve *ValidationError
    if errors.As(err, &ve) { // find a *ValidationError in the chain
        fmt.Println("the bad field was:", ve.Field) // email
    }
}

errors.Is answers "is this specific error in the chain?"; errors.As answers "is there an error of this type in the chain, and if so give it to me" -- so you can reach the structured data. Between the two, you can wrap freely for context and still make precise decisions at the top.

Sentinel errors in your own APIs

You will define your own sentinels too -- a package-level var ErrX = errors.New(...) that callers can match against. It gives your API a stable, documented set of failure conditions callers can handle specifically, rather than string-matching messages (which is always a mistake):

package main

import (
    "errors"
    "fmt"
)

var ErrEmpty = errors.New("stack is empty")

type Stack struct{ items []int }

func (s *Stack) Pop() (int, error) {
    if len(s.items) == 0 {
        return 0, ErrEmpty
    }
    top := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return top, nil
}

func main() {
    var s Stack
    if _, err := s.Pop(); errors.Is(err, ErrEmpty) {
        fmt.Println("nothing to pop") // handled specifically, not by message text
    }
}

Exporting ErrEmpty lets callers write errors.Is(err, ErrEmpty) to handle that exact case, while still getting a readable message if they just print it. This is how the standard library exposes conditions like io.EOF or sql.ErrNoRows, and it is a good habit for your own packages.

Handle it, wrap it, or return it -- pick one

Now the part that separates tidy Go from spaghetti Go: at every if err != nil, you have exactly three sensible moves, and you should pick one.

Handle it. You are the layer that actually knows what to do -- retry, fall back to a default, log and carry on. This is the layer where the error stops. A cache miss that you resolve by fetching from the database is handled here; the caller upstairs never needs to hear about it.

Wrap it and return it. You cannot fix it, but you can say what you were doing when it happened. Add context with %w and hand it up. This is the common case in the middle of a program.

Return it unchanged. Sometimes there is genuinely nothing useful to add, and a bare return err is honest. Do not wrap just to feel busy -- fmt.Errorf("error: %w", err) adds a word and zero information.

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

func fetch(id int) error { return ErrNotFound } // pretend a lookup failed

func loadProfile(id int) error {
    if err := fetch(id); err != nil {
        // we cannot fix it here, but we can say what we were doing
        return fmt.Errorf("loadProfile %d: %w", id, err)
    }
    return nil
}

func main() {
    err := loadProfile(42)
    if errors.Is(err, ErrNotFound) {
        // the TOP layer handles it: this is where the error stops
        fmt.Println("no such profile, showing default page")
    }
    fmt.Println("log line:", err) // loadProfile 42: not found
}

The anti-pattern to avoid is handling an error twice: logging it here and returning it, so the same failure gets logged at three different layers and your log file reads like a stack trace written by a commitee. The rule of thumb: log it where you handle it, and nowhere else. If you are returning the error, trust the caller to deal with the logging.

Joining multiple errors with errors.Join

Sometimes one operation produces several independent failures and you do not want to stop at the first -- validating a form where three fields are wrong, or closing a batch of files where two of the closes fail. Since Go 1.20, errors.Join bundles multiple errors into one, and errors.Is still sees through it to every error inside:

package main

import (
    "errors"
    "fmt"
)

var (
    ErrNoName  = errors.New("name is required")
    ErrNoEmail = errors.New("email is required")
)

func validate(name, email string) error {
    var errs []error
    if name == "" {
        errs = append(errs, ErrNoName)
    }
    if email == "" {
        errs = append(errs, ErrNoEmail)
    }
    return errors.Join(errs...) // nil if errs is empty
}

func main() {
    err := validate("", "")
    fmt.Println(err)                        // name is required\nemail is required
    fmt.Println(errors.Is(err, ErrNoEmail)) // true -- Is sees every joined error
}

errors.Join returns nil when you pass it nothing (or only nil values), so the empty-slice case just works -- a valid form returns nil without any special-casing. When it does hold errors, it prints them one per line, and both errors.Is and errors.As walk all of them, not just the first. This is the clean way to collect-and-report instead of bailing out early, and it pairs beautifully with the validation exercise at the end.

panic and recover: the escape hatch you rarely want

panic unwinds the stack and crashes the program, running deferred functions on the way. recover, called inside a deferred function, stops that unwinding and lets you regain control. This is not Go's error handling -- it is for truly unrecoverable situations (a programming bug, an impossible state), and occasionally for turning a panic at a package boundary back into an ordinary error:

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from: %v", r)
        }
    }()
    result = a / b // panics when b == 0
    return result, nil
}

func main() {
    if r, err := safeDivide(10, 2); err == nil {
        fmt.Println("10 / 2 =", r)
    }
    _, err := safeDivide(1, 0)
    fmt.Println(err) // recovered from: runtime error: integer divide by zero
}

The deferred closure calls recover, turning the divide-by-zero panic into a returned error. Useful at a boundary -- but the idiomatic version of safeDivide would simply check b == 0 and return an error directly, no panic involved. The rule: return errors for expected failures, and save panic for "this should be impossible; if it happens the program is broken". You will write a lot of Go before you legitimately need recover.

The same idea in Python

Python uses exceptions -- raise and try/except -- which keep the happy path clean but hide failure points and let errors propagate silently until something catches them (or nothing does). Go inverts the trade: failures are explicit values, visible at every call:

class NotFoundError(Exception):
    pass

def lookup(key):
    raise NotFoundError(f"lookup {key!r}: not found")

try:
    lookup("alice")
except NotFoundError as e:
    print("handled:", e)

Python's exceptions are terser to write and easier to accidentally ignore; Go's error values are more verbose and impossible to overlook, since the compiler hands you the error and idiomatic code checks it. The mapping between the two worlds is actually quite neat once you see it. Python's raise X is Go's return err. Python's except SomeError is Go's errors.Is(err, ErrSomething). Python's raise Y from x (exception chaining) is Go's fmt.Errorf("...: %w", x), and pulling the original back out of e.__cause__ is Go's errors.As. The difference is not really what you can express -- both can add context and match on cause -- but where the checks live: in Python they are opt-in blocks you can forget to write, and in Go they are values sitting in your face until you deal with them.

Neither is objectively right. Exceptions keep the common path uncluttered and are wonderful for a script where any failure just means "stop and print a traceback". Go's values shine in long-running servers where you want to handle each failure deliberately -- retry this, degrade that, propagate the other -- and where an ignored error is a bug you want to see in review. Wrapping (%w) plus errors.Is/As gives Go most of the ergonomics of exception chaining, without the invisibility.

Exercises

  1. Wrap and match. Define a sentinel ErrTooShort, and write validatePassword(p string) error that wraps it with fmt.Errorf("validatePassword: %w", ErrTooShort) when the password is under 8 characters. In main, call it and use errors.Is to detect the specific case and print a helpful message.

  2. A custom error with data. Define type HTTPError struct { Code int; Message string } implementing error, and a function that returns one wrapped with context. Use errors.As to pull it back out and print the Code.

  3. Handle, do not panic. Take the safeDivide from this episode and rewrite it without panic/recover -- check b == 0 up front and return an error directly. Note in a comment why this version is the idiomatic one.

What we learned

  • Go has no exceptions: error is a one-method interface, functions return it as their last value, and callers check if err != nil -- every failure path is visible;
  • Build errors with errors.New (a message) or fmt.Errorf (a formatted message), and add context at each layer as the error travels up;
  • The %w verb wraps an error, keeping the original retrievable; errors.Is matches a sentinel in the chain and errors.As extracts an error of a given type;
  • Define package-level sentinel errors (var ErrX = errors.New(...)) and custom error types so callers handle conditions precisely instead of matching message strings;
  • At every check you either handle, wrap-and-return, or return unchanged -- pick one, and log an error only where you handle it (never twice);
  • errors.Join bundles several independent failures into one value that errors.Is/As still see through -- ideal for validation and batch cleanup;
  • panic/recover is an escape hatch for truly unrecoverable states or package boundaries -- not everyday error handling, which stays value-based;
  • Compared to Python's exceptions, Go's error values are more verbose but impossible to ignore, and %w + Is/As give you exception-style chaining without the invisibility.

Next episode closes out the fundamentals: packages, modules, and project layout -- how visibility works, how go.mod and workspaces organise real projects, and how to split a program into packages that stay clean as it grows. See you there.

Thanks for your time -- de groeten! ;-)

scipio@scipio

Learn Go Series (#11) - Error Handling the Go Way | Ecency