Skip to content

Chapter 2: Error Handling Patterns

Go’s explicit error handling is a deliberate design choice. Rather than exceptions that can bubble up unexpectedly, Go makes error handling visible and mandatory.

Error handling in Go is one of its most controversial features. Coming from languages with try/catch, the explicit if err != nil checks feel verbose and repetitive. But this verbosity serves a purpose: it makes error handling visible, predictable, and impossible to ignore.

Go’s philosophy is that errors are expected outcomes, not exceptional conditions. A file might not exist. A network request might fail. A user might enter invalid input. These aren’t exceptions - they’re normal program flow. Go treats them as such, forcing you to explicitly decide how to handle each error.

This chapter covers error fundamentals, custom error types, error wrapping and unwrapping, sentinel errors, and practical patterns for real-world error handling. You’ll learn not just the mechanics, but the philosophy and best practices that make Go error handling effective.

In Go, errors are just values that implement the error interface. This interface is remarkably simple - just one method that returns a string:

type error interface {
Error() string
}

This simplicity is powerful. Any type can be an error by implementing Error(). Errors are returned like any other value. They can be stored, passed around, and inspected. There’s no special exception handling mechanism - just regular control flow.

Why this matters: Errors as values means you handle them where they occur, with full context. No catching exceptions three layers up the call stack and trying to figure out what went wrong. Error handling is local, explicit, and predictable.

The error return convention: Functions that can fail return (result, error). By convention, error is the last return value. nil error means success. Non-nil error means failure (and other return values should be ignored).

Create custom error types when you need to include additional context beyond a simple error message. Custom errors can carry structured data - field names, error codes, retry information, HTTP status codes - that callers can programmatically inspect and act on.

The standard errors.New("message") is fine for simple cases, but real applications need more. A custom ValidationError can specify which field failed validation. A custom HTTPError can include the status code. A custom RetryableError can signal that an operation should be retried.

When to use custom errors:

  • When callers need to make decisions based on error details
  • When errors need structured context (which field, what value, why it failed)
  • When different error types require different handling strategies
  • When you want type-safe error inspection without string matching

How they work: Define a struct, implement the Error() string method on a pointer receiver, and you have a custom error type. Callers use errors.As() to extract the custom type and access its fields.

Two conventions worth following from day one:

  • Pointer receiver on Error(). The standard library does this everywhere (*os.PathError, *json.SyntaxError, *net.OpError). With a value receiver, both T and *T satisfy error, so callers can’t tell which one to search for and errors.As(err, &target) silently fails when the target kind doesn’t match what was returned.
  • errors.As, never a bare type assertion. err.(*ValidationError) only inspects the outermost error. The moment anyone wraps it with fmt.Errorf("...: %w", err) - which the very next section teaches you to do - the assertion returns false and your error handling silently disappears. errors.As walks the whole chain.

Create custom error types when you need to include additional context:

Sentinel errors are predefined error values for specific conditions:

Wrap errors to add context while preserving the original error:

Use errors.Is to check for specific errors and errors.As to extract typed errors:

Everything so far assumed one error at a time. Real code often has several: a form with three invalid fields, a batch job where four of a hundred items failed, a shutdown sequence where two of five closers returned an error. Before Go 1.20 people concatenated strings and lost all structure.

Go 1.20 added two things:

  • errors.Join(errs ...error) error - returns an error wrapping all non-nil arguments. It returns nil if every argument is nil, which makes the “collect then join” pattern below clean: no length check needed.
  • Multiple %w verbs in one fmt.Errorf - fmt.Errorf("a: %w, b: %w", err1, err2) is now legal.

Both produce an error implementing Unwrap() []error (note: a slice, distinct from the single-error Unwrap() error). errors.Is and errors.As traverse the whole tree, so a joined error matches on any of its branches.

Handle errors at one place, don’t log and return:

Check errors immediately, don’t defer error handling:

  1. Errors are values - the error interface is just Error() string
  2. Custom errors - create types when you need additional context
  3. Sentinel errors - predefined errors for known conditions
  4. Wrap with %w - use fmt.Errorf("context: %w", err) to add context
  5. errors.Is/As - check error chains without unwrapping manually; never bare type assertions, which break as soon as the error is wrapped
  6. errors.Join - report independent failures together (Go 1.20+); %w may appear more than once in one Errorf
  7. Pointer receiver on Error() - *MyError is the error type, matching the standard library
  8. Handle once - either return an error or handle it, not both

Error Chain Parser

medium

Create a function that processes a file path. It should wrap errors at each level (validate path, check permissions, read file). Use errors.Is and errors.As to identify specific errors in the chain.


Put the whole chain - custom types, sentinels, wrapping, errors.Is/errors.As - to work:


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 3: Pointers & Memory