Collect Multiple Errors in Go for Batch Operations
Collect several errors in Go without losing any: fail fast or keep going, errors.Join, per-item results, a cap on the list, and a concurrent version.

A function that does one thing can return one error. A function that does a hundred things, importing rows, sending emails, deleting files, has a choice that a single return err hides. If the third item fails, should the other ninety-seven be attempted? The answer depends on the job, and getting it wrong in either direction is a common source of bugs: a nightly import that dies on the first bad row and leaves the rest unprocessed, or one that keeps going, swallows every failure and reports success.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article covers the patterns for collecting errors in batch work, from the simplest to the concurrent. It belongs to the error handling guide, and the standard-library function it leans on is covered on its own in errors.Join. Everything ran on Go 1.25.6.
Decide: stop or continue
Two policies cover nearly everything.
Fail fast. Stop at the first error and return it. This is right when the items depend on each other (step two needs step one), when partial work would leave things inconsistent, or when the first failure means every later one is likely to fail too, such as a database that has gone away. It is also the simplest, and it is the default: if err != nil { return err }.
Collect and continue. Process every item, remember each failure, and report all of them at the end. This is right when items are independent: validating a form, importing rows, deleting a set of files. The caller gets the complete picture in one pass.
Don't mix them by accident. A loop that logs a failure and continues, then returns nil, has chosen "continue" and also chosen to hide the failures, which is almost never intended.
The basic pattern
For independent items, append and join:
package main
import (
"errors"
"fmt"
"strconv"
)
func parseAll(inputs []string) ([]int, error) {
var (
out []int
errs []error
)
for i, s := range inputs {
n, err := strconv.Atoi(s)
if err != nil {
errs = append(errs, fmt.Errorf("item %d: %w", i, err))
continue
}
out = append(out, n)
}
return out, errors.Join(errs...)
}
func main() {
nums, err := parseAll([]string{"1", "two", "3", "", "5"})
fmt.Println(nums)
fmt.Println(err)
}[1 3 5]
item 1: strconv.Atoi: parsing "two": invalid syntax
item 3: strconv.Atoi: parsing "": invalid syntaxThree details do the work. Each error is wrapped with the item's position, which turns invalid syntax into something a person can act on. The loop continues past the bad item and keeps the good ones, so the function returns the partial result and the error together, which is unusual in Go but right here: the caller gets the data that did parse and the list of what did not. And errors.Join(errs...) returns nil when errs is empty, so the success path needs no extra check.
Document the partial return. A convention that "the result is only valid when the error is nil" is the norm in Go, and a function that deviates must say so in its doc comment.
Telling callers which items failed
A joined error is a good report for a person and a poor interface for a program. If the caller wants to retry only the failed items, or write the failures to a rejects file, it needs structure. Return a result per item:
package main
import (
"errors"
"fmt"
"strings"
)
type Result struct {
Index int
Item string
Err error
}
var ErrEmpty = errors.New("empty name")
func process(items []string) []Result {
results := make([]Result, len(items))
for i, it := range items {
results[i] = Result{Index: i, Item: it}
if strings.TrimSpace(it) == "" {
results[i].Err = ErrEmpty
}
}
return results
}
func main() {
var failed []string
for _, r := range process([]string{"ada", " ", "linus", ""}) {
if r.Err != nil {
failed = append(failed, fmt.Sprintf("#%d: %v", r.Index, r.Err))
}
}
fmt.Println(len(failed), "failed:", failed)
}2 failed: [#1: empty name #3: empty name]The caller decides what to do with each result: log, retry, store. The slice is aligned with the input by index, so results can be matched back to records without searching. You can also build the joined error from the results when you need both views, which is a cheap way to give a command line tool both a human summary and a machine-readable list.
Don't collect without limit
A malformed file with a million rows can produce a million errors, and a Join message of that size is a memory problem and a useless log line. Cap the list and say you did:
package main
import (
"errors"
"fmt"
)
const maxErrors = 3
func validate(rows []int) error {
var errs []error
dropped := 0
for i, r := range rows {
if r >= 0 {
continue
}
if len(errs) == maxErrors {
dropped++
continue
}
errs = append(errs, fmt.Errorf("row %d: negative value %d", i, r))
}
if dropped > 0 {
errs = append(errs, fmt.Errorf("... and %d more errors", dropped))
}
return errors.Join(errs...)
}
func main() {
fmt.Println(validate([]int{-1, 2, -3, -4, -5, -6, 7}))
}row 0: negative value -1
row 2: negative value -3
row 3: negative value -4
... and 2 more errorsThe first three are reported in full and the rest are counted. A compiler works this way (it prints the first handful of errors, then "too many errors") for the same reason: after enough failures the later ones are noise, and the first few usually contain the cause. If the limit would hide something important, stop early instead and return a distinct "aborted after N errors" error so the caller knows the job did not finish.
Collecting from goroutines
Concurrent batches add one problem: many goroutines append to one slice. A slice is not safe for concurrent writes, so protect it, or send errors back over a channel. A mutex is the simplest:
package main
import (
"errors"
"fmt"
"slices"
"sync"
)
type indexed struct {
i int
err error
}
func main() {
items := []int{4, -1, 7, -2, 9, -3}
var (
mu sync.Mutex
errs []indexed
wg sync.WaitGroup
)
for i, v := range items {
wg.Go(func() {
if v < 0 {
mu.Lock()
errs = append(errs, indexed{i, fmt.Errorf("item %d: negative", i)})
mu.Unlock()
}
})
}
wg.Wait()
slices.SortFunc(errs, func(a, b indexed) int { return a.i - b.i })
joined := make([]error, len(errs))
for i, e := range errs {
joined[i] = e.err
}
fmt.Println(errors.Join(joined...))
}item 1: negative
item 3: negative
item 5: negativeThe goroutines finish in any order, so the errors arrive in any order. Sorting by item index before joining makes the output deterministic, which matters for tests and for anyone diffing two runs. wg.Go is the Go 1.25 shorthand for starting a goroutine under a WaitGroup (WaitGroup.Go). If you also want to cancel the rest on the first failure, errgroup from golang.org/x/sync returns the first error and cancels a shared context, which is the fail-fast version of this pattern (errgroup and returning errors from goroutines cover both).
When Join is not enough: a type of your own
errors.Join gives you a joined message and searchable members, and nothing else. If callers want to ask how many items failed, or to iterate over the failures with their keys, write a small type that holds the data and exposes Unwrap() []error so the standard functions still work:
package main
import (
"errors"
"fmt"
"io/fs"
"strings"
)
type BatchError struct {
Total int
Failed map[string]error
}
func (e *BatchError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "%d of %d items failed", len(e.Failed), e.Total)
return b.String()
}
func (e *BatchError) Unwrap() []error {
out := make([]error, 0, len(e.Failed))
for _, err := range e.Failed {
out = append(out, err)
}
return out
}
func main() {
var err error = &BatchError{
Total: 10,
Failed: map[string]error{
"a.txt": fmt.Errorf("delete a.txt: %w", fs.ErrPermission),
"b.txt": fmt.Errorf("delete b.txt: %w", fs.ErrNotExist),
},
}
fmt.Println(err)
fmt.Println(errors.Is(err, fs.ErrPermission), errors.Is(err, fs.ErrNotExist))
var be *BatchError
if errors.As(err, &be) {
fmt.Println("retry candidates:", len(be.Failed))
}
}2 of 10 items failed
true true
retry candidates: 2The one-line message is a summary, and the detail lives in the map for code that wants it. Unwrap() []error is the form added in Go 1.20 for a type with several causes (the 1.20 release notes describe it), and with it errors.Is finds both sentinels in the group. The order of the unwrapped slice follows map iteration, which is random; sort it if a stable order matters to you. Reach for this only when callers use the extra structure. A bare errors.Join is the right default, and a custom type is the right answer when the failures need identity.
Test the batch, including the empty and all-bad cases
A batch function has three interesting inputs: nothing, everything good and everything bad, plus a mix. Check that the empty case returns nil (not an empty joined error that is non-nil), that the all-good case returns nil, and that the mixed case reports exactly the failures and keeps the successes. Assert on errors.Is for the causes and avoid matching the complete multi-line message. Testing error paths shows the table shape. For the underlying API, see the errors.Join documentation.


