Skip to main content
Technology & EngineeringGo209 lines

Error Handling

Error handling patterns including wrapping, sentinel errors, custom types, and error groups in Go

Quick Summary33 lines
You are an expert in Go error handling, helping developers write robust code with clear, actionable error chains using idiomatic Go patterns.

## Key Points

- Always handle errors; never discard them with `_` unless you have a documented reason.
- Wrap errors with `fmt.Errorf("context: %w", err)` to build a chain of context.
- Use `errors.Is` for sentinel comparison and `errors.As` for type extraction.
- Add context about *what* the function was doing, not *that* it failed: `"opening config file: %w"` not `"error: %w"`.
- Use sentinel errors (`ErrNotFound`) for expected conditions callers should branch on.
- Use custom error types when callers need structured data from the error.
- Log errors at the top of the call stack, not at every level (avoid duplicate logs).
- **Comparing errors with `==`**: use `errors.Is` instead, which traverses the wrap chain.
- **Using `%v` instead of `%w`**: `%v` formats the error but does not wrap it, breaking `errors.Is`/`errors.As`.
- **Wrapping then re-creating**: `fmt.Errorf("msg: %w", fmt.Errorf("inner"))` is fine, but `errors.New(err.Error())` discards the chain.
- **Returning a typed nil**: `func f() *MyError { return nil }` returned as `error` produces a non-nil interface. Return `error(nil)` explicitly.
- **Panic for control flow**: reserve `panic` for truly unrecoverable situations (e.g., programmer bugs). Use `error` for everything else.

## Quick Example

```go
type error interface {
    Error() string
}
```

```go
var (
    ErrNotFound   = errors.New("not found")
    ErrForbidden  = errors.New("forbidden")
)
```
skilldb get go-skills/Error HandlingFull skill: 209 lines
Paste into your CLAUDE.md or agent config

Error Handling — Go Programming

You are an expert in Go error handling, helping developers write robust code with clear, actionable error chains using idiomatic Go patterns.

Core Philosophy

Go's error handling is built on a radical premise: errors are not exceptional. They are ordinary values, returned like any other result, and handled with ordinary control flow. There are no try-catch blocks, no exception hierarchies, no stack unwinding. This forces you to confront every failure point explicitly at the call site, which makes error handling verbose but also makes the failure paths of your program visible and reviewable in code review.

The wrapping system introduced in Go 1.13 transforms errors from opaque strings into structured chains. When you write fmt.Errorf("opening config: %w", err), you are building a linked list of context that tells the story of what went wrong — not just "file not found" but "opening config: reading /etc/app.yaml: file not found." This chain is the error's provenance, and errors.Is and errors.As let callers inspect it without string parsing. The wrap chain is your program's error narrative; treat it with the same care you would give to log messages.

The discipline of error handling in Go comes down to a simple rule: handle it or wrap it, but never ignore it. Every if err != nil block is a decision point where you either deal with the error (retry, return a default, log and move on) or add context and pass it up. The worst thing you can do is swallow an error silently, because that turns a diagnosable failure into a mysterious misbehavior discovered hours later in production.

Anti-Patterns

  • Logging and returning the same error: When every layer in the call stack logs the error before returning it, you get five log lines for one failure, none of which tell you the full story. Log errors at the top of the call stack where you have the full context; wrap them everywhere else.

  • Wrapping with redundant context: Writing fmt.Errorf("error getting user: %w", err) when err already says "getting user" adds noise. Each wrap should add new information — what this layer was trying to do, which input triggered the failure, what resource was involved.

  • Using panic for expected failures: Network timeouts, missing files, invalid input — these are not panics. Reserve panic for programmer bugs like nil pointer dereferences in code that should be unreachable. If a caller can reasonably be expected to handle the failure, return an error.

  • Matching errors by string content: Calling strings.Contains(err.Error(), "not found") is fragile and breaks when error messages change. Define sentinel errors or custom error types, and use errors.Is or errors.As for matching.

  • Creating error types with unexported fields but no Unwrap method: If your custom error wraps an underlying error but does not implement Unwrap() error, then errors.Is and errors.As cannot traverse the chain. Always implement Unwrap when your error type contains a cause.

Overview

Go treats errors as values. Functions return an error as the last return value, and callers check it explicitly. Since Go 1.13, the errors and fmt packages support wrapping and unwrapping errors, enabling rich error chains without third-party libraries.

Core Concepts

The error Interface

type error interface {
    Error() string
}

Sentinel Errors

Package-level variables for known error conditions.

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

Error Wrapping (Go 1.13+)

// Wrap with context
return fmt.Errorf("fetching user %d: %w", id, err)

// Check wrapped errors
if errors.Is(err, ErrNotFound) { ... }

// Extract typed errors
var pathErr *os.PathError
if errors.As(err, &pathErr) { ... }

Implementation Patterns

Custom Error Types

type ValidationError struct {
    Field   string
    Message string
}

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

func Validate(u User) error {
    if u.Email == "" {
        return &ValidationError{Field: "email", Message: "required"}
    }
    return nil
}

// Caller
var ve *ValidationError
if errors.As(err, &ve) {
    log.Printf("field %s: %s", ve.Field, ve.Message)
}

Multi-Error Collection

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    msgs := make([]string, len(m.Errors))
    for i, e := range m.Errors {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}

// Or use errors.Join (Go 1.20+)
err := errors.Join(err1, err2, err3)

Error Handling in HTTP Handlers

type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }

type appHandler func(w http.ResponseWriter, r *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, appErr.Code)
        } else {
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        slog.Error("handler error", "err", err)
    }
}

Errgroup for Concurrent Error Handling

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]string, len(urls))

    for i, url := range urls {
        g.Go(func() error {
            body, err := fetch(ctx, url)
            if err != nil {
                return fmt.Errorf("fetching %s: %w", url, err)
            }
            results[i] = body
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

Retry with Backoff

func withRetry(ctx context.Context, maxAttempts int, fn func() error) error {
    var err error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        err = fn()
        if err == nil {
            return nil
        }
        // Don't retry non-transient errors
        if errors.Is(err, ErrNotFound) {
            return err
        }
        delay := time.Duration(1<<attempt) * 100 * time.Millisecond
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}

Best Practices

  • Always handle errors; never discard them with _ unless you have a documented reason.
  • Wrap errors with fmt.Errorf("context: %w", err) to build a chain of context.
  • Use errors.Is for sentinel comparison and errors.As for type extraction.
  • Add context about what the function was doing, not that it failed: "opening config file: %w" not "error: %w".
  • Use sentinel errors (ErrNotFound) for expected conditions callers should branch on.
  • Use custom error types when callers need structured data from the error.
  • Log errors at the top of the call stack, not at every level (avoid duplicate logs).

Common Pitfalls

  • Comparing errors with ==: use errors.Is instead, which traverses the wrap chain.
  • Using %v instead of %w: %v formats the error but does not wrap it, breaking errors.Is/errors.As.
  • Wrapping then re-creating: fmt.Errorf("msg: %w", fmt.Errorf("inner")) is fine, but errors.New(err.Error()) discards the chain.
  • Returning a typed nil: func f() *MyError { return nil } returned as error produces a non-nil interface. Return error(nil) explicitly.
  • Panic for control flow: reserve panic for truly unrecoverable situations (e.g., programmer bugs). Use error for everything else.

Install this skill directly: skilldb add go-skills

Get CLI access →