multierr package

Use errors.Join from the standard library to combine multiple errors into a single error value for better debugging.

When one failure isn't enough

You are writing a function that validates a configuration file. The database host is missing. The port number is out of range. The TLS certificate path points to a non-existent file. In Go versions before 1.20, you had to pick one error to return, concatenate strings manually, or pull in an external dependency. Picking one error hides the other mistakes. String concatenation breaks type checking and makes programmatic error handling impossible. External dependencies add maintenance overhead for a problem the language eventually solved itself.

Go 1.20 introduced errors.Join to the standard library. It lets you bundle multiple independent failures into a single error value without losing type information or breaking the existing error-handling ecosystem. You return one value, but the caller can inspect every failure inside it.

How error joining works

Error joining is the practice of packaging multiple distinct failures into one interface value. Think of it like a shipping manifest for a damaged package. The warehouse doesn't send three separate letters. They print one document that lists every dent, tear, and missing corner. The recipient still handles one document, but the document contains every issue that needs attention.

In Go, that document is an error interface value that implements a specific method signature: Unwrap() []error. Before Go 1.20, the Unwrap method only returned a single error, creating a linear chain. errors.Join changes the shape of that chain. It creates a lightweight wrapper that holds a slice of errors and returns that slice when Unwrap() is called. The standard library functions errors.Is, errors.As, and errors.Unwrap were updated to recognize this fan-out pattern. They automatically flatten the slice and check each element.

You never call Unwrap directly in application code. The standard library handles the traversal. Your job is to collect the errors, join them, and let the caller inspect them.

Minimal example

Here is the simplest way to combine two failures and check for one of them later.

package main

import (
	"errors"
	"fmt"
)

// ErrInvalidEmail represents a malformed email address
var ErrInvalidEmail = errors.New("invalid email format")
// ErrMissingAge represents a missing or negative age value
var ErrMissingAge   = errors.New("age is required")

// validateProfile checks multiple fields and returns all failures
func validateProfile(email string, age int) error {
	// Collect failures in a slice instead of returning early
	var errs []error

	if email == "" {
		errs = append(errs, ErrInvalidEmail)
	}
	if age <= 0 {
		errs = append(errs, ErrMissingAge)
	}

	// Join filters out nils and wraps the slice in a standard error
	return errors.Join(errs...)
}

func main() {
	// Trigger both validation failures
	err := validateProfile("", -1)
	
	// errors.Is traverses the joined chain automatically
	if errors.Is(err, ErrInvalidEmail) {
		fmt.Println("email is broken")
	}
	if errors.Is(err, ErrMissingAge) {
		fmt.Println("age is broken")
	}
}

The code above demonstrates the complete lifecycle of a joined error. It accumulates failures in a slice, passes them to errors.Join, and inspects the result with errors.Is. The slice approach is intentional. Early returns are excellent for hard stops, but validation routines benefit from showing every mistake at once. errors.Join takes that slice and creates a wrapper. If the slice is empty, it returns nil. If it contains one error, it returns that error directly. If it contains two or more, it builds a hidden struct that holds them all. errors.Is knows how to handle that struct. It calls Unwrap() []error behind the scenes, iterates over the slice, and checks each element against the target error.

What happens at runtime

When you call errors.Join(err1, err2), the standard library allocates a small struct on the heap. That struct stores the errors in a slice and implements the error interface. Its Error() method concatenates the string representations of each wrapped error, separated by newlines. Its Unwrap() method returns the underlying slice.

When you later call errors.Is(joinedErr, ErrInvalidEmail), the function checks if joinedErr matches the target. If not, it checks whether joinedErr implements Unwrap() []error. If it does, errors.Is iterates over the returned slice and recursively checks each element. This means you can join errors that are themselves wrapped or joined. The traversal is flat and exhaustive. You do not need to write custom unwrapping logic. The standard library handles the graph traversal for you.

