Combining Several Errors in Go Without Breaking errors.Is
Sometimes you don't have one error, you have several, and Go's error handling was built almost entirely around the assumption that a function returns exactly one.
The same annoying shape turns up all over the place:
- validating a config file
- closing several resources in a defer chain
- running a batch of independent checks
For years the answer was either "return the first error and swallow the rest" or reach for a third-party multierror package. Since Go 1.20 there's a stdlib answer: errors.Join.
It looks almost too simple to be worth an article:
func validate(cfg Config) error {
var errs []error
if cfg.Port == 0 {
errs = append(errs, fmt.Errorf("port: %w", errMissing))
}
if cfg.Host == "" {
errs = append(errs, fmt.Errorf("host: %w", errMissing))
}
return errors.Join(errs...)
}
The interesting part isn't the constructor. It's what it has to do to keep errors.Is and errors.As working across a list instead of a chain. That's the bit worth actually reading the source for.
Join returns a type whose Unwrap gives a slice
errors.Join doesn't concatenate error strings and hand you a plain errors.New value. It returns a value of an unexported type, joinError, that holds a slice of the non-nil errors you passed in:
type joinError struct {
errs []error
}
func (e *joinError) Error() string {
var b []byte
for i, err := range e.errs {
if i > 0 {
b = append(b, '\n')
}
b = append(b, err.Error()...)
}
return string(b)
}
func (e *joinError) Unwrap() []error {
return e.errs
}
That's a slightly abridged version, but the shape is exact. Two things matter here:
Error()just joins each message with a newline, so printing a joined error gives you a readable multi-line block rather than something like[err1 err2].- This is the actual point of the feature:
Unwrapreturns a slice, not a single error.
The error chain became a tree
Before Go 1.20, Unwrap() error was the only shape the error tree understood, which is why wrapping is usually described as a chain. errors.Is and errors.As walk that chain by repeatedly calling Unwrap on whatever comes back.
Go 1.20 added a second interface, interface{ Unwrap() []error }, and taught both functions to recognise it. When they hit a value that implements the slice form, they recurse into every element instead of following a single link.
A "chain" becomes a tree, and the traversal is depth-first across all of it.
That's the whole trick. Nothing about errors.Is's comparison logic changed; it still does an equality check or calls Is(error) bool on each node. What changed is that a node can now have multiple children.
You can find one error in the pile, however deep
Take the validate example again, and imagine a caller wants to react specifically to a missing host, regardless of what else failed:
err := validate(cfg)
if errors.Is(err, errMissing) {
// true even though errMissing might be one of several
// wrapped errors buried inside a joinError
}
Without the slice-aware Unwrap, this would only work if errMissing happened to be the single error you returned.
With errors.Join, it works no matter how many other errors are sitting alongside it in the tree, and no matter how deep it's nested if you join joins of joins. errors.As behaves the same way for extracting a concrete type out of the pile.
That's why errors.Join is a genuinely different tool from doing strings.Join on a list of .Error() strings, or wrapping errors in a custom slice type with a naive Error() method. Those approaches lose the tree structure the moment you need to interrogate the result programmatically rather than just log it.
All-nil input gives you a real nil
errors.Join filters out nils before it does anything else. If everything you pass in is nil (including calling it with zero arguments), it returns a genuine untyped nil, not a non-nil *joinError wrapping nothing:
err := errors.Join(nil, nil)
fmt.Println(err == nil) // true
Hang on, why does that matter? Because the classic Go footgun is a typed nil hiding inside an interface, where err != nil even though there's "nothing wrong". errors.Join is careful not to manufacture that trap itself.
It's a good habit to build the same nil-check into any batch operation you write by hand:
func closeAll(closers ...io.Closer) error {
var errs []error
for _, c := range closers {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
If every Close succeeds, errs stays nil, and errors.Join(errs...) (spreading a nil slice) correctly returns nil rather than an empty-but-non-nil error.
fmt.Errorf can wrap more than one error too
The same Go 1.20 release quietly extended fmt.Errorf to accept more than one %w verb. That builds the same kind of multi-child tree without going through errors.Join at all:
err := fmt.Errorf("processing batch %d: %w and %w", id, err1, err2)
Internally this constructs a wrapErrors type with the same Unwrap() []error shape. Which to reach for:
fmt.Errorfwith several%wwhen you want a single human-readable sentence around two related failures.errors.Joinwhen you're accumulating an open-ended list and don't want to hand-format the message yourself.
They compose too. Nothing stops you joining several already-wrapped errors, or wrapping a joined error with more context further up the call stack.
It won't format errors for users
errors.Join solves the traversal problem. It doesn't solve the "what do I show the user" problem.
The default Error() output is every message on its own line, in the order given. There's no indication of severity, no deduplication, and no structure beyond that. For a config validator that's often exactly what you want.
For an API that needs to return a machine-readable list of field errors, you still want your own type with its own Unwrap() []error method. You get the errors.Is/errors.As behaviour for free just by implementing that one method correctly.
Probing a huge tree isn't free
errors.Is walking every branch of a large tree is O(n) in the number of leaves for each check. That's rarely a problem for the handful of errors a validation pass produces.
But if you're joining thousands of errors from, say, a bulk import and then repeatedly probing the result with errors.Is in a hot loop, it stops being free. At that point you probably want to classify errors once while collecting them, rather than re-walking the tree afterwards.