Errors Are Values: Returned, Not Thrown

Why Go treats errors as ordinary return values, the if err != nil pattern and its tradeoff, creating errors with errors.New and fmt.Errorf, wrapping with %w and matching with errors.Is and errors.As, sentinel and custom error types, and what panic and recover are actually for. Compiled and run against Go 1.26.5.

Go does not have exceptions. A function that can fail returns an error as an ordinary value, alongside its result, and the caller checks it with an ordinary if. There is no try, no catch, no invisible unwinding of the stack to some handler far away. Failure is data you receive and inspect, exactly like any other return value.

This is one of Go’s most divisive choices and one of its most consistent. It’s the source of the if err != nil you’ll write more than any other line, and the source of the complaint that Go is verbose. It’s worth understanding not just how the machinery works but why the language made this trade, because once you see errors as values the whole standard library falls into place.

error is just an interface

There’s no special error machinery in the language. error is a plain interface, and a tiny one:

type error interface {
	Error() string
}

Anything with an Error() string method is an error. That’s it. The error you return from functions, the one errors.New produces, the one a database driver hands you — all of them are values satisfying this one-method interface. This is the interface chapter’s structural typing at work: you can make your own error type by giving a struct an Error() method, and it slots in everywhere an error is expected, with no registration.

Because an error is just a value, it follows every rule other values follow. You can store it, pass it, compare it, put it in a struct, return it. Nothing about it is magic.

Returned and checked, not thrown

The convention: a function that can fail returns (result, error), and by universal agreement the error is the last return value. The caller checks whether it’s nil, and nil means success.

package main

import (
	"errors"
	"fmt"
)

// half returns a value AND an error. The caller checks the error.
func half(n int) (int, error) {
	if n%2 != 0 {
		return 0, errors.New("cannot halve an odd number")
	}
	return n / 2, nil
}

func main() {
	if v, err := half(10); err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("half of 10 is", v)
	}

	if v, err := half(7); err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("half of 7 is", v)
	}

	// fmt.Errorf builds a formatted error.
	err := fmt.Errorf("job %d failed after %d retries", 42, 3)
	fmt.Println(err)
}
half of 10 is 5
error: cannot halve an odd number
job 42 failed after 3 retries

Two ways to make an error appear here. errors.New("...") builds a simple error from a fixed string. fmt.Errorf(...) builds one from a format string, the way fmt.Printf builds output, which is what you want whenever the message needs to include specifics like an id or a count.

Now the tradeoff, stated honestly. This is explicit but verbose. The upside is that every place a function can fail is visible in the code, right where it happens; you cannot accidentally ignore an error’s possibility, because the failure is sitting in a variable you have to do something with. There’s no invisible control flow leaping past your code to a handler you forgot to write. The downside is the noise: real Go has these three-line if err != nil blocks scattered throughout, and reading past them is a skill you develop. Go’s designers took that deal deliberately — they decided that error handling is part of the program’s logic and should be as visible as the rest of it, not tucked into a separate channel. Whether the visibility is worth the verbosity is the enduring argument about the language, but the choice is coherent.

Sentinels, custom types, wrapping, and matching

A bare string error is fine for a leaf function, but real programs need more: to recognize a specific error a caller can react to, to attach structured data, and to add context as an error travels up through layers without losing what it originally was. Go handles all three with the same small toolkit.

A sentinel error is a package-level error value that callers compare against by identity. A custom error type is a struct implementing error, carrying fields. Wrapping with the %w verb nests one error inside another so the outer message adds context while the inner error stays discoverable. And errors.Is and errors.As walk that nested chain — Is looks for a specific sentinel value, As looks for a specific type and extracts it.

package main

import (
	"errors"
	"fmt"
)

// A sentinel error: a package-level value callers compare against.
var ErrNotFound = errors.New("not found")

// A custom error type: a struct that implements error.
type QueryError struct {
	Query string
	Err   error
}

func (e *QueryError) Error() string {
	return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}

// Unwrap lets errors.Is / errors.As walk into the wrapped error.
func (e *QueryError) Unwrap() error { return e.Err }

func lookup(id string) error {
	// Wrap the sentinel with %w so it stays discoverable up the chain.
	return &QueryError{Query: "SELECT " + id, Err: fmt.Errorf("row %s: %w", id, ErrNotFound)}
}

func main() {
	err := lookup("42")
	fmt.Println("err:", err)

	// errors.Is walks the chain looking for a specific sentinel value.
	fmt.Println("Is ErrNotFound:", errors.Is(err, ErrNotFound))

	// errors.As walks the chain looking for a specific type, and extracts it.
	var qe *QueryError
	if errors.As(err, &qe) {
		fmt.Println("As QueryError, .Query =", qe.Query)
	}
}
err: query "SELECT 42": row 42: not found
Is ErrNotFound: true
As QueryError, .Query = SELECT 42

Follow the chain. lookup returns a *QueryError whose Err field wraps ErrNotFound via %w. The printed message reads outside-in: the query context, then the row context, then not found at the bottom. Even though ErrNotFound is buried two layers deep, errors.Is(err, ErrNotFound) finds it, because %w and the Unwrap method let Is descend through the layers comparing identities. That’s the payoff of sentinels: a caller anywhere up the stack can write if errors.Is(err, ErrNotFound) and react to that exact condition, however much context got wrapped around it on the way up.