This behavior aligns with the error wrapping philosophy introduced in Go 1.13. The difference is structural. fmt.Errorf("...: %w", err) creates a linear chain. errors.Join creates a branching structure. Both work with errors.Is and errors.As because those functions were designed to handle both patterns.

Realistic example

Real code rarely validates two fields. It usually starts services, reads configuration, and initializes databases. Here is a startup routine that collects initialization failures and returns them as a single error value.

package main

import (
	"errors"
	"fmt"
)

var ErrDBConnection = errors.New("database connection failed")
var ErrCacheTimeout = errors.New("cache initialization timed out")

// StartServices initializes external dependencies and returns all failures
func StartServices() error {
	var initErrs []error

	// Simulate database connection attempt
	// In production, this would be a driver call that returns an error
	if false {
		initErrs = append(initErrs, ErrDBConnection)
	}

	// Simulate cache warm-up failure
	// Appending directly demonstrates that nils are safe to include
	initErrs = append(initErrs, ErrCacheTimeout)

	// Return nil if everything succeeded, otherwise join them
	return errors.Join(initErrs...)
}

func main() {
	err := StartServices()
	if err != nil {
		// errors.As extracts the specific typed error if present
		var cacheErr error
		if errors.As(err, &cacheErr) && errors.Is(cacheErr, ErrCacheTimeout) {
			fmt.Println("cache failed, proceeding without it")
		}
	}
}

The errors.As call in main demonstrates extraction. When you pass a pointer to errors.As, it walks the entire joined chain. If it finds an error that matches the type or value you requested, it copies it into your pointer and returns true. This works seamlessly with joined errors because the traversal logic treats the joined slice as a flat list of candidates. The pattern is common in middleware and startup routines where you want to log every failure but still allow the program to continue with degraded functionality.

Pitfalls and compiler behavior

The standard library handles edge cases gracefully, but a few patterns trip people up. Passing nil to errors.Join is safe. The function explicitly drops nil values before wrapping. If every argument is nil, you get nil back. This prevents accidental nil pointer dereferences when you append to a slice conditionally. You can safely write errs = append(errs, maybeError) without checking for nil first.

The compiler will reject code that tries to use errors.Join with incompatible types. If you accidentally pass a string instead of an error, you get cannot use "message" (untyped string constant) as error value in argument. The fix is always to wrap the string with errors.New or fmt.Errorf first. Error values must implement the error interface. Strings do not.

Another common mistake is assuming errors.Join preserves the exact formatting of the original errors. It does not. The Error() method of a joined error simply concatenates the string representations of each wrapped error, separated by newlines. If you need custom formatting, you must handle it at the call site. The joined error is a container, not a formatter.

You will also encounter the third-party github.com/hashicorp/go-multierr package in legacy codebases. It predates Go 1.20 and provides multierr.Append and multierr.Combine. The behavior is nearly identical to errors.Join, but it requires an external dependency. New projects should stick to the standard library. Existing projects can migrate by replacing multierr.Combine(errs...) with errors.Join(errs...). The standard library version is faster because it avoids reflection and uses a simpler internal representation. The migration is usually a find-and-replace operation followed by a go mod tidy.

When to use what

Use errors.Join when you need to return multiple independent failures from a single function call. Use fmt.Errorf("...: %w", err) when you want to add contextual information to a single underlying error. Use errors.Is and errors.As when you need to check for specific error values or types inside a joined chain. Use a plain nil return when the operation succeeded completely. Use a third-party multi-error library only when you are maintaining a codebase that predates Go 1.20 and cannot upgrade the toolchain.

Convention and design

Go error handling favors explicitness over cleverness. The community accepts the verbosity of if err != nil because it makes failure paths impossible to ignore. When you join errors, you are not hiding failures. You are batching them so the caller can decide how to report them. The convention is to join at the boundary where multiple independent operations happen, then unwrap or inspect at the boundary where the user sees the output. Keep the joining close to the source. Keep the inspection close to the handler. Do not join errors deep inside a single logical operation. Join them when distinct subsystems report back.

Errors are data. Join them when they belong together. Inspect them when you need to act.

Where to go next