JavaRush /Courses /Go SELF /errors.Join — joining multiple errors

errors.Join — joining multiple errors

Go SELF
Level 17, Lesson 3
Available

1. Why do we need “multiple errors” at all, instead of one?

If up to this point you’ve had a rule in your head like “there must be one error, otherwise it’ll be chaos,” that’s normal: in most operations it’s genuinely beneficial to stop at the first problem. But sometimes choosing the “first” error is unfair, because the user will run into the second, third, and fourth anyway—just sequentially. In these cases, errors.Join turns UX from “endless back-and-forth” into “one clear report.”

Imagine validating a form (even a console one): the name is empty, the age is negative, and the e-mail format is also weird. If we return only the first error, the user fixes the name, runs again, gets the age error, fixes it, runs again… and at some point starts suspecting the program is messing with them.

In data import (for example, a list of lines) the situation is even more obvious: you want to know all the bad lines, not “the first bad line”—otherwise the import turns into “run it 200 times until you fix everything.”

So we’re not being abstract: let’s introduce a tiny training scenario that we’ll evolve throughout the lecture: TaskBox is a console utility that accepts “tasks” and validates the input. For now: no files, no structs, no complex architecture—just functions, strings, numbers, and errors.

2. How errors.Join works in practice

errors.Join(err1, err2, ...) is a standard function that combines multiple errors into one “multi-error.” It has been in the standard library since Go 1.20. The important part is that this is not “gluing strings together,” but an error that wraps a set of causes so that errors.Is/errors.As can still find those causes.

From a practical perspective, errors.Join has several useful properties.

First, if you pass only nil (or pass nothing at all), the result will be nil. That is, you can write code like “collect errors in a slice → errors.Join(errs...)”, and it will naturally return nil when there are no problems.

Second, the string representation of a joined error usually looks like multiple messages separated by newlines—convenient for a person reading the output.

Minimal example

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := errors.Join(errors.New("name required"), errors.New("age invalid"))
	fmt.Println(err) // name required\nage invalid
}

Notice a characteristic detail: it’s one variable err, but it stores information about two problems. For the program, it’s also one error—but structured.

3. Pattern: validation with a report of all problems

Now let’s take the first small step in TaskBox: we’ll write validation for task parameters. Let a task have title and priority. We want to check multiple conditions and return all violations at once.

Sentinel errors for recognizable causes

First, let’s declare sentinel errors. These will be “recognizable” causes that code can react to via errors.Is:

package main

import "errors"

var (
	ErrTitleRequired      = errors.New("title required")
	ErrPriorityOutOfRange = errors.New("priority out of range")
)

Collect violations in a slice and join via errors.Join

Now let’s write validateTask(title, priority) so it doesn’t stop at the first error:

package main

import "errors"

func validateTask(title string, priority int) error {
	var errs []error

	if title == "" {
		errs = append(errs, ErrTitleRequired)
	}
	if priority < 1 || priority > 5 {
		errs = append(errs, ErrPriorityOutOfRange)
	}

	return errors.Join(errs...)
}

Pay attention to the part that often surprises beginners: we return errors.Join(errs...) even if errs is empty. That’s fine: in this case errors.Join returns nil, and the caller sees “no error.”

By the way, the ellipsis ... is read as “expand the slice into an argument list.” So if errs is a []error, then errs... becomes “pass each element as a separate argument.” Without this, Go can’t pass “a slice as many arguments,” and the compiler will complain (and, overall, fairly so).

To make the picture clearer, here’s a simple flowchart of this function’s logic:

flowchart TD
    A[validateTask] --> B{title empty?}
    B -->|yes| C[add ErrTitleRequired]
    B -->|no| D[skip]
    C --> E{priority 1..5?}
    D --> E
    E -->|no| F[add ErrPriorityOutOfRange]
    E -->|yes| G[skip]
    F --> H["return errors.Join(errs...)"]
    G --> H

This diagram shows an important style: we don’t “pick the first error”—we collect a set of problems and return it.

How to add context to each cause without turning everything into a mess

Collecting multiple errors is great, but then a question comes up: how do we make the messages understandable? If you just return ErrTitleRequired, the user sees "title required"—and it’s not guaranteed they’ll understand where it happened (create? import? update?).

We already know that fmt.Errorf("context: %w", err) adds context and preserves the cause inside. This behavior appeared in Go 1.13 and became the basic tool for building an “error chain.” And here’s the nice part: the same technique combines отлично with errors.Join.

Let’s add context “at the field level” so the multi-error is self-documenting:

package main

import (
	"errors"
	"fmt"
)

func validateTask(title string, priority int) error {
	var errs []error

	if title == "" {
		errs = append(errs, fmt.Errorf("title: %w", ErrTitleRequired))
	}
	if priority < 1 || priority > 5 {
		errs = append(errs, fmt.Errorf("priority: %w", ErrPriorityOutOfRange))
	}

	return errors.Join(errs...)
}

Now a person immediately sees “title: …” and “priority: …”. At the same time, code can still do errors.Is(err, ErrTitleRequired)—because each wrapper contains the sentinel, and errors.Is knows how to walk the chain.

And it’s important not to overdo “pretty text.” If you start writing contexts like “validation error for field title while creating a task...”, you’ll get long walls of text that are hard to read. Good context is usually short and “operational”: title, priority, parse, import line 17.

4. Pattern: import with accumulated per-line errors

