Go β€” Learn with Examples

/system-design

In Go (since Go 1.20), errors.Join lets you combine multiple errors into a single error while still preserving each individual error inside it.

This is especially useful when:

  • multiple operations can fail independently
  • you want to return all errors, not just the first one

🧠 Basic idea

  
  
err := errors.Join(err1, err2, err3)
  
  
  • Returns nil if all inputs are nil
  • Automatically ignores nil errors
  • The resulting error wraps all the non-nil ones

βœ… Simple example

  
  
package main

import (
	"errors"
	"fmt"
)

func main() {
	err1 := errors.New("file not found")
	err2 := errors.New("permission denied")

	joined := errors.Join(err1, err2)

	fmt.Println(joined)
}
  
  

Output:

  
  
file not found
permission denied
  
  

πŸ” Checking errors with errors.Is

This is where errors.Join becomes powerful:

  
  
package main

import (
	"errors"
	"fmt"
)

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

func main() {
	err1 := ErrNotFound
	err2 := errors.New("something else")

	joined := errors.Join(err1, err2)

	fmt.Println(errors.Is(joined, ErrNotFound)) // true
}
  
  

πŸ‘‰ errors.Is works across all joined errors.


πŸ”Ž Extracting errors kind with errors.As

  
  
package main

import (
	"errors"
	"fmt"
)

type MyError struct {
	msg string
}

func (e MyError) Error() string {
	return e.msg
}

func main() {
	err1 := MyError{"custom error"}
	err2 := errors.New("other error")

	joined := errors.Join(err1, err2)

	var target MyError
	if errors.As(joined, &target) {
		fmt.Println("Found MyError:", target.msg)
	}
}
  
  

πŸ§ͺ Real-world pattern: collecting multiple errors

  
  
func validate() error {
	var errs []error

	if err := checkName(); err != nil {
		errs = append(errs, err)
	}
	if err := checkEmail(); err != nil {
		errs = append(errs, err)
	}
	if err := checkAge(); err != nil {
		errs = append(errs, err)
	}

	return errors.Join(errs...)
}
  
  

πŸ‘‰ Instead of failing fast, you return everything wrong at once.


⚠️ Important behaviors

1. nil handling

  
  
errors.Join(nil, nil) // => nil
  
  

2. Single error

  
  
errors.Join(err) // still works, returns wrapped err
  
  

3. Formatting

By default:

  
  
fmt.Println(joined)
  
  

prints each error on a new line.


🧬 Under the hood (important insight)

The value returned by errors.Join is not a simple wrapper β€” it implements a multi-error unwrapping interface:

  
  
interface {
    Unwrap() []error
}
  
  

This is different from the usual wrapping pattern:

  
  
interface {
    Unwrap() error
}
  
  

πŸ‘‰ Classic wrapping (fmt.Errorf("%w", err)) creates a chain (linked list of errors)
πŸ‘‰ errors.Join creates a tree (or graph) of errors


πŸ”— Chain vs 🌳 Tree

Standard wrapping (chain)

  
  
err3 := fmt.Errorf("level 3: %w", err2)
err2 := fmt.Errorf("level 2: %w", err1)
err1 := errors.New("root error")
  
  

Structure:

  
  
err3 β†’ err2 β†’ err1
  
  

Each error unwraps to one child.


errors.Join (tree)

  
  
err := errors.Join(err1, err2, err3)
  
  

Structure:

  
  
        err
      /  |  \
   err1 err2 err3
  
  

Each error unwraps to multiple children.


πŸ” How errors.Is works internally

When you call:

  
  
errors.Is(err, target)
  
  

Go does roughly:

  1. Check err == target
  2. If not, call Unwrap()
  3. If:
    • Unwrap() error β†’ continue down the chain
    • Unwrap() []error β†’ recursively explore all branches

πŸ‘‰ This means errors.Is performs a depth-first traversal of the error tree.


