Skip to content

Error Handling | Go - Wyatt's Notes

Go’s error handling is convention-based. The error interface has a single method:

type error interface {
Error() string
}

Any type implementing Error() string is an error. There is no exception mechanism — errors are Values, returned like any other value.

Functions that can fail return an error as their last return value:

func Open(name string) (*File, error)
func Read(r io.Reader) ([]byte, error)
func Parse(s string) (T, error)

The caller checks the error immediately:

f, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()

Sentinel errors are predefined error values that the caller can compare against:

var ErrNotFound = errors.New("not found")
func Lookup(key string) (Value, error) {
// ...
if notFound {
return Value{}, ErrNotFound
}
return val, nil
}
v, err := Lookup("key")
if errors.Is(err, ErrNotFound) {
// handle not found
}

Use errors.Is (Go 1.13+) instead of == for comparison, because errors.Is unwraps wrapped Errors:

if errors.Is(err, ErrNotFound) { ... }

Define custom error types when the caller needs to distinguish error kinds:

type ParseError struct {
Line int
Column int
Msg string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("parse error at %d:%d: %s", e.Line, e.Column, e.Msg)
}

Use errors.As to extract a specific error type from a wrapped error chain:

var perr *ParseError
if errors.As(err, &perr) {
fmt.Printf("parse error at line %d\n", perr.Line)
}

Go 1.13 introduced fmt.Errorf with %w verb for error wrapping, along with errors.Is and errors.As for inspecting wrapped errors.

func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
// parse data...
}

fmt.Errorf("context: %w", err) creates a new error that wraps err. The wrapped error is Accessible via errors.Is and errors.As.

  • %w wraps the error, making it accessible to errors.Is and errors.As.
  • %v (or %s) formats the error as a string. The original error is not accessible.
err1 := fmt.Errorf("context: %w", originalErr) // wrapped
err2 := fmt.Errorf("context: %v", originalErr) // not wrapped, just formatted
errors.Is(err1, originalErr) // true
errors.Is(err2, originalErr) // false

Traverses the error chain to check if any error in the chain matches a target:

var ErrNotFound = errors.New("not found")
func process() error {
return fmt.Errorf("processing: %w", ErrNotFound)
}
err := process()
fmt.Println(errors.Is(err, ErrNotFound)) // true

Traverses the error chain to find an error of a specific type:

type TimeoutError struct {
Duration time.Duration
}
func (e *TimeoutError) Error() string {
return fmt.Sprintf("timed out after %v", e.Duration)
}
err := someFunction()
var terr *TimeoutError
if errors.As(err, &terr) {
fmt.Printf("timed out after %v\n", terr.Duration)
}

To make a custom error type compatible with errors.Is and errors.AsImplement the Unwrap() error method:

type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}

Go 1.20 added errors.Join for combining multiple errors into one:

var errs []error
errs = append(errs, err1)
errs = append(errs, err2)
combined := errors.Join(errs...)
fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true

panic is for unrecoverable programming errors — bugs, not expected failures:

panic("invariant violated: x must be positive")
panic(fmt.Sprintf("unexpected state: %v", state))

Panics unwind the stack, running deferred functions. If unhandled, the program crashes.

recover stops a panic and returns the panic value. It is only useful inside a deferred function:

func safeDivide(a, b float64) (result float64, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
if b == 0 {
panic("division by zero")
}
return a / b, nil
}

recover returns nil if there was no panic.

Panics are appropriate for:

  • Invariant violations (programming bugs)
  • Unreachable code paths (default in a type switch covering all cases)
  • Initialization failures in init() functions

Panics are not appropriate for:

  • File not found, network timeout, invalid user input — these are expected failures
  • Any error that a caller might reasonably want to handle

In servers, recover panics at the handler level to prevent a single panic from crashing the entire Process:

func handler(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic in handler: %v\n%s", err, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
// handler logic
}

Errors are values, not exceptions: Go treats errors like any other return value — you get them back from functions and check them explicitly. This is like a function that always hands you a receipt: you look at it to see if the transaction succeeded or failed. There’s no hidden control flow, no try-catch blocks that might jump somewhere unexpected.

Why it matters: Explicit error handling forces you to think about failure modes at every step. While it can feel verbose, it makes error paths visible and debuggable. You always know where errors are checked and how they propagate up the call stack.

The key insight: Error wrapping with %w creates a chain of context — each layer adds information about what it was trying to do, while preserving the ability to inspect the original error with errors.Is and errors.As.

  1. Ignoring errors. result, _ := someFunc() silently discards errors. At minimum, log the error. In tests, use t.Fatal(err).

  2. Using panic for expected errors. Panics are for bugs. If a file might not exist, return an error. If a network request might time out, return an error.

  3. Comparing errors with ==. Use errors.Is instead. Direct comparison fails for wrapped errors and for custom error types that are not sentinel values.

  4. Creating error types without Unwrap. If your error type wraps another error, implement Unwrap() error so that errors.Is and errors.As can traverse the chain.

  5. Using %v instead of %w when wrapping. %v formats the error as a string, losing the ability to inspect the chain with errors.Is/errors.As.

  6. Recover outside of defer. recover only works inside deferred functions. Calling it anywhere else always returns nil.

  7. String-based error matching. Using strings.Contains(err.Error(), "not found") is fragile. Use sentinel errors or custom error types with errors.Is/errors.As.

flowchart TD
    A[Error Handling] --> B[Key Concepts]
    A --> C[Core Principles]
    A --> D[Practical Applications]
    B --> E[Fundamental definitions]
    C --> F[Design patterns]
    D --> G[Real-world usage]

This topic covers the core concepts of error handling, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

  • Interfaces: Interface patterns that complement error handling design.
  • Generics: Generic functions that can work with error types.
  • Practice Error Handling: Practice problems that include error handling scenarios.