In data import there’s a classic pain: you read many records, and some of them may be malformed. If you return the first error, you force the user to fix one record at a time. As a result, the import either becomes suffering, or people start “giving up” and turning off checks. Both options are bad: the first kills UX, the second kills data quality.

Let’s make a simple “task import” in TaskBox. Let the input be a slice of strings, and the line format be "title;priority". We won’t dive into advanced parsing today: we’ll use strings.Cut and strconv.Atoi.

Parsing one line

package main

import (
	"fmt"
	"strconv"
	"strings"
)

func parseTaskLine(line string) (string, int, error) {
	title, prioStr, ok := strings.Cut(line, ";")
	if !ok {
		return "", 0, fmt.Errorf("bad format: %q", line)
	}

	priority, err := strconv.Atoi(prioStr)
	if err != nil {
		return "", 0, fmt.Errorf("priority is not int: %w", err)
	}

	return title, priority, nil
}

Import: collect all errors, adding line number context

We’ll iterate over the lines, and put every problem into errs, adding line number context:

package main

import (
	"errors"
	"fmt"
)

func importTasks(lines []string) error {
	var errs []error

	for i, line := range lines {
		_, _, err := parseTaskLine(line)
		if err != nil {
			errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
			continue
		}
	}

	return errors.Join(errs...)
}

So far this is only parsing. Let’s also add our validation validateTask, so we collect problems from “parsing and business rules”:

package main

import (
	"errors"
	"fmt"
)

func importTasks(lines []string) error {
	var errs []error

	for i, line := range lines {
		title, prio, err := parseTaskLine(line)
		if err != nil {
			errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
			continue
		}

		if err := validateTask(title, prio); err != nil {
			errs = append(errs, fmt.Errorf("line %d: %w", i+1, err))
		}
	}

	return errors.Join(errs...)
}

The point of this code isn’t that it’s “perfect,” but that it demonstrates a style: we don’t stop the import on the first broken record—we collect a report. This is the grown-up approach: your code behaves like a good teacher—marks all mistakes in a test, instead of giving an “F” for the first typo and going off to drink tea.

Testability: errors.Is and errors.As work with Join too

A common misconception: “If I joined errors, now it’s just text.” No. errors.Join joins errors structurally, and recognition tools keep working.

Why does this matter? Because at the top level of a program you often want to decide: is this a validation error (show it to the user), or something systemic (abort execution)? We’ll formalize an error taxonomy later, but even before that it’s useful to be able to check for sentinel causes.

Let’s write a small main that calls importTasks and checks whether a specific cause occurred:

package main

import (
	"errors"
	"fmt"
)

func main() {
	lines := []string{";2", "Buy milk;10", "Read book;3"}

	err := importTasks(lines)
	fmt.Println(err)
	// line 1: title: title required
	// line 2: priority: priority out of range

	fmt.Println(errors.Is(err, ErrTitleRequired))      // true
	fmt.Println(errors.Is(err, ErrPriorityOutOfRange)) // true
}

Two useful things happen here at once. A person gets a clear report, and the code gets the ability to programmatically distinguish classes of problems. This matches the idea of “errors are values”: we don’t just print them—we react to them.

Internally, this works because Go has long been able to unwrap error chains (Unwrap → errors.Is/errors.As). In Go 1.13 this was added as the standard “chain mechanism.” And errors.Join adds “branching” to this mechanism: now an error can have not one cause, but a set of causes.

5. Typical mistakes when using errors.Join

Mistake #1: concatenating errors as strings instead of using errors.Join.
Beginners often do fmt.Errorf("bad input: %v, %v, %v", err1, err2, err3) or manually join strings with +. For a human, this sort of works, but for code you kill testability: errors.Is and errors.As won’t be able to “see” the causes, because you didn’t build an error structure—you just produced text.

Mistake #2: putting nil into the error slice and being surprised by weird output.
A slice []error should contain only real errors. If somewhere you do errs = append(errs, err) without checking if err != nil, then errs may end up containing nil. This leads to murky situations: somewhere it seems like “there were errors,” but when joining, some information is lost or looks strange. The safest rule is: add to errs only inside if err != nil { ... }.

Mistake #3: forgetting that errors.Join(errs...) returns nil when there are no errors.
This is not a bug—it’s a feature. But if you expect “non-nil, but empty error,” you’ll start writing unnecessary checks. It’s better to accept the Go model: “no error” means nil. If you want to print “all good,” do it in the caller with if err == nil.

Mistake #4: mixing the roles of Join and wrapping so the context becomes unreadable.
A good order is usually: first you collect causes (including field-level or line-level context), then join them via errors.Join, and only then, if needed, add a general operation context ("import tasks: %w"). If you do the opposite and wrap every tiny thing in “import tasks: ...”, the output will start repeating itself and will look like a log written in a panic 5 minutes before a deadline.

Mistake #5: trying to analyze a multi-error via err.Error() and strings.Contains.
With multi-error this is especially tempting: “well, it’s a list of errors, let’s search for a substring.” Don’t do that, because the text can change, the format can differ, and you’re turning the structure back into a string. If you need to distinguish kinds of problems, do it via errors.Is (by sentinel) or errors.As (by cause type), and leave the string for humans.

1
Task
Go SELF, level 17, lesson 3
Locked
Task creation
Task creation
1
Task
Go SELF, level 17, lesson 3
Locked
Password policy
Password policy
1
Task
Go SELF, level 17, lesson 3
Locked
Supply import
Supply import
1
Task
Go SELF, level 17, lesson 3
Locked
Partial parsing
Partial parsing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION