JavaRush /Courses /Go SELF /Multi-error in practice — errors.Join for imports

Multi-error in practice — errors.Join for imports

Go SELF
Level 47, Lesson 3
Available

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.

1
Task
Go SELF, level 47, lesson 3
Locked
Collection of failures
Collection of failures
1
Task
Go SELF, level 47, lesson 3
Locked
Unpacking causes
Unpacking causes
1
Task
Go SELF, level 47, lesson 3
Locked
Gradebook
Gradebook
1
Task
Go SELF, level 47, lesson 3
Locked
Tracker import
Tracker import
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION