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
nilif all inputs arenil - Automatically ignores
nilerrors - 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:
- Check
err == target - If not, call
Unwrap() - If:
Unwrap() errorβ continue down the chainUnwrap() []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")β causalityerrors.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
}