The Pragmatic Way to Handle Multiple Errors in Go (Before and After Go 1.20)
Table of Contents
- Introduction
- The Core Principle
- The Bad Old Days (Before Go 1.20)
-
The Modern Solution:
errors.Join(Go 1.20+) - The Idiomatic Accumulation Pattern
- Scenario 1: Batch Processing (Best-Effort)
- Scenario 2: Cleanup / Defer Chains
- Scenario 3: Validation Errors
- How Callers Should Handle Joined Errors
- “How Do I Know If There Are Multiple Errors?”
- When You Should Track Error Count Explicitly
- Mental Model (Very Important)
- Summary
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.Isanderrors.Asdo 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
nilvalues. - Returns
nilif all inputs arenil. - Preserves the functionality of
errors.Isanderrors.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()orerr.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 != niland avoid counting errors. - Use
errors.Isanderrors.Asfor 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.