errors.As does the parallel job for types. It walks the same chain looking for a *QueryError, and when it finds one it fills in your pointer so you can read qe.Query. Use Is when you care which known error this is, and As when you need the structured data off a specific error type.

Two rules of thumb keep this tidy. Wrap with %w when the caller should be able to see through to the underlying error; use %v when you want to state a message but deliberately hide the internals. And define a sentinel or a custom type only for conditions callers will actually branch on — most errors just need a good message and don’t need to be individually recognizable.

Joining several errors with errors.Join

Wrapping nests one error inside another, but sometimes you have several independent failures at once — a validation pass that finds three problems, a cleanup step where two of five closers error. For that, errors.Join (added in Go 1.20) combines any number of errors into a single one. The joined error prints each on its own line, and — the useful part — errors.Is matches any of the errors that went in.

package main

import (
	"errors"
	"fmt"
)

var (
	ErrTooShort = errors.New("too short")
	ErrNoDigit  = errors.New("no digit")
)

// validate joins every failure it finds into one error.
func validate(pw string) error {
	var errs []error
	if len(pw) < 8 {
		errs = append(errs, ErrTooShort)
	}
	if !containsDigit(pw) {
		errs = append(errs, ErrNoDigit)
	}
	return errors.Join(errs...) // nil if errs is empty
}

func containsDigit(s string) bool {
	for _, r := range s {
		if r >= '0' && r <= '9' {
			return true
		}
	}
	return false
}

func main() {
	err := validate("abc")
	fmt.Println("err:", err)

	// errors.Is matches ANY of the joined errors.
	fmt.Println("Is ErrTooShort:", errors.Is(err, ErrTooShort))
	fmt.Println("Is ErrNoDigit: ", errors.Is(err, ErrNoDigit))
}
err: too short
no digit
Is ErrTooShort: true
Is ErrNoDigit:  true

The password "abc" failed both checks, so validate joined ErrTooShort and ErrNoDigit into one error whose message is the two lines stacked. A caller can then test for each condition independently: errors.Is(err, ErrTooShort) and errors.Is(err, ErrNoDigit) are both true against the same combined value, because Is walks the whole set, not just a single chain. Two conveniences make this pleasant in practice: errors.Join ignores nil arguments, so you can append conditionally without filtering, and it returns nil when everything you passed was nil — so return errors.Join(errs...) is a correct success path when the slice is empty.

panic and recover: not your exceptions

Go does have a mechanism that unwinds the stack: panic stops normal execution, runs deferred functions on the way up, and crashes the program if nothing stops it. recover, called inside a deferred function, halts that unwinding and returns the panic value. It looks like throw/catch, and reaching for it that way is the most common mistake newcomers make.

It is not for ordinary errors. Panic is for the truly unexpected — a bug, a broken invariant, a state that should be impossible — where continuing would be meaningless. A nil map write, an out-of-bounds index, an integer divide by zero all panic, because they signal a programming fault, not a runtime condition you planned for. You use recover at a boundary where you’d rather convert an unexpected fault into a controlled failure than let one bad request take the whole process down.

package main

import "fmt"

// safeDivide recovers from a panic and turns it into an ordinary error, so a
// programming fault in one call can't take the whole program down.
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil // a / 0 panics with "integer divide by zero"
}

func main() {
	if v, err := safeDivide(10, 2); err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println("10 / 2 =", v)
	}

	if _, err := safeDivide(10, 0); err != nil {
		fmt.Println("error:", err)
	}

	fmt.Println("program continues normally")
}
10 / 2 = 5
error: recovered: runtime error: integer divide by zero
program continues normally

Three things this demonstrates. recover only works inside a deferred function — a call to recover() anywhere else returns nil and does nothing, because there’s no panic in flight for it to catch. The deferred closure uses a named return value (err), which is how it hands the recovered problem back to the caller as a normal error. And after the recovery, main keeps running to its end: the panic from dividing by zero was contained, converted, and the program continued.

But notice what we’re not doing. safeDivide doesn’t use panic as its normal failure channel; dividing by zero is a bug in the caller, and we recovered only to stop it from killing the process. In your own code, a condition you expect and can handle should be an ordinary error return, not a panic. Panic-and-recover is a seatbelt for the unexpected, most often at a server’s request boundary or a worker’s top level, and it is not a substitute for if err != nil. Save it for the genuinely exceptional, and let values carry everything else.

Final thoughts

Errors in Go are values: error is a one-method interface, failures are returned rather than thrown, and callers check them with if err != nil — explicit and a little verbose, by deliberate design, so that every point of failure is visible in the code. Build errors with errors.New and fmt.Errorf, add context by wrapping with %w, and recover intent from the chain with errors.Is (match a sentinel) and errors.As (extract a type). Reserve panic and recover for the genuinely unexpected, always recovering inside a defer, and never as a stand-in for exceptions. Handled as values, errors are just more of the program, which is exactly how Go wants you to treat them.

Next: generics — writing code that works across types without falling back to any.

Comments