How to Use the errors.Join Function in Go 1.20+
You are writing a function that validates a user profile. It checks the email format, verifies the username isn't taken, and ensures the password meets complexity rules. The email is malformed. The username is already in use. The password is too short. You want to tell the user all three things at once, not just "Email invalid" and stop.
In Go versions before 1.20, you faced a choice. You could return the first error and discard the rest, forcing the user to fix problems one by one. Or you could concatenate error messages into a single string, which loses the ability to check error types programmatically. Go 1.20 added errors.Join to solve this cleanly. It combines multiple error values into a single error that preserves the structure of each component.
The concept: a multi-slot tray
Think of errors.Join like a multi-slot tray at a deli counter. You can place multiple items in the tray. When you hand the tray to the cashier, they see everything. You did not tape the items together with duct tape, and you did not pick just one item to show them. The tray holds them all, and you can still inspect each item individually if you need to.
Technically, errors.Join takes a variadic list of errors and returns a single error value. This returned error implements the standard error interface, so it prints as a string. It also implements Is and As, so you can check if the joined error contains a specific sentinel error or unwrap it to a specific type. The function handles nil values gracefully: it ignores them, and if all inputs are nil, it returns nil.
Errors are values. Join them like slices, not strings.
Minimal example
Here is a validation function that accumulates errors and returns them together.
package main
import (
"errors"
"fmt"
)
var ErrEmailInvalid = errors.New("email is invalid")
var ErrUserTaken = errors.New("username is taken")
// validateProfile collects validation errors and joins them.
func validateProfile(email, username string) error {
var errs []error
// Append to slice to accumulate multiple failures
if email == "" {
errs = append(errs, ErrEmailInvalid)
}
if username == "admin" {
errs = append(errs, ErrUserTaken)
}
// errors.Join returns nil if the slice is empty or all nils
return errors.Join(errs)
}
The function builds a slice of errors. It appends to the slice whenever a check fails. At the end, it calls errors.Join. If the slice is empty, errors.Join returns nil, so the caller sees success. If the slice has items, errors.Join returns a combined error.
func main() {
// Trigger both validation failures
err := validateProfile("", "admin")
// The combined error prints all messages joined by newlines
if err != nil {
fmt.Println(err)
}
}
The output shows both error messages, separated by a newline. The caller receives a single error value, but the value contains both problems.
How the join works at runtime
When you call errors.Join(err1, err2), Go creates a wrapper error. The wrapper stores the list of errors. When you call Error() on the wrapper, it iterates over the list and joins the strings with newlines. This gives you a readable message without losing the individual components.
The wrapper also implements Is(target error) bool. When you call errors.Is(combined, ErrEmailInvalid), the wrapper checks each component. If any component matches the target, Is returns true. This means you can use standard error checking patterns with joined errors. You do not need special logic to detect errors inside a join.
The wrapper implements As(target interface{}) bool as well. errors.As searches the components in order. It returns true if it finds a component that matches the target type. It modifies the target to point to the first matching component. This behavior is deterministic: the first match wins. If you join two errors of the same type, errors.As only gives you the first one.
Here is how you check a joined error using errors.Is and errors.As.
func checkCombined(err error) {
// errors.Is traverses the joined errors automatically
if errors.Is(err, ErrEmailInvalid) {
fmt.Println("Email is part of the problem")
}
// errors.As finds the first matching type in the join
var specific *ValidationError
if errors.As(err, &specific) {
fmt.Printf("Found specific error: %v\n", specific)
}
}
The errors.Is call works even if ErrEmailInvalid is buried inside the join. The errors.As call extracts the first error that matches *ValidationError. If the join contains multiple *ValidationError values, you only get the first one. This is a design choice: errors.As is for finding a type, not for iterating all matches.
Realistic usage: independent side effects
Validation is the classic use case, but errors.Join shines whenever you have independent operations that can all fail, and you want to report all failures. A common pattern is a function that persists data and sends a notification. If the database write fails and the email send fails, you want to log both errors. You do not want to hide the email failure because the database failed first.
// saveAndNotify persists data and sends an alert, reporting all failures.
func saveAndNotify(data string) error {
var errs []error
// Attempt database write
if err := db.Save(data); err != nil {
errs = append(errs, fmt.Errorf("db save: %w", err))
}
// Attempt notification send
if err := notify.Send(data); err != nil {
errs = append(errs, fmt.Errorf("notify: %w", err))
}
// Return combined error or nil
return errors.Join(errs)
}
Each operation runs independently. Errors are wrapped with fmt.Errorf and %w to add context while preserving the underlying error chain. The wrapper error is appended to the slice. Finally, errors.Join combines them. The caller gets a single error that contains both the database error and the notification error, each with its context.
Failures happen together. Report them together.
Pitfalls and compiler errors
errors.Join is simple, but there are nuances. The function ignores nil errors in the input list. If you pass errors.Join(nil, err, nil), the result is equivalent to errors.Join(err). If you pass only nil values, the result is nil. This makes it safe to append to a slice conditionally and join at the end.
The compiler enforces types strictly. errors.Join expects arguments of type error. If you pass a string or an integer, the compiler rejects the code.
The compiler complains with
cannot use "message" (untyped string constant) as error value in argument to errors.Joinif you try to join a raw string. Wrap the string inerrors.Neworfmt.Errorffirst.
A subtle runtime pitfall involves errors.As and duplicate types. If you join two errors of the same concrete type, errors.As returns true and sets the target to the first match. You cannot use errors.As to collect all matches. If you need to inspect every component, you must unwrap the slice manually.
Joined errors implement an Unwrap() []error method. You can cast the error to an interface with that method to retrieve the list of components. This is useful for debugging or for custom error handling that needs to see all parts.
// Unwrap returns a slice of errors for joined errors
if unwrapper, ok := err.(interface{ Unwrap() []error }); ok {
for _, e := range unwrapper.Unwrap() {
fmt.Println("Component:", e)
}
}
The type assertion checks if the error supports the slice unwrap. If it does, you iterate over the components. This pattern lets you log each error individually or apply different handling logic to each part.
Nil is invisible. Empty is nil. Trust the join.
When to use errors.Join
Go provides several ways to handle errors. Pick the right tool based on your needs.
Use errors.Join when a single operation fails at multiple independent steps and you want to report all failures to the caller.
Use fmt.Errorf with %w when you need to wrap a single error with additional context, preserving the error chain for errors.Is and errors.As.
Use a custom error struct when you need to attach structured data like request IDs or field names that do not fit in a flat error chain.
Return the first error immediately when subsequent steps depend on the success of previous steps, so there is no point in continuing.
Use string concatenation only for final user-facing messages where programmatic error checking is no longer needed.
Join for accumulation. Wrap for context. Stop for dependency.