A function sometimes performs several independent operations and more than one can fail. Cleanup is a common example: closing one resource should not prevent the program from attempting to close the next. Validation can have the same shape when callers benefit from seeing several independent problems at once.
Returning only the last error loses information. Returning only the first error may hide failures that happened later. Go’s errors.Join provides a standard way to return one error value that still wraps multiple underlying errors.
The important mental model is not “turn several messages into one string.” A joined error is an error tree. Standard inspection with errors.Is and errors.As can traverse that tree, so callers can still recognize the underlying failures.
Start with the smallest useful join
errors.Join accepts any number of errors:
package main
import (
"errors"
"fmt"
)
var (
ErrCache = errors.New("cache flush failed")
ErrLog = errors.New("log flush failed")
)
func main() {
err := errors.Join(ErrCache, ErrLog)
fmt.Println(errors.Is(err, ErrCache))
fmt.Println(errors.Is(err, ErrLog))
}Both checks print true. The returned value wraps both errors rather than choosing one as the single cause.
errors.Join also ignores nil arguments. If every supplied error is nil, it returns nil. That property makes it convenient when several operations already return error:
err := errors.Join(
flushCache(),
flushLogs(),
)
if err != nil {
return err
}All function calls above are evaluated before errors.Join runs, so both flush operations are attempted. This is different from ordinary fail-fast code that returns immediately after the first failure.
Understand the error tree
Go error wrapping is not limited to a single chain. An error may implement either:
Unwrap() erroror:
Unwrap() []errorA non-nil error returned by errors.Join uses the second form. The standard errors.Is and errors.As functions inspect the resulting tree, including descendants wrapped by the joined errors.
For example, contextual wrapping can be preserved before joining:
cacheErr := fmt.Errorf("flush cache: %w", ErrCache)
logErr := fmt.Errorf("flush logs: %w", ErrLog)
err := errors.Join(cacheErr, logErr)
fmt.Println(errors.Is(err, ErrCache)) // true
fmt.Println(errors.Is(err, ErrLog)) // trueThe extra context helps humans understand which operation failed, while %w preserves machine-readable error identity.
One subtle point is that errors.Unwrap itself only handles Unwrap() error. It does not return the children of an error that exposes Unwrap() []error. Use errors.Is or errors.As for normal inspection instead of repeatedly calling errors.Unwrap and assuming every error is a linear chain.
Preserve cleanup failures without skipping cleanup
Cleanup is a strong use case because resources are often independent. Suppose a function owns two closers and both must be attempted:
func closeBoth(first, second io.Closer) error {
firstErr := first.Close()
secondErr := second.Close()
return errors.Join(firstErr, secondErr)
}This function has a clear guarantee: it attempts both closes, then reports every non-nil close error through one returned error.
Contrast that with a fail-fast implementation:
func closeBoth(first, second io.Closer) error {
if err := first.Close(); err != nil {
return err
}
return second.Close()
}The second version can be correct when the second operation must not run after the first fails. It is risky for independent cleanup because second.Close() is skipped whenever first.Close() fails.
The choice is therefore about operation semantics, not syntax. Join errors when the operations should all be attempted and multiple failures remain relevant to the caller.
Keep the primary operation failure too
A more realistic function can fail while doing work and then fail again during cleanup. Discarding either failure can make diagnosis harder.
func processFile(f *os.File) (err error) {
defer func() {
err = errors.Join(err, f.Close())
}()
if _, writeErr := f.WriteString("record\n"); writeErr != nil {
return fmt.Errorf("write record: %w", writeErr)
}
return nil
}The named return value lets the deferred function combine the current operation error with the close error. The cases are straightforward:
- if writing and closing both succeed, the function returns
nil; - if only writing fails, the write error remains wrapped in the result;
- if only closing fails, the close error is returned;
- if both fail, the result wraps both.
This pattern is useful only when the close error matters. Some APIs define close after a successful read as operationally unimportant, while writers and transactional resources may report meaningful finalization failures during close. Follow the contract of the resource you are using.
Use errors.As when joined failures have types
Joined errors preserve type-based inspection as well as sentinel matching. Consider a validation error with structured information:
type FieldError struct {
Field string
Msg string
}
func (e *FieldError) Error() string {
return e.Field + ": " + e.Msg
}A caller can find a matching error inside a joined result:
err := errors.Join(
&FieldError{Field: "email", Msg: "missing"},
errors.New("configuration unavailable"),
)
var fieldErr *FieldError
if errors.As(err, &fieldErr) {
fmt.Println(fieldErr.Field)
}errors.As stops when it finds a value assignable to the requested target type. If a joined error contains several *FieldError values and the caller needs every one of them, a single errors.As call is not an enumeration API. In that situation, consider returning a purpose-built validation result or collection whose contract explicitly exposes all field problems.
Do not use the formatted string as an API
The error returned by errors.Join formats its child error strings separated by newlines. That representation is useful for logs and diagnostics, but callers should not parse it to recover individual failures.
Prefer semantic inspection:
if errors.Is(err, ErrCache) {
// react to a cache failure
}rather than string matching:
if strings.Contains(err.Error(), "cache flush failed") {
// fragile: depends on human-readable text
}Error text can gain context or change wording without changing the underlying error identity. errors.Is and errors.As express the contract directly.
Joining is different from wrapping one cause
Use fmt.Errorf with %w when one operation fails and you want to add context:
if err := store.Save(item); err != nil {
return fmt.Errorf("save item %q: %w", item.ID, err)
}Use errors.Join when several errors are independently relevant:
return errors.Join(cacheErr, logErr, closeErr)These techniques compose. You can wrap each failure with useful local context and then join the wrapped errors.
Avoid joining errors merely to avoid deciding which failure is authoritative. If operation B only runs because operation A succeeded, or B’s failure makes A’s result irrelevant, a normal sequential error path often communicates the contract more clearly.
Watch for duplicate and noisy errors
errors.Join does not deduplicate its inputs. Joining the same error twice produces a result containing both entries. That is usually a sign that error ownership is unclear.
Large fan-out operations can also produce an unwieldy error tree. Returning hundreds of nearly identical failures may consume memory, flood logs, and make the useful signal harder to find. For bulk processing, a summary such as a failure count plus a bounded sample may be a better API than joining every individual error.
Similarly, do not assume joining creates concurrency or cancellation behavior. errors.Join only combines error values that already exist. Scheduling, retries, cancellation, and limits on parallel work must be designed separately.
When errors.Join is the right tool
Use errors.Join when all relevant operations should be attempted, more than one can fail independently, and callers still need standard Go error inspection. Cleanup of independent resources and small groups of independent finalization steps fit this model well.
Prefer ordinary %w wrapping for one causal failure. Prefer a structured result type when callers need to enumerate, order, count, serialize, or attach metadata to many failures. Prefer fail-fast control flow when continuing after an error would be invalid or unsafe.
Conclusion
errors.Join lets a Go function preserve multiple independent failures without abandoning the standard error interface. Its main value is semantic: the result forms an error tree that errors.Is and errors.As can inspect.
Use it where the control flow genuinely permits several operations to fail independently. Keep contextual wrapping around individual failures, do not parse the combined message, and switch to a purpose-built collection when callers need richer access than error-tree inspection provides.