Buy Me a Coffee

The Pragmatic Way to Handle Multiple Errors in Go (Before and After Go 1.20)

Golang errors.Join illustration

Introduction

For many Go developers, a recurring challenge has been: “What is the right way to handle multiple errors?” Whether it’s running several cleanup steps, processing batches of data, or validating various inputs, ensuring errors are handled correctly and efficiently has been crucial. Before Go 1.20, developers did not have a unified approach; each team created its own solution, often leading to awkward, unidiomatic, or even incorrect patterns.

With Go 1.20, we now have a standard, pragmatic solution: errors.Join. This article will explore why older techniques were problematic, how the Go team designed a better approach, and how to apply this new solution effectively in your code.

The Core Principle

The core principle to remember, even when dealing with multiple errors, is:

Always check: err != nil

Go’s philosophy is not about counting errors but rather making decisions on how to handle failures. This approach promotes clarity and directness in error management.

The Bad Old Days (Before Go 1.20)

Scenario 1: Appending Errors to a Slice

A common pattern was to append errors to a slice:

func run() error {
  var errs []error

  if err := step1(); err != nil {
    errs = append(errs, err)
  }
  if err := step2(); err != nil {
    errs = append(errs, err)
  }

  if len(errs) > 0 {
    return fmt.Errorf("multiple errors: %v", errs)
  }
  return nil
}

However, this approach had notable issues:

  • errors.Is and errors.As do not work.
  • The semantic meaning of each error can be lost.
  • Callers have to parse strings or slices for details.

Scenario 2: Returning []error

Some teams used a pattern where functions returned an error slice directly:

func run() []error {
  var errs []error
  if err := step1(); err != nil {
    errs = append(errs, err)
  }
  if err := step2(); err != nil {
    errs = append(errs, err)
  }
  return errs
}

This breaks from Go’s convention of returning a single error, forcing every caller to handle errors differently. It complicates integration with existing APIs and makes error handling more cumbersome.

The Modern Solution: errors.Join (Go 1.20+)

To address these problems, Go 1.20 introduced errors.Join, which offers:

  • Ability to accept multiple error inputs.
  • Automatic disregard of nil values.
  • Returns nil if all inputs are nil.
  • Preserves the functionality of errors.Is and errors.As.

Most significantly, it returns a single error value that maintains composability and idiomatic error handling.

The Idiomatic Accumulation Pattern

Switching to errors.Join allows for a cleaner pattern:

var err error

if e := step1(); e != nil {
  err = errors.Join(err, e)
}
if e := step2(); e != nil {
  err = errors.Join(err, e)
}

return err

This method ensures readability, composability, and alignment with Go tools and practices.

Scenario 1: Batch Processing (Best-Effort)

In batch processing, errors.Join is very effective:

func processAll(items []Item) error {
  var err error

  for _, item := range items {
    if e := process(item); e != nil {
      err = errors.Join(err, e)
    }
  }

  return err
}

The caller can handle errors cleanly:

if err != nil {
  log.Printf("processing failed: %v", err)
}

You only need to know an error occurred, not the count or specific failures.

Scenario 2: Cleanup / Defer Chains

When multiple cleanup steps may fail, errors.Join simplifies error aggregation:

func run() (err error) {
  defer func() {
    if e := cleanupA(); e != nil {
      err = errors.Join(err, e)
    }
    if e := cleanupB(); e != nil {
      err = errors.Join(err, e)
    }
  }()

  return doWork()
}

This pattern ensures all cleanup operations complete, regardless of errors.

Scenario 3: Validation Errors

For validating inputs, use errors.Join to accumulate multiple validation errors:

func validate(u User) error {
  var err error

  if u.Name == "" {
    err = errors.Join(err, errors.New("name is required"))
  }
  if u.Age < 0 {
    err = errors.Join(err, errors.New("age must be positive"))
  }

  return err
}

Callers manage validation results effectively with:

if err != nil {
  return fmt.Errorf("invalid user: %w", err)
}

How Callers Should Handle Joined Errors

Always Start with err != nil

Initiate error handling with a simple check:

if err != nil {
  return err
}

This keeps the logic straightforward.

Checking for Specific Causes with errors.Is

Using errors.Is, you can determine specific error causes:

if errors.Is(err, fs.ErrNotExist) {
  // Handle the case where an operation failed due to a missing file
}

This works seamlessly with joined errors.

Extracting Specific Types with errors.As

To handle specific error types, leverage errors.As:

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
  // Handle the first matching PathError found
}

This provides targeted error management.

“How Do I Know If There Are Multiple Errors?”

Typically, it’s unnecessary to know how many errors occur:

  • Go does not provide methods like err.Len() or err.Count().
  • This encourages focusing on handling errors, not counting them.

If You Absolutely Must (Logging/Diagnostics Only)

For diagnostics, you can check:

if err != nil {
  if je, ok := err.(interface{ Unwrap() []error }); ok {
    log.Printf("multiple errors (%d): %v", len(je.Unwrap()), err)
  } else {
    log.Printf("error: %v", err)
  }
}

Avoid using this in regular control flow logic.

When You Should Track Error Count Explicitly

Only track counts if they impact business logic explicitly:

failures := 0
var err error

if e := step1(); e != nil {
  failures++
  err = errors.Join(err, e)
}

Keep logical separation between error reporting and business rules.

Mental Model (Very Important)

Think of errors.Join as allowing “One failure, with multiple causes.” Go encourages:

  • Clear control flow.
  • Errors that are composable.
  • Minimal introspection.

Summary

With Go 1.20’s errors.Join, handling multiple errors effectively is much simpler:

  • Check err != nil and avoid counting errors.
  • Use errors.Is and errors.As for error inspection.
  • Track error counts only where business logic demands it.

By applying this model, your error handling remains idiomatic, robust, and consistent with Go’s design philosophy.


Enjoyed this article? Support my work with a coffee ☕ on Ko-fi.
Buy Me a Coffee at ko-fi.com
DigitalOcean Referral Badge
Sign up to get $200, 60-day account credit !