1. Why multi-error matters in imports
When you write a CSV import, it’s very easy out of habit to do “fail fast”: you find an issue on row No. 2 — you immediately return err. That approach is honest, but not always kind. The user fixes row No. 2, runs the import again, sees an error on row No. 17, then on row No. 23… and starts suspecting you’re messing with them (possibly not without reason).
Import is almost always a batch operation. The user brings a file, and your job is to produce a report of what’s wrong with it. And the more issues you show in one run (within reason), the smaller the “fixed → ran again → fixed again” loop becomes.
Plus it simply saves time: data errors often come in “clusters”. And as developers we want to be not only strict, but helpful.
Let’s first look at the “naive” code that breaks UX.
// Bad UX: fail on the very first error.
if err := validateRecord(rec, recNo); err != nil {
return nil, err
}
It’s correct, but for imports you often want a different model: collect issues and keep going.
For clarity, you can imagine an import like this:
flowchart TD
A[Read CSV] --> B[Parse record]
B --> C{Validation OK?}
C -- yes --> D[Add to results]
C -- no --> E[Store error]
D --> F{More records?}
E --> F
F -- yes --> B
F -- no --> G[If there were errors: return them all]
And that “return them all” is exactly what this lecture is about.
2. Reminder: errors in Go are values
Before writing errors.Join, it’s useful to return to Go’s core idea: errors are values. This is not just philosophy for a mug that says “I ❤️ Go”, but a practical principle: errors can be accumulated, wrapped, analyzed, and used to choose a handling strategy.
In Go 1.13, a standard “cause chain” mechanism was added: an error can “wrap” another error via Unwrap() error, and then errors.Is/errors.As can search for the required cause inside the chain.
And starting with Go 1.20, there is a standard way to do almost the same thing, but for multiple errors: errors.Join. It returns a single error that contains a list of causes, available via Unwrap() []error.
The idea is simple: outwardly we return one error value (that’s more convenient for an API), but inside we can keep many details.
3. Minimal model: importing tasks from CSV
To avoid writing an “abstract import of abstract entities”, let’s continue our learning line with tasks. Let the CSV contain rows of the form:
- id — an integer
- title — a required string
- done — true/false
The Go struct (it should already be familiar from earlier lessons about struct):
package task
type Task struct {
ID int
Title string
Done bool
}
The import signature we’re aiming for:
func ImportCSV(r io.Reader) ([]Task, error)
Why return both tasks and an error? Because the strategy “accept valid rows and report invalid ones” is perfectly viable. Sometimes you may choose the strategy “if there is at least one error, import nothing”, but even in that case multi-error is still useful: the user report becomes complete.
In this lecture we’re not arguing about the policy “partially accept or not”. We’re learning the technique: how to collect multiple errors correctly.
4. What errors.Join is and why it’s convenient
errors.Join is a standard library function that takes multiple errors and returns one. The most important things for real-world imports:
- errors.Join(nil, nil) returns nil (i.e., “there were no errors”).
- errors.Join(err1, nil, err2) ignores nil and joins only the real errors.
- the returned error supports unwrapping a list of causes via Unwrap() []error.
This means we can collect a []error in a loop, and at the end do:
return tasks, errors.Join(errs...)
And get one error that you can:
- just print (often that’s already enough for a CLI);
- analyze programmatically (for example, checking errors.Is/errors.As against each cause — the mechanism is based on the unwrap idea, just like in Go 1.13).
Important: errors.Join is not “magic”, it’s just a well-designed standard container for a set of errors. For imports, it hits the mark.
5. Make errors self-contained: add row context
If we simply put errors like errors.New("invalid id") into errs, the final report will be useless: “invalid id” — where? on which row? which value? which column?
So we need a small error type that adds context: record number (we count it ourselves), column name, and the raw value. And ideally it should keep the “cause” via Unwrap, so we can use errors.Is/errors.As on the nested error.
Minimal version:
package taskcsv
import "fmt"
type RowError struct {
Row int
Col string
Val string
Err error
}
func (e *RowError) Error() string {
return fmt.Sprintf("row %d, col %s: %q: %v", e.Row, e.Col, e.Val, e.Err)
}
func (e *RowError) Unwrap() error {
return e.Err
}
Note: we implemented Unwrap() error, i.e., one specific underlying cause error. This is useful so that errors.As and errors.Is can “dig” down to the original cause (for example, an error from strconv.Atoi).
6. Collect errors in a loop: validation must not break the whole import
Now imagine we have a parseTask function that tries to turn []string (one CSV record) into a Task. If it fails, it returns an error.
Key point: if parseTask returns an error, we do not have to stop the import right away. We can record the error in a list and continue.
The import skeleton looks like this:
package taskcsv
import (
"encoding/csv"
"errors"
"io"
)
func ImportCSV(r io.Reader) ([]Task, error) {
cr := csv.NewReader(r)
header, err := cr.Read()
if err != nil {
return nil, err
}
_ = header // we will process the header "properly" in a neighboring lecture
var tasks []Task
var errs []error
recNo := 1 // header = 1
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
recNo++
t, err := parseTask(rec, recNo)
if err != nil {
errs = append(errs, err)
continue
}
tasks = append(tasks, t)
}
return tasks, errors.Join(errs...)
}
Notice the two different handling branches.
We exit immediately if it’s a file read / CSV parsing error (for example, the stream is broken). That’s a “technical” error — continuing the import makes no sense.
But if it’s a data error for a specific row, we add it to errs and keep going.
7. How to join errors neatly: helper joinAll
You can write errors.Join(errs...) directly, but for beginners it’s often useful to make the intent explicit: “if there are no errors — return nil”.
Why? Because errs is a slice, and you want the code to read like a protocol. That reduces the chance of accidentally returning an “empty” error later.
Let’s make a small helper:
package taskcsv
import "errors"
func joinAll(errs []error) error {
if len(errs) == 0 {
return nil
}
return errors.Join(errs...)
}
And use it:
return tasks, joinAll(errs)
It’s a small thing, but it makes the code more “self-describing”. And readability is the kind of thing you start appreciating exactly when you’re debugging an import on a Friday evening.
8. Example parseTask: parse fields and add context
Let rec be []string{ "10", "Buy milk", "false" }.
We’ll parse id, validate title, parse done. We’ll wrap errors in RowError so that the caller gets “where and why”.
package taskcsv
import (
"errors"
"strconv"
"strings"
)
var ErrEmptyTitle = errors.New("title is required")
func parseTask(rec []string, row int) (Task, error) {
idRaw := strings.TrimSpace(rec[0])
id, err := strconv.Atoi(idRaw)
if err != nil {
return Task{}, &RowError{Row: row, Col: "id", Val: idRaw, Err: err}
}
title := strings.TrimSpace(rec[1])
if title == "" {
return Task{}, &RowError{Row: row, Col: "title", Val: rec[1], Err: ErrEmptyTitle}
}
doneRaw := strings.TrimSpace(rec[2])
done, err := strconv.ParseBool(doneRaw)
if err != nil {
return Task{}, &RowError{Row: row, Col: "done", Val: doneRaw, Err: err}
}
return Task{ID: id, Title: title, Done: done}, nil
}
There are two important practical ideas here.
First, we don’t lose the original cause: if Atoi/ParseBool returned an error, it goes into Err and is available via Unwrap() error.
Second, we introduce our own domain errors (ErrEmptyTitle). This is convenient because such errors can be reliably recognized via errors.Is (including inside wrappers).
9. How to display and handle a joined error
What the user will see
Imagine the CSV has two problems: on one row id is not a number, on another row title is empty.
We accumulated errors, called errors.Join, and returned one error upward. What happens if we print it?
Here is an example “demo output” (simplified):
if err != nil {
println(err.Error())
}
The point is that Join typically formats things so multiple messages are visible. For a CLI tool this is already pretty good: the user sees several issues and can fix the file in one pass.
But sometimes you want prettier output: for example, a prefix “Import failed:” and then one line per issue. For that, we need to be able to extract individual causes from the joined error.
How to unwrap errors.Join back into a list of errors
errors.Join returns a type that holds a list internally and exposes it via Unwrap() []error.
This type is not necessarily exported, so we usually do a type assertion to an interface:
package taskcsv
type unwrapper interface {
Unwrap() []error
}
func unwrapMany(err error) []error {
u, ok := err.(unwrapper)
if !ok {
return nil
}
return u.Unwrap()
}
Now you can produce a “human” report:
errs := unwrapMany(err)
for _, e := range errs {
println("-", e.Error())
}
Why is this important specifically for imports? Because import is exactly where the user expects a “list of problems” report. And errors.Join lets you keep that in a single error (convenient for an API), while still allowing you to unwrap and format it nicely if needed.
Checking errors.Is for our errors inside a joined error
Suppose we want to know: “was there at least one row in the file with an empty title?”. We made ErrEmptyTitle as a separate error — great. Now we can do:
if errors.Is(err, ErrEmptyTitle) {
println("The file contains tasks without a title") // example message
}
The key is that we are not comparing strings; we are checking error semantics via errors.Is, which can “dive into” wrappers.
This is especially nice when there are many errors: we can both show a report to the user and make a decision in code (for example, return exit code 2 for “data errors”).
10. Import strategies: fail-fast and collect-all
When you design an import, you are essentially choosing a strategy. Below is a compact table to capture what exactly changes.
| Strategy | Behavior | Pros | Cons | How to implement |
|---|---|---|---|---|
| Fail-fast | Stop on the first error | Simpler code, less “state” | Bad UX for batch input | return err immediately |
| Collect-all | Collect all errors and continue | Better UX, fewer reruns | Slightly more complex code, need to store errors | errs = append(errs, err) + errors.Join |
errors.Join is good here because it doesn’t force you to invent your own “mega error type” and doesn’t break contracts: you still return a normal error, it just may contain multiple causes inside.
11. Common mistakes
Mistake No. 1: collecting errors as strings instead of error values.
Sometimes you want to build a []string and then join it with "\n". It’s quick, but you lose Go’s main advantage: errors are values. You won’t be able to use errors.Is, you won’t be able to “dig down” to strconv.Atoi, and you won’t be able to handle one class of errors differently from another. It’s much better to accumulate []error, and treat text as a presentation concern.
Mistake No. 2: returning errors.Join(errs...) but forgetting the “no errors” case.
Formally, errors.Join() will return nil if all arguments are nil, but beginners often get confused about when errs is empty, when it contains nil, and why sometimes “there is an error, but nothing prints”. The safest option is to keep joinAll(errs) with an explicit check len(errs) == 0.
Mistake No. 3: not adding context (row/column/value).
invalid syntax without saying “where” in a CSV file is almost mocking. In imports, always add context: record number, column name, and the raw value. Otherwise the user will be forced to scan the whole file.
Mistake No. 4: mixing CSV read/parse errors with data errors.
If csv.Reader returned a read/parsing error, continuing is usually pointless: you don’t know where you are in the stream. But data errors in a specific record are exactly the candidate for accumulation. If you mix these classes, you can end up in a situation where “we continued importing after the stream became invalid”, and the results will be unpredictable.
Mistake No. 5: trying to “parse” a multi-error via err.Error() and regexes.
The moment you start parsing the error text, you’ve lost: the format can change, localization can change, and in general it’s brittle. If you need to handle causes programmatically, use errors.Is/errors.As and Unwrap interfaces.
GO TO FULL VERSION