Combining Errors with errors.Join in Go

How to report several errors at once with errors.Join, how errors.Is and errors.As search joined errors, and when to return many errors instead of the first one.

Most functions stop at the first failure and return it. Some jobs are different. When you validate a form, the user wants to hear about every invalid field at once, not one per attempt. When you close three files during cleanup, each close can fail independently. errors.Join, added in Go 1.20, lets you return all of those errors as a single error value.

A First errors.Join Example

errors.Join takes any number of errors and returns one error that contains all of them. nil values in the list are skipped.

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := errors.Join(
		errors.New("name is required"),
		nil,
		errors.New("age must be 18 or older"),
	)
	fmt.Println(err)
}

Output:

name is required
age must be 18 or older

The messages of the joined errors are separated by newlines. The nil in the middle was dropped. If every argument is nil, or you pass none, errors.Join returns nil, so it is safe to use even when nothing failed.

Validating Several Fields at Once

Here is the pattern most people need: collect problems in a slice, then join them at the end.

package main

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

type Signup struct {
	Email    string
	Password string
	Age      int
}

func validate(s Signup) error {
	var errs []error
	if !strings.Contains(s.Email, "@") {
		errs = append(errs, fmt.Errorf("email %q is not valid", s.Email))
	}
	if len(s.Password) < 8 {
		errs = append(errs, errors.New("password must be at least 8 characters"))
	}
	if s.Age < 13 {
		errs = append(errs, fmt.Errorf("age %d is below 13", s.Age))
	}
	return errors.Join(errs...)
}

func main() {
	if err := validate(Signup{Email: "asha", Password: "123", Age: 10}); err != nil {
		fmt.Println("signup rejected:")
		fmt.Println(err)
	}
	if err := validate(Signup{Email: "a@b.in", Password: "longenough", Age: 20}); err == nil {
		fmt.Println("second signup is valid")
	}
}

Output:

signup rejected:
email "asha" is not valid
password must be at least 8 characters
age 10 is below 13
second signup is valid

When all fields are fine, errs is empty and errors.Join(errs...) returns nil. You do not need a separate if len(errs) > 0 check.

errors.Is and errors.As Search Every Joined Error

A joined error is not just text. It keeps every original error, and errors.Is and errors.As look inside all of them.

package main

import (
	"errors"
	"fmt"
)

var (
	ErrMissingName = errors.New("name is required")
	ErrTooYoung    = errors.New("too young")
)

func main() {
	err := errors.Join(ErrMissingName, fmt.Errorf("age 9: %w", ErrTooYoung))

	fmt.Println(errors.Is(err, ErrMissingName))
	fmt.Println(errors.Is(err, ErrTooYoung))
}

Both lines print true. The second match is found inside a wrapped error inside the joined error, so the search goes through both kinds of layers.

errors.Unwrap does not open joined errors:

A joined error has an Unwrap() []error method that returns a slice, not the single-error Unwrap() error. Because of that, errors.Unwrap(joined) returns nil. Use errors.Is and errors.As, which understand both forms.

Wrapping More Than One Error With fmt.Errorf

fmt.Errorf can also hold several errors if you use %w more than once. This is useful when you want one line of context in front of two causes:

package main

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

func main() {
	writeErr := errors.New("disk full")
	closeErr := fs.ErrClosed

	err := fmt.Errorf("save report: %w (and close failed: %w)", writeErr, closeErr)
	fmt.Println(err)
	fmt.Println(errors.Is(err, fs.ErrClosed))
}

This prints save report: disk full (and close failed: file already closed) and then true. Use errors.Join when you want a list, and multiple %w when you want a single sentence.

Collecting Cleanup Errors

Closing resources is a common place where more than one thing can fail. A function that writes a file should report both a write error and a close error if both happen:

package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
)

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create report: %w", err)
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	if _, err := f.Write(data); err != nil {
		return fmt.Errorf("write report: %w", err)
	}
	return nil
}

func main() {
	path := filepath.Join(os.TempDir(), "report.txt")
	fmt.Println(writeReport(path, []byte("ok\n")))
	fmt.Println(writeReport("/no/such/dir/report.txt", nil))
}

The first call prints <nil>. The second prints create report: open /no/such/dir/report.txt: no such file or directory. The deferred function runs after the return statement has set err, and joins in the close error. When both are nil, the result stays nil.

The named result is what makes this work:

The deferred function can only change the returned error because the result is named, (err error). With an unnamed result, err = ... inside the defer changes a local variable and the caller never sees the close error.

When to Join and When to Stop Early

Join errors only when the steps are independent and the caller benefits from seeing all failures, such as form validation, batch jobs that process many items, or cleanup. When step two depends on step one, stop at the first error. Continuing after a failed database connection just produces a list of follow-up errors that all say the same thing.

Good use of errors.Join:

If fixing each listed error is a separate action for the user, and none of them is just a consequence of another, joining them is the right choice.

Summary

errors.Join combines several errors into one, skips nil values, and returns nil when nothing failed, which makes "collect then join" a clean pattern for validation and batch work. errors.Is and errors.As search every joined error, while errors.Unwrap does not. Use multiple %w verbs when you want one sentence of context for two causes, and combine a deferred errors.Join with a named result to keep close errors from being lost.