Error Handling

Go's explicit error values, wrapping errors, and panic/recover — and when (not) to use them.

Errors are just values

Go has no exceptions for ordinary error handling. Instead, error is a built-in interface, and functions that can fail simply return one as their last value:

Go
type error interface {
    Error() string
}
Go
package main

import (
    "errors"
    "fmt"
)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}
Plaintext
Error: division by zero

The idiomatic pattern: check immediately

Go
data, err := fetchData()
if err != nil {
    return err // or handle it, log it, wrap it — but always check it
}
useData(data)

This "check immediately, don't nest" style keeps the success path at the outer indentation level and error handling explicit and local — a deliberate trade-off against exceptions, which can silently skip many stack frames.

Creating errors with context: fmt.Errorf and %w

Go
func loadConfig(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("loading config from %s: %w", path, err) // %w wraps the original error
    }
    return nil
}

Wrapping with %w preserves the original error so callers can still inspect it with errors.Is and errors.As, while adding human-readable context at each layer.

errors.Is and errors.As

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

func findUser(id int) error {
    return fmt.Errorf("user %d: %w", id, ErrNotFound)
}

func main() {
    err := findUser(42)

    if errors.Is(err, ErrNotFound) {
        fmt.Println("that user doesn't exist")
    }
}

errors.Is walks the chain of wrapped errors looking for a match — essential once you start wrapping errors with %w across multiple layers of a call stack.

Custom error types

Go
type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Msg: "must not be negative"}
    }
    return nil
}
Go
err := validateAge(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("Field with problem:", valErr.Field) // age
}

panic and recover — the exception of last resort

panic stops normal execution and unwinds the stack; recover (only useful inside a defer) can stop that unwind. This is reserved for truly unrecoverable situations (programmer bugs, corrupted invariants) — not a substitute for normal error returns:

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

    result = a / b // panics on b == 0 with a runtime error
    return
}

Common mistakes

  • Using panic/recover for ordinary, expected failure conditions (a missing file, invalid user input) — reserve it for truly exceptional, unrecoverable bugs.
  • Ignoring an error return value (data, _ := fetchData()) — a very easy way to hide real failures.
  • Comparing wrapped errors with == instead of errors.Is== won't see through a %w-wrapped chain.

Interview questions

Q: Why doesn't Go have exceptions like Java or Python? Go's designers chose explicit error return values to make control flow and failure paths visible at every call site, rather than allowing an exception to silently jump across many stack frames. It trades some verbosity for clarity and predictability.

Q: What's the difference between errors.Is and errors.As? errors.Is checks whether an error (or anything it wraps) matches a specific sentinel error value. errors.As checks whether an error (or anything it wraps) can be unwrapped into a specific type, giving you access to that type's fields.

Q: When is it appropriate to use panic? Only for programmer errors or truly unrecoverable states (e.g., a required invariant is violated, an impossible code path is reached) — never as a general substitute for returning an error from a function that can fail in expected ways.