How to Handle Multiple Errors in Go

Combine multiple Go errors into a single return value using errors.Join to report all failures without stopping execution.

Multiple failures, one report

You are writing a startup validation function. It checks the database connection, verifies the API key, and loads the configuration file. The database is down. The API key is expired. The config file is missing.

In many languages, you throw an exception on the first failure. The user sees "Database connection failed," fixes the database, restarts, and then sees "API key expired." They fix the key, restart, and hit the missing config. Three restarts. One frustrated user.

Go gives you the tools to report all three at once. You run every check, collect the failures, and return a single error value that contains all of them. The user sees the full list, fixes everything, and restarts once.

The mechanic's report

Go treats errors as values. You return them from functions just like integers or strings. The standard library provides errors.Join to combine multiple errors into one. This combined error behaves like a single error but preserves all the originals.

Think of it like a mechanic's report. The mechanic does not stop checking the car when the oil is low. They check the brakes, the tires, and the oil, then hand you a single report listing every issue. errors.Join is that report. It aggregates failures without losing the details of each one.

Collect and join

The pattern is straightforward. Create a slice of errors. Run your checks. Append any non-nil errors to the slice. If the slice has items, join them and return.

Here is the simplest implementation:

package main

import (
	"errors"
	"fmt"
)

// checkConfig validates multiple resources and returns all failures.
func checkConfig() error {
	var errs []error

	// Check database connection
	if err := checkDB(); err != nil {
		errs = append(errs, err)
	}

	// Check API key validity
	if err := checkAPI(); err != nil {
		errs = append(errs, err)
	}

	// Return joined errors if any exist
	if len(errs) > 0 {
		return errors.Join(errs...)
	}
	return nil
}

// checkDB simulates a database check.
func checkDB() error {
	return errors.New("database unreachable")
}

// checkAPI simulates an API key check.
func checkAPI() error {
	return errors.New("api key expired")
}

func main() {
	// Run validation and print result
	if err := checkConfig(); err != nil {
		fmt.Println(err)
	}
}

The output lists both errors:

# output:
database unreachable
api key expired

errors.Join takes variadic arguments. The spread operator ... passes the slice elements as individual arguments. The function returns a single error value. When printed, it displays each wrapped error on a new line.

Collect errors, join them, and let the caller decide.

How errors.Join preserves types

New Go developers sometimes concatenate error messages into a single string. This destroys type information. If you join strings, you lose the ability to check for specific error types using errors.Is or errors.As.

errors.Join preserves types. The returned error implements Unwrap() []error. This method returns a slice of all the wrapped errors. Standard error chains use Unwrap() error for a single parent. Joined errors use Unwrap() []error for a fan-out structure.

Functions like errors.Is know how to traverse this structure. If you check errors.Is(joinedErr, target), it returns true if any of the wrapped errors match the target. You do not lose the ability to check specific error types.

package main

import (
	"errors"
	"fmt"
)

// ErrDatabaseDown is a sentinel error for database failures.
var ErrDatabaseDown = errors.New("database is down")

// validate returns a joined error containing a known sentinel.
func validate() error {
	return errors.Join(
		ErrDatabaseDown,
		errors.New("cache miss"),
	)
}

func main() {
	err := validate()

	// errors.Is traverses the joined error and finds the match
	if errors.Is(err, ErrDatabaseDown) {
		fmt.Println("Database is down")
	}
}

The output confirms the check works:

# output:
Database is down

errors.Join keeps the error graph intact. You can still use sentinel errors and type assertions through the joined wrapper.

Startup validation in practice

Real code often validates multiple resources during startup. You might check a database, a cache, and environment variables. You also want to wrap errors with context so the caller knows which check failed.

Here is a realistic validation function using context and error wrapping:

package main

import (
	"context"
	"errors"
	"fmt"
)

// Config holds service configuration.
type Config struct {
	DBHost    string
	CacheHost string
	SecretKey string
}

