Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • error-handling
  • errors-join
  • errors-is
  • multierror
  • debugging

Go's errors.Join: Combining Errors Without Losing errors.Is

Validating a config file, closing several resources in a defer chain, running a batch of independent checks: these all produce the same annoying shape of problem. 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. 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.

What Join actually returns

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. First, 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]. Second, and this is the actual point of the feature, Unwrap returns a slice, not a single error.

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.

Why this matters in practice

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.

This is the reason errors.Join is a genuinely different tool from just 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.

The nil handling is worth knowing precisely

errors.Join filters out nils before it does anything else, and 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

This matters 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 to avoid manufacturing 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.

The other half: fmt.Errorf with multiple %w

The same Go 1.20 release quietly extended fmt.Errorf to accept more than one %w verb, which 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. Use this when you want a single human-readable sentence around two related failures; use errors.Join when 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.

Where it doesn't help

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, with 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, and you get the errors.Is/errors.As behaviour for free just by implementing that one method correctly.

It's also worth remembering that 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.