DEV Community

Cover image for errors.Join vs Multi-Return: When to Aggregate, When to Wrap
Gabriel Anhaia
Gabriel Anhaia

Posted on

errors.Join vs Multi-Return: When to Aggregate, When to Wrap


You write a validation function. Five rules. Email format, password length, age range, country code, terms accepted. The user fixes the email, hits submit, gets back "password too short." Fixes that, hits submit, gets back "invalid country." Fixes that, hits submit, gets back "must accept terms."

Four round trips. Each one because your function bailed on the first error and never looked at the rest.

errors.Join landed in the standard library in Go 1.20 and most code still does not use it. The reflex from years of if err != nil { return err } is hard to break. The first failure stops the chain. That reflex is right most of the time and wrong in three specific cases, and knowing which case you are in is the whole skill.

What errors.Join actually does

errors.Join(err1, err2, err3) returns a single error that wraps all the non-nil arguments. Its Error() method renders each wrapped error on its own line. errors.Is and errors.As walk into every branch of the tree, not just the first one. If every argument is nil, it returns nil. That is the property that makes the aggregate pattern read cleanly.

errs := errors.Join(
    validateEmail(in.Email),
    validatePassword(in.Password),
    validateAge(in.Age),
)
if errs != nil {
    return errs
}
Enter fullscreen mode Exit fullscreen mode

Three rules, one call, one branch. If two fail you see both. If all three pass you continue.

The pre-1.20 way to do this used github.com/hashicorp/go-multierror:

var result *multierror.Error
result = multierror.Append(result, validateEmail(in.Email))
result = multierror.Append(result, validatePassword(in.Password))
result = multierror.Append(result, validateAge(in.Age))
return result.ErrorOrNil()
Enter fullscreen mode Exit fullscreen mode

Same idea, two more lines, an extra dependency, and a custom type that does not interoperate with errors.Is out of the box. multierror was a fine answer for its decade. The standard library is the right answer now.

Case 1: parallel work where every failure matters

The validation harness is the obvious one. The interesting one is the fan-out RPC. You have a request that needs four downstream services to enrich a response. Two of them fail. The user gets a 500 with the message "service B unavailable" and you spend the next hour debugging service B before realising service D was also down for a different reason.

Aggregate.

func enrich(ctx context.Context, id string) (*View, error) {
    var (
        wg   sync.WaitGroup
        mu   sync.Mutex
        errs []error
        view View
    )

    run := func(label string, fn func() error) {
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := fn(); err != nil {
                mu.Lock()
                errs = append(errs, fmt.Errorf("%s: %w", label, err))
                mu.Unlock()
            }
        }()
    }

    run("profile", func() error { return fetchInto(ctx, profileURL(id), &view.Profile) })
    run("orders", func() error { return fetchInto(ctx, ordersURL(id), &view.Orders) })
    run("cart", func() error { return fetchInto(ctx, cartURL(id), &view.Cart) })
    run("promo", func() error { return fetchInto(ctx, promoURL(id), &view.Promo) })
    wg.Wait()

    if err := errors.Join(errs...); err != nil {
        return nil, fmt.Errorf("enrich %s: %w", id, err)
    }
    return &view, nil
}
Enter fullscreen mode Exit fullscreen mode

The errs slice collects whatever each goroutine reported. errors.Join(errs...) collapses it to a single value: nil if every goroutine succeeded, or a wrapping error containing every failure if any did not. The caller gets one error with a tree of causes. The log line names all four services that failed in one breath instead of one at a time over four retries.

golang.org/x/sync/errgroup is the other half of this conversation. errgroup.WithContext cancels siblings when the first goroutine errors, which is what you want when you only need any result and the rest is wasted work. Use errgroup when failures are interchangeable. Use errors.Join over a slice when you need to know about all of them.

Case 2: cleanup paths where multiple defers can each fail

The other place the multi-return reflex falls over is teardown. You open three resources, you defer three closes, and any of them can fail. The deferred closes do not return errors to the caller because defer does not have a return-value channel. The pre-1.20 fix was a named return and a tangle of nested closures.

func process(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("close: %w", cerr)
        }
    }()
    // ... and the same shape repeats per resource
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Two resources turn into four conditional close handlers. The && err == nil guard is the bug nursery. It silently drops the close error if the body already failed. With errors.Join you stop pretending the close error is secondary.

func process(path string) (err error) {
    f, openErr := os.Open(path)
    if openErr != nil {
        return fmt.Errorf("open: %w", openErr)
    }
    defer func() {
        err = errors.Join(err, f.Close())
    }()

    tx, txErr := db.Begin()
    if txErr != nil {
        return fmt.Errorf("begin tx: %w", txErr)
    }
    defer func() {
        err = errors.Join(err, tx.Rollback())
    }()

    return doWork(f, tx)
}
Enter fullscreen mode Exit fullscreen mode