πŸ§ͺ Example: traversal in action

  
  
package main

import (
	"errors"
	"fmt"
)

var ErrA = errors.New("A")
var ErrB = errors.New("B")
var ErrC = errors.New("C")

func main() {
	joined := errors.Join(
		ErrA,
		errors.Join(ErrB, ErrC),
	)

	fmt.Println(errors.Is(joined, ErrC)) // true
}
  
  

Structure:

  
  
        joined
       /      \
    ErrA     (join)
             /   \
          ErrB  ErrC
  
  

πŸ‘‰ errors.Is finds ErrC even though it's nested.

πŸ” Traversal with fmt.Errorf (chain example)

To understand the difference clearly, let’s look at how traversal works with classic wrapping:

  
  
package main

import (
	"errors"
	"fmt"
)

var ErrRoot = errors.New("root")

func main() {
	err := fmt.Errorf("level 3: %w",
		fmt.Errorf("level 2: %w",
			fmt.Errorf("level 1: %w", ErrRoot),
		),
	)

	fmt.Println(errors.Is(err, ErrRoot)) // true
}
  
  

Structure:

  
  
err
 ↓
level 2
 ↓
level 1
 ↓
ErrRoot
  
  

πŸ‘‰ Here, errors.Is walks linearly down the chain:

  • check level 3
  • unwrap β†’ level 2
  • unwrap β†’ level 1
  • unwrap β†’ ErrRoot βœ…

βš–οΈ Key difference in traversal

Feature fmt.Errorf("%w") errors.Join
Structure Chain (linked list) Tree / graph
Unwrap signature Unwrap() error Unwrap() []error
Traversal Linear Depth-first search
Use case Causality Aggregation

πŸ”Ž errors.As works the same way

  
  
var target *MyError
errors.As(err, &target)
  
  

πŸ‘‰ It also traverses the entire tree until it finds a matching type.


⚠️ Important implications

1. Order does NOT matter

  
  
errors.Join(err1, err2)
errors.Join(err2, err1)
  
  

πŸ‘‰ Both behave the same for Is / As


2. No short-circuit guarantees

Even if the first error matches, Go may still explore others internally.

πŸ‘‰ Don’t rely on evaluation order.


3. Can create deep trees

  
  
err := errors.Join(
	errors.Join(err1, err2),
	errors.Join(err3, err4),
)
  
  

πŸ‘‰ This builds a tree of trees, and Go will traverse all of it.


🧠 Mental model

Think of errors.Join as:

β€œThis operation failed for multiple independent reasons”

Not:

β€œThis error happened because of another error”

That distinction is key:

  • fmt.Errorf("%w") β†’ causality
  • errors.Join(...) β†’ aggregation

πŸš€ Why this design is powerful

Because Go extended the error model from:

  • linear chains β†’ to β†’ general trees

Without breaking compatibility.

πŸ‘‰ Old code still works
πŸ‘‰ New code can express richer failure states


πŸ’‘ Bonus: inspect manually

You can type-assert and inspect the tree:

  
  
if u, ok := err.(interface{ Unwrap() []error }); ok {
	for _, e := range u.Unwrap() {
		fmt.Println("child:", e)
	}
}
  
  

πŸ‘‰ This is the core reason errors.Join feels β€œmagical”:
it upgrades Go’s error model from a linked list β†’ to a traversable graph


πŸ’‘ When to use errors.Join

Use it when:

  • validating input (multiple fields)
  • batch processing (multiple failures)
  • cleanup operations (multiple resources failing to close)

Avoid it when:

  • only one error matters (simpler code is better)

πŸš€ Pro tip

If you want structured error handling, combine it with sentinel errors:

  
  
var ErrInvalidEmail = errors.New("invalid email")
var ErrInvalidName  = errors.New("invalid name")
  
  

Then you can still do:

  
  
if errors.Is(err, ErrInvalidEmail) {
    // handle specifically
}