How do you use errors.Join to collect per-item failures in a loop instead of stopping at the first?
answer
- one slice, one join, at the end
- say which item each failure belongs to
- the empty case answers itself
- re-joining every iteration grows a deep tree
- independence decides collect versus stop
basics
~20 sAppend each failure, wrapped with the item it belongs to, into a local []error and return errors.Join(errs...) once. Join returns nil when the slice is empty, so the success path needs no length check. Avoid re-joining pairwise inside the loop.
solid answer
~50 sThe idiom is one local slice and one Join. Declare `var errs []error` before the loop; inside, on failure, append `fmt.Errorf("fetch %s: %w", path, err)` so each cause carries the identity of the item it belongs to; after the loop, `return errors.Join(errs...)`. Because Join returns nil when nothing survives, the all-succeeded path returns a genuine nil without an `if len(errs) == 0` guard. What you should not do is `err = errors.Join(err, e)` on every iteration: Join does not flatten, so each call nests the previous result as a child and you end up with a tree as deep as the loop is long, whose message has to be rebuilt level by level. The real decision this shape forces is upstream: collecting is right for independent items like validation or a fan-out fetch, and wrong when a failure means the remaining iterations are pointless or unsafe.
code
go · 9 linesfunc fetchAll(paths []string) error {
var errs []error
for _, p := range paths {
if err := fetch(p); err != nil {
errs = append(errs, fmt.Errorf("fetch %s: %w", p, err))
}
}
return errors.Join(errs...) // nil when errs is empty
}go deeper
Practise the shape until it is automatic: declare a slice before the loop, append the wrapped failure inside it, and join once at the end. Know that the success path returns nil on its own.
Explain why re-joining on each iteration nests rather than flattens, and why each cause must be wrapped with the identity of the item it came from before it goes into the slice.
Demonstrate the judgment call: which loops may keep going after a failure, which must stop because the failure will not clear, and how you keep the accumulation race-free when the body runs concurrently.
Set the house rule on collect-versus-fail-fast for the codebase, including what an operator sees. Getting this wrong turns one bad dependency into a thousand-line error and a report nobody reads.
## The shape ```go func fetchAll(paths []string) error { var errs []error for _, p := range paths { if err := fetch(p); err != nil { errs = append(errs, fmt.Errorf("fetch %s: %w", p, err)) } } return errors.Join(errs...) } ``` Three things make this work, and each is worth being able to justify. **One slice, one Join at the end.** The slice is a plain `[]error` and starts nil; appending to a nil slice is fine, so no initialisation is needed. The single Join at the end produces one value with a flat list of causes. **No emptiness check.** `errors.Join` returns nil when no non-nil argument survives, so the success path returns a true nil error. Writing `if len(errs) > 0 { return errors.Join(errs...) }; return nil` is not wrong, only redundant. And returning a hand-built multi-error type unconditionally *is* wrong — that is the typed-nil trap, where a non-nil interface holds an empty value and every `if err != nil` upstream fires. **Identity on every cause.** Join records what failed, never which item failed. Without the `fmt.Errorf("fetch %s: %w", p, err)` wrap, forty timeouts produce forty identical lines and the report is useless. Wrapping with `%w` keeps the cause matchable, so a caller can still ask `errors.Is(err, context.DeadlineExceeded)`. ## Why not join pairwise inside the loop ```go var err error for _, p := range paths { err = errors.Join(err, fetch(p)) // don't } ``` This compiles and mostly behaves, which is why it survives review. The problem is that Join does not flatten: passing an already-joined error as an argument nests it as a single child. After n failing iterations you have a tree n levels deep rather than one node with n children. Rendering the message then costs work at every level, because each level's text is built by concatenating the level below it, and inspection has to descend n levels instead of scanning one slice. The accumulate-then-join-once shape avoids both and reads better besides. A related mistake is joining inside a nested loop and letting the outer level join the inner joins. That is defensible when the nesting is *meaningful* — one joined error per file, joined again per directory, so the tree mirrors the structure and a reader can tell which file a failure came from. It is a bug when the nesting is accidental. ## Concurrency If the loop body runs in goroutines, the slice needs protection: `append` from several goroutines is a data race, and the race detector will flag it under `go test -race`. The usual answers are a mutex around the append, a results channel drained by one goroutine, or a pre-sized slice where each worker writes its own index — `errs := make([]error, len(paths))` and `errs[i] = ...` — which is race-free without a lock because each index is written by exactly one goroutine. That last one leans on Join's nil-dropping: the slots for successful items stay nil and disappear at the join. ## The decision the shape forces Collect-all is not automatically better than fail-fast. Ask three questions: - **Are the items independent?** Validating ten fields, closing five resources, or fetching forty unrelated modules: yes. A pipeline where step two consumes step one's output: no. - **Is continuing safe?** After a failure that indicates a broken invariant, exhausted credentials or a saturated dependency, the remaining iterations may make things worse rather than gathering information. A rate-limit failure on module one usually means the next thousand will also fail, and collecting a thousand of them helps nobody. - **Is the caller better off with all of it?** A user fixing a config file wants every problem at once. An on-call engineer usually wants the first failure and a count. When the answer is mixed, a hybrid works: collect, but break out of the loop when the failure is one that will not clear — check `errors.Is` against the fatal sentinels inside the loop and return early, joining what you have. ## Reporting Because the caller usually wants both a summary and the detail, it is common to pair the joined error with a count you tracked yourself. The joined value cannot tell you how many items you attempted, only how many failed, so if the report is "37 of 400 modules failed", the 400 is yours to carry.
- What goes wrong if you return your own multi-error struct unconditionally instead of calling errors.Join?You hit the typed-nil trap. A non-nil interface holding an empty struct is not equal to nil, so every `if err != nil` upstream fires even though nothing failed. Join sidesteps it by returning an untyped nil when no cause survives.
- The loop body now runs in goroutines. What changes about the accumulation?Appending to a shared slice from several goroutines is a data race that `-race` will report. Guard it with a mutex, funnel results through a channel, or pre-size a slice and give each worker its own index — the nil slots for successes are dropped when you join.
- When would you stop the loop early rather than collect everything?When the failure will not clear — an authentication rejection, a rate limit, a broken invariant — or when later iterations depend on earlier ones. Collecting a thousand copies of the same fatal condition costs time and memory and tells the reader nothing new.
saying these in an interview costs you the question
- Calls errors.Join pairwise on every iteration and nests the result
- Adds an if len(errs) == 0 guard believing Join needs it
- Joins bare causes with no per-item identity
- Returns a custom empty multi-error struct instead of nil
- Appends to the shared slice from goroutines with no synchronisation