// validateStartup checks all dependencies and returns aggregated errors.
func validateStartup(ctx context.Context, cfg Config) error {
	var errs []error

	// Validate database connectivity
	if err := pingDB(ctx, cfg.DBHost); err != nil {
		// Wrap with context to identify the source
		errs = append(errs, fmt.Errorf("database: %w", err))
	}

	// Validate cache connection
	if err := pingCache(ctx, cfg.CacheHost); err != nil {
		// Wrap with context to identify the source
		errs = append(errs, fmt.Errorf("cache: %w", err))
	}

	// Check required environment variables
	if cfg.SecretKey == "" {
		errs = append(errs, errors.New("missing SECRET_KEY"))
	}

	// Return aggregated errors
	if len(errs) > 0 {
		return errors.Join(errs...)
	}
	return nil
}

// pingDB simulates a database ping.
func pingDB(ctx context.Context, host string) error {
	return errors.New("connection refused")
}

// pingCache simulates a cache ping.
func pingCache(ctx context.Context, host string) error {
	return errors.New("timeout")
}

func main() {
	ctx := context.Background()
	cfg := Config{DBHost: "db.local", CacheHost: "cache.local"}

	// Validate and handle result
	if err := validateStartup(ctx, cfg); err != nil {
		fmt.Println("Startup failed:", err)
	}
}

The output shows the wrapped messages:

# output:
Startup failed: database: connection refused
cache: timeout
missing SECRET_KEY

fmt.Errorf with %w wraps the underlying error. errors.Join combines the wrapped errors. The result is a single error that preserves the chain for each failure. You can still check errors.Is(err, context.DeadlineExceeded) if one of the pings timed out due to context cancellation.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and compiler checks

errors.Join is simple, but there are a few details to watch.

The function expects variadic arguments. If you pass a slice directly without the spread operator, the compiler rejects the code:

The compiler rejects return errors.Join(errs) with cannot use errs (variable of type []error) as error value in return because Join expects variadic arguments, not a slice. You must use errors.Join(errs...).

errors.Join handles nil values gracefully. You do not need to filter nils manually. errors.Join(nil, err) returns err. errors.Join(nil, nil) returns nil. errors.Join() with no arguments returns nil. This makes the pattern safe even if your checks produce nil errors.

errors.Join requires Go 1.20 or later. If you are maintaining a codebase on an older version, you cannot use this function. You would need to implement a custom error type or use a third-party package. The standard library approach is preferred when available.

You can detect a joined error and iterate over the components if you need to. The joined error implements an interface with Unwrap() []error. You can check for this interface using errors.As:

package main

import (
	"errors"
	"fmt"
)

// Unwrapper defines the interface for joined errors.
type Unwrapper interface {
	Unwrap() []error
}

func main() {
	err := errors.Join(errors.New("a"), errors.New("b"))

	// Check if error is a joined error
	var joined Unwrapper
	if errors.As(err, &joined) {
		fmt.Println("Joined error detected")
		for i, e := range joined.Unwrap() {
			fmt.Printf("Error %d: %v\n", i, e)
		}
	}
}

The output lists the components:

# output:
Joined error detected
Error 0: a
Error 1: b

This pattern is rare. Most callers just print the joined error or check it with errors.Is. Unpacking is useful when you need to handle each error differently, such as retrying specific failures.

errors.Join preserves types. String concatenation destroys them.

When to use what

Go offers several ways to handle errors. Pick the right tool for the situation.

Use errors.Join when you need to report multiple independent failures in a single return value.

Return the first error when a failure makes subsequent checks meaningless or unsafe.

Use a custom error struct when you need to attach structured data like codes or request IDs to a batch of failures.

Avoid string concatenation when you need to preserve error types for errors.Is checks.

Use fmt.Errorf with %w when you are wrapping a single error with additional context.

Use a slice of errors as a return type only when the function signature explicitly documents multiple return values, which is uncommon in idiomatic Go.

Don't hide the second error behind the first.

Where to go next