Skip to content
gohacks.dev
Basics

Join Multiple Errors in Go With errors Join From Go 1.20

Combine several errors into one with errors.Join in Go 1.20 and later: how it prints, how Is and As search it, and how it handles nil values and cleanup.

4 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

Some functions can fail more than once. A validator may find three bad fields, a batch job may hit errors on twelve of a hundred items, and a function that writes a file may fail in the write and again in Close. Returning only the first error loses information, and inventing a slice type to carry the rest used to be a rite of passage. Go 1.20 added errors.Join, which is the standard answer.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It combines errors into one value, and the value behaves well with the rest of the toolkit. It is a small addition to the picture in the Go error handling guide, and the longer treatment of batch work is in collecting multiple errors. Everything ran on Go 1.25.6.

The basics

main.goGo
package main

import (
	"errors"
	"fmt"
	"io/fs"
)

func main() {
	a := errors.New("name is required")
	b := fmt.Errorf("age: %w", fs.ErrInvalid)

	err := errors.Join(a, b)
	fmt.Println(err)
	fmt.Println(errors.Is(err, fs.ErrInvalid), errors.Is(err, a))

	fmt.Println(errors.Join(nil, nil) == nil)
	fmt.Println(errors.Join(a, nil, nil))
}
Text
name is required
age: invalid argument
true true
true
name is required

The message is each error's text on its own line, so a joined error prints as a list. errors.Is and errors.As search every member, which means a caller can test for one particular failure inside the group. Nil arguments are discarded, and if every argument is nil the result is a true nil error, so errors.Join(nil, nil) == nil holds and you can return it directly from a function without checking.

Reporting every validation failure

The commonest use is collecting problems in a loop and returning them together:

main.goGo
package main

import (
	"errors"
	"fmt"
	"strings"
)

type User struct {
	Name  string
	Email string
	Age   int
}

func (u User) Validate() error {
	var errs []error
	if strings.TrimSpace(u.Name) == "" {
		errs = append(errs, errors.New("name is required"))
	}
	if !strings.Contains(u.Email, "@") {
		errs = append(errs, fmt.Errorf("email %q is not valid", u.Email))
	}
	if u.Age < 0 {
		errs = append(errs, errors.New("age cannot be negative"))
	}
	return errors.Join(errs...)
}

func main() {
	fmt.Println(User{"Ada", "[email protected]", 36}.Validate() == nil)
	fmt.Println(User{"", "nope", -1}.Validate())
}
Text
true
name is required
email "nope" is not valid
age cannot be negative

With no problems, errs is empty, Join returns nil, and Validate reports success without an extra branch. With three, the caller gets all three in one pass, which saves a person from fixing a form one error at a time.

Keeping the Close error

A function that writes through a file has two ways to fail, and the Close error is easy to drop. A named result and a deferred join keep both:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type file struct{}

func (file) Write(string) error { return errors.New("disk full") }
func (file) Close() error       { return errors.New("flush failed") }

func save() (err error) {
	f := file{}
	defer func() { err = errors.Join(err, f.Close()) }()
	return f.Write("data")
}

func main() {
	fmt.Println(save())
}
Text
disk full
flush failed

If the write fails and the close fails, you see both. If only the close fails, the joined error is that one. If neither fails, Join(nil, nil) is nil. The longer version, with the trade-offs for files you only read, is in errors from deferred Close calls and named results and defer.

Join or %w

Both combine errors, and the difference is intent. fmt.Errorf("load: %w", err) adds context to one cause and keeps one line. errors.Join(a, b) states that there are several independent causes and adds no context of its own. You can combine them, wrapping a join with a phrase: fmt.Errorf("validate user: %w", errors.Join(errs...)). Go 1.20 also allows several %w verbs in one Errorf call, which is neater when you know the number of causes at write time (wrapping errors), while Join is the tool when the count depends on the data.

One caution: a multi-line message is awkward in a structured log line and in an HTTP response body. Log the joined error as a field, and for an API response, translate the members into a list of problems with fields, one per element. The type of each member is still available to errors.As for that purpose. The reference is the errors.Join documentation.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.