The named return on the signature is what makes this work. return doWork(f, tx) assigns the result to err before the defers run, so the deferred errors.Join(err, ...) sees the body's error and folds in the close errors on top. Without the named return, the defers operate on a fresh err == nil and the close errors leak silently.

If the body returns nil and both closes succeed, the deferred errors.Join(nil, nil) returns nil and the function returns nil. If the body fails and the file close also fails, both are reported. The reader does not have to argue with themselves about which error is "really" the one to surface.

There is one trap. tx.Rollback() on a committed transaction returns sql.ErrTxDone. If the happy path ends with tx.Commit(), the deferred Rollback() will produce that error every time. The fix is the same one you would write without errors.Join: ignore the known-OK-after-commit case explicitly.

defer func() {
    rerr := tx.Rollback()
    if !errors.Is(rerr, sql.ErrTxDone) {
        err = errors.Join(err, rerr)
    }
}()
Enter fullscreen mode Exit fullscreen mode

The point is not that errors.Join makes cleanup automatic. The point is that it stops the close error from disappearing.

Case 3: when you specifically do not want this — sequential code

This is the line a lot of teams get wrong after they discover errors.Join. They start aggregating everything, and a flow that should bail on the first failure ends up calling four functions that each operate on the broken state of the previous one.

// don't do this
order, err1 := loadOrder(id)
charge, err2 := chargeCard(order)
record, err3 := recordPayment(charge)
return errors.Join(err1, err2, err3)
Enter fullscreen mode Exit fullscreen mode

If loadOrder fails, order is nil, chargeCard(nil) panics. The aggregate pattern only works when each input was independent and each call could run regardless of the others. The validation rules above qualify because no rule needs the result of any other rule. The teardown above qualifies because each resource was opened independently. Sequential business logic does not qualify.

The rule is mechanical. If function B reads the output of function A, return on A's error before calling B. That is what if err != nil { return err } is for. Do not replace it with errors.Join. The two patterns are not interchangeable; they answer different questions.

errors.Is and errors.As walk the whole tree

A joined error is a tree, not a list. errors.Is(joined, sentinel) returns true if any wrapped error matches the sentinel. errors.As(joined, &typed) binds the first match it finds.

errs := errors.Join(
    fmt.Errorf("rule one: %w", ErrTooShort),
    fmt.Errorf("rule two: %w", ErrInvalidEmail),
)

errors.Is(errs, ErrTooShort)     // true
errors.Is(errs, ErrInvalidEmail) // true
errors.Is(errs, ErrUnknown)      // false
Enter fullscreen mode Exit fullscreen mode

That is what you want for retry logic ("did any sub-call hit a transient error?") and for HTTP mapping ("did any sub-call hit a not-found?"). The gotcha shows up with errors.As when the same error type appears twice. errors.As binds the first match it finds, walking the tree depth-first in the order arguments were passed to Join. The second instance is reachable only if you reset the binding and search again from the next branch. The standard library does not give you a helper for that. If you join two *ValidationError values:

joined := errors.Join(
    &ValidationError{Field: "email"},
    &ValidationError{Field: "age"},
)

var v *ValidationError
if errors.As(joined, &v) {
    // v is the FIRST ValidationError (Field: "email").
    // The second one (Field: "age") is invisible to this block.
}
Enter fullscreen mode Exit fullscreen mode

The fix when you know aggregation can produce duplicate types is to walk the tree yourself with errors.Unwrap overloaded for []error:

type unwrapper interface {
    Unwrap() []error
}

func collect[T error](err error) []T {
    var out []T
    var visit func(error)
    visit = func(e error) {
        if e == nil {
            return
        }
        var match T
        if errors.As(e, &match) {
            out = append(out, match)
        }
        if u, ok := e.(unwrapper); ok {
            for _, sub := range u.Unwrap() {
                visit(sub)
            }
        } else if u := errors.Unwrap(e); u != nil {
            visit(u)
        }
    }
    visit(err)
    return out
}
Enter fullscreen mode Exit fullscreen mode

collect[*ValidationError](joined) returns every *ValidationError in the tree. The Unwrap() []error interface is exactly what errors.Join returns under the hood, which is why this works without reflection. The standard library may grow a helper for this in a future release; for now, a small generic helper covers it.

A short decision rule

When you sit down to write the function, ask one question before you reach for either pattern.

Does each fallible call's success or failure depend on the result of the previous one?

  • Yes — sequential. Multi-return + %w, return on the first error.
  • No — independent. errors.Join over the slice, branch once at the end.

Use the right shape for each, and the next time someone fixes their email and hits submit, they get back every reason their form is broken in one response.


If this clicked

Error handling is one of the longer chapters in The Complete Guide to Go Programming: the wrap chain, sentinel vs typed errors, when panic is the right answer, and the errors package patterns most tutorials skip. Hexagonal Architecture in Go picks up where the language model leaves off, with how typed errors flow across adapter boundaries in real services.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)