errors.Join — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "What is
errors.Join?" and "When do I reach for it?"
Sometimes one operation can fail in more than one way at the same time. A form validator finds three problems with the same input. A Close method releases two resources and both fail. A worker pool runs ten jobs and four return errors. In each case the calling code wants all of the failures, not just the first one. Returning only the first throws away information; concatenating them into a string throws away the structure — you can no longer ask "did any of these wrap os.ErrNotExist?"
Go 1.20 added one tiny function for exactly this:
The result is a single error value that contains the three errors. You can print it (newline-separated by default), test it with errors.Is and errors.As (which check every joined error), and unwrap it (it implements Unwrap() []error). It is the standard library's answer to a problem that the community had previously solved with at least four different third-party packages.
package main
import (
"errors"
"fmt"
)
func main() {
a := errors.New("file missing")
b := errors.New("permission denied")
err := errors.Join(a, b)
fmt.Println(err)
}
Output:
Two errors, one value, printed on two lines. That is errors.Join in 30 seconds.
After reading this file you will: - Know the signature and behavior of errors.Join. - Know how nil arguments are handled (filtered). - Be able to use errors.Is and errors.As against a joined error. - Know the difference between Join (multi-error) and fmt.Errorf("%w", ...) (chain). - Know when to use Join and when not to.
Prerequisites¶
- Required: Basic error handling in Go — you know
if err != nil. - Required:
errors.Newandfmt.Errorf— you have created errors before. - Required:
errors.Isanderrors.As(covered in 5.5) —Joininteracts with them. - Helpful but not required:
Unwrap() error(single-error unwrap, also 5.5). - Helpful but not required: Familiarity with one of the older multi-error libs (
hashicorp/multierror,uber-go/multierr) —Joinreplaces them.
You should be on Go 1.20 or newer. The function does not exist in earlier versions.
Glossary¶
| Term | Definition |
|---|---|
errors.Join | Standard-library function (Go 1.20+) that combines several errors into one. |
| multi-error | An error value that holds multiple distinct errors as siblings, not as a chain. |
| error chain | A linked list of errors created by repeated fmt.Errorf("...: %w", err). Walked by errors.Unwrap returning a single error. |
| error tree | The graph you get when you mix chains and joins. errors.Is/errors.As walk it depth-first. |
Unwrap() error | Single-error unwrap — turns one error into its predecessor. |
Unwrap() []error | Multi-error unwrap (Go 1.20+) — turns one error into a slice of joined errors. |
| joinError | The internal type returned by errors.Join (unexported). |
| filter nil | Join drops nil arguments before storing; if all are nil it returns nil. |
| leaf error | An error in the tree that has no further Unwrap to follow. |
Core Concepts¶
Concept 1: errors.Join makes one error from several¶
The signature:
You pass any number of errors (including zero); you get back either nil (if every argument was nil or there were no arguments) or one error value that contains the non-nil ones.
Conceptually err is now a bag of three errors. Printing it concatenates their messages with newlines. Testing it with errors.Is(err, target) checks each one.
Concept 2: nil arguments are filtered¶
The function ignores nil automatically:
errors.Join(nil, nil) // returns nil
errors.Join(err1, nil, err2) // returns a join of {err1, err2}
errors.Join() // returns nil
errors.Join(nil) // returns nil
This is the biggest convenience of the function. You do not need to write if err != nil { errs = append(errs, err) } everywhere — pass them all in, the nils disappear.
But note this subtlety:
Even with one non-nil argument, you still get a *joinError, not the original error. The wrapper is preserved so you can rely on Unwrap() []error and on the newline-separated Error() format.
Concept 3: Error() is newline-separated¶
The default Error() method joins the messages with \n:
err := errors.Join(
errors.New("first"),
errors.New("second"),
errors.New("third"),
)
fmt.Println(err)
// first
// second
// third
Three lines, in input order. No prefix, no count, no separator other than newline. This is fine for human-readable logs and ugly for compact diagnostics. If you want a different format, build your own multi-error type (see middle.md) or join the strings yourself.
Concept 4: errors.Is and errors.As walk into joined errors¶
target := errors.New("not found")
err := errors.Join(otherErr, fmt.Errorf("wrap: %w", target))
if errors.Is(err, target) {
fmt.Println("found target inside the join")
}
errors.Is walks the tree: it checks the join itself, then each child, recursively. errors.As does the same, finding the first error in the tree that matches the target type. You do not have to know how many errors are inside, or how deep — the walker handles it.
Concept 5: It is not a chain¶
A common confusion: Join does not produce the same shape as fmt.Errorf("%w: %w: %w", a, b, c). The two return distinct kinds of error trees:
fmt.Errorf("%w", x)— wrapping. One error wraps another, single-line by default.errors.Join(x, y)— collection. One value holds many siblings, multi-line by default.
You can mix them — wrap a join, join some wraps — and the standard library walks both kinds correctly. But know which one you are reaching for.
Real-World Analogies¶
| Concept | Analogy |
|---|---|
errors.Join | A school nurse stapling three "things wrong with this student today" forms together — sore throat, fever, missing assignment. |
Unwrap() []error | The forms come unstapled when asked: each is its own complaint. |
| nil filtering | The nurse discards blank forms before stapling. |
errors.Is over a join | "Does any of these forms mention strep?" — check each, return yes if any does. |
| A wrap | One form "Re: previous note" referring to an earlier diagnosis — a chain, not a sibling. |
| A join inside a wrap | A cover letter that says "see attachments below" plus the multi-form bundle. |
Mental Models¶
The bag model. A joined error is a bag with non-nil errors inside. The bag itself is one error value; reaching in requires Unwrap() []error. Printing the bag prints each contents on its own line.
The set-of-records model. Think of the join as a row in a "validation results" table. Each child error is a separate record explaining one failure. The record set is what callers want to show the user, all at once.
The tree model. Once Join exists alongside the older Unwrap() error, every Go error is potentially a tree: nodes that wrap a single child, nodes that wrap many. errors.Is and errors.As are the visitors — DFS pre-order, accepting at the first match. You do not have to think about the shape; you just have to remember that the walkers handle both shapes.
Pros & Cons¶
| Pros | Cons |
|---|---|
| Standard-library, no third-party dependency. | Requires Go 1.20+. |
Tiny API: one function, one interface (Unwrap() []error). | The default Error() is plain newlines — no formatting hooks. |
errors.Is/As work without effort across joins. | Naive use can hide context (which child of which parent failed?). |
| nil-filtering removes a class of boilerplate. | A 1-error join is not the same value as the bare error — surprises some tests. |
Fits naturally with fmt.Errorf multi-%w. | No built-in way to collect errors during iteration — you still write the loop. |
When to use:¶
- Aggregating validation errors so the user sees all problems at once.
- Closing several resources and reporting every failure.
- Returning the result of N parallel jobs where each can fail independently.
When NOT to use:¶
- When the first error means "stop and back out" — return early, do not collect.
- When errors form a causal chain ("A failed because B failed because C") — that is
%wwrapping, not joining. - When you want a typed multi-error with custom formatting — write your own type implementing
Unwrap() []error.
Use Cases¶
- Form / payload validation — collect every field's error, present them as a list.
- Resource cleanup — call N closers, join the failures.
- Batched goroutine work — each goroutine reports an error; the dispatcher returns
Joinof them. - Configuration loading — multiple sources fail in different ways; show them all.
- Migration / replacement —
errors.Joinreplacesmultierror.Appendin most existing code.
Code Examples¶
Example 1: The minimal Join¶
package main
import (
"errors"
"fmt"
)
func main() {
a := errors.New("a failed")
b := errors.New("b failed")
err := errors.Join(a, b)
fmt.Println(err)
}
What it does: Combines two errors. fmt.Println prints them on two lines.
Example 2: nil filtering¶
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.Join(nil, errors.New("only one"), nil)
fmt.Println(err)
}
What it does: The nils are discarded; only "only one" ends up in the join.
Example 3: All-nil returns nil¶
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.Join(nil, nil, nil)
fmt.Println(err == nil)
}
What it does: Prints true. Useful: if every individual operation succeeded, the joined value is nil and your if err != nil check works as expected.
Example 4: errors.Is against a join¶
package main
import (
"errors"
"fmt"
"io/fs"
)
func main() {
err := errors.Join(
errors.New("network glitch"),
fmt.Errorf("config: %w", fs.ErrNotExist),
)
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("found ErrNotExist somewhere in the join")
}
}
What it does: Even though fs.ErrNotExist is buried inside one of the joined errors, errors.Is finds it by walking the tree.
Example 5: Validation collector¶
package main
import (
"errors"
"fmt"
)
type User struct {
Name string
Email string
Age int
}
func validate(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if u.Age < 0 {
errs = append(errs, errors.New("age must be non-negative"))
}
return errors.Join(errs...) // returns nil if errs is empty
}
func main() {
err := validate(User{})
if err != nil {
fmt.Println("validation failed:")
fmt.Println(err)
}
}
What it does: The standard validation pattern. Collect into a slice, Join at the end, the empty case naturally yields nil.
Every example must be runnable. Include
package mainandfunc main().
Coding Patterns¶
Pattern 1: Append-and-Join¶
var errs []error
for _, x := range xs {
if err := process(x); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
The most common shape. Append into a slice; one call at the end. nil-filtering means a clean run yields nil.
Pattern 2: Defer-collect on close¶
func close(a, b io.Closer) (err error) {
if e := a.Close(); e != nil {
err = errors.Join(err, e)
}
if e := b.Close(); e != nil {
err = errors.Join(err, e)
}
return err
}
Close every resource; collect every failure. errors.Join(nil, e) becomes a 1-error join; passing in nil arguments is harmless.
Pattern 3: Don't-stop-on-first¶
var multi error
for _, step := range steps {
if err := step(); err != nil {
multi = errors.Join(multi, err)
}
}
return multi
When the next step is independent of the previous, collect rather than stop. (Compare with the opposite pattern: short-circuit on first error.)
Pattern 4: Combine-then-wrap¶
Wrap the joined error to add context. The wrap chains over the join; errors.Is/As still walks both layers.
Pattern 5: Multi-%w (Go 1.20+)¶
Since Go 1.20, fmt.Errorf accepts more than one %w. The result implements Unwrap() []error just like errors.Join. Useful when you also want a custom formatted message.
Clean Code¶
- Use
Joinfor siblings,%wfor causes. Two errors caused by the same operation = join. Error A caused by error B = wrap. - Pass slices with
...; do notJoinin a loop unless you have a reason.Joinallocates a new joinError each call. - Let the all-nil case work for you.
Joinreturnsnilcleanly; do not pre-check for empties. - Wrap once, at a boundary. Do not nest joins in joins arbitrarily — flatten.
- Print or log the joined error in full. Truncating it to one line discards the data the join exists to preserve.
Product Use / Feature¶
A typical HTTP handler that validates and reports all failures at once:
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := validate(u); err != nil {
// Send the user *all* the problems, not just the first.
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// ... save user ...
}
The user submitting {} will see:
Three problems in one round-trip — the user fixes all three at once, instead of bouncing through three "Submit → fail → fix → repeat" cycles. That is the small UX win the multi-error pattern enables.
Error Handling¶
- A joined error implements
Unwrap() []error— notUnwrap() error. Code that callserrors.Unwrap(err)(the function) on a joined error getsnil. errors.Is(err, target)walks bothUnwrap() errorandUnwrap() []errorinterfaces. Use it; do not test the slice manually.errors.As(err, &target)does the same — it finds the first match in the tree.- A joined error can itself be wrapped or joined. Trees can be deep; the walkers cope.
- Returning a joined error from a function works exactly like returning a plain error. The caller treats it as one
errorvalue.
Security Considerations¶
- A joined error's default
Error()puts every child's message on a separate line. If any child message contains user input or sensitive context, all of it ends up in your log. Sanitize at the source (the same way you would sanitize any error message). - Multi-error messages can be much longer than single-error ones. Make sure your log infrastructure tolerates large records.
- Returning a joined validation error in an HTTP response leaks every validation rule the user violated. Usually fine for forms; sometimes a flag for an attacker to enumerate fields. Prefer a structured response (JSON list) over the raw
Error()string for public APIs.
Performance Tips¶
errors.Joinallocates a*joinErrorand copies its argument slice. The cost is a few hundred nanoseconds for typical inputs and one or two allocations.- Calling
Joinonce at the end of a loop is cheaper than calling it inside the loop and re-wrapping the previous join. Append into a slice;Join(errs...)at the bottom. - Do not
Jointwonils in a hot loop hoping it is free — it does walk the slice to filter, even if it returnsnil. - The
Error()method callsError()on each child and joins with newlines — proportional to total message length. - See
optimize.mdfor benchmark numbers and how to capture errors without per-iteration allocation.
Best Practices¶
- Use
errors.Joininstead ofmultierror.Appendin any new code on Go 1.20+. - Collect into a slice;
Join(errs...)at the bottom — one allocation, one place that can fail. - Keep child errors small. A joined error of 50 errors with 10 KB messages each is 500 KB of log per failure.
- Wrap before joining if context matters. "Step 1 failed: …" is more useful than the bare child error inside a multi-error.
- Implement
Unwrap() []erroron your own multi-error types so they integrate witherrors.Isanderrors.Asfor free. - Test with
errors.Isto confirm sentinels survive a round-trip throughJoin.
Edge Cases & Pitfalls¶
errors.Join(err)is noterr. A single-element join still wraps the error.==comparison fails;errors.Isstill works.errors.Join()(zero args) returnsnil.errors.Join(nil, nil)returnsnil.errors.Unwrap(joined)(the function, not the method) returnsnil.Unwraponly knows the single-error interface.- Type assertion to a custom multi-error type fails.
errors.Joinreturns*errors.joinError(unexported); you cannot type-assert to it. Useerrors.As(err, &slice)if you need the children. - The slice returned from
Unwrap() []errorshould not be mutated. It is a view into internal state; modify it and the nexterrors.Iscall sees garbage. JoinofJoinis not flattened. A nested join is itself a child error. The walkers see through it; printing shows the nesting only via newlines.
Common Mistakes¶
- Reaching for
multierrorpackages on a Go 1.20+ project. Use the standard library. - Manually formatting a multi-error string with
strings.Joininstead oferrors.Join. You loseerrors.Is/As. - Calling
errors.Unwrap(joined)and being surprised it returnsnil. The function only followsUnwrap() error. - Forgetting that a 1-element
Joinstill wraps. Tests that compare==to the input error fail. - Joining inside a loop:
multi = errors.Join(multi, err)is fine but does N allocations;appendthen oneJoin(...)is cheaper. - Mutating the slice from
Unwrap() []error. Treat it as read-only. - Joining with
nil"to keep things simple" and then expectingerrors.Is(err, x)to behave differently. nil children are dropped — they do not influenceIs.
Common Misconceptions¶
- "
errors.Join(a)isa." It is not — the function always returns a*joinErrorfor any non-nil input list. - "
Joinflattens nested joins." It does not.Join(Join(a, b), c)is a 2-element joinError whose first child is itself a 2-element joinError. - "
errors.Unwrapreturns the slice." No — the functionerrors.Unwrapreturns a single error or nil. To get the slice you call the methodUnwrap() []error(rare) or useerrors.Asto find a known node. - "
Joinis the same asmultierror.Append." Close, butAppendmutates a result;Joinis pure. The migration is mechanical, not identical. - "
Joinis for chaining causes." No. Use%wfor causes andJoinfor siblings.
Tricky Points¶
- Single-
%wvs multi-%winfmt.Errorf.fmt.Errorf("%w", err)produces a single-error wrap (Unwrap() error).fmt.Errorf("%w; %w", a, b)produces a multi-error wrap (Unwrap() []error). SameErrorfcall, two different result shapes depending on how many%ws you use. Join's argument order is preserved — children appear in the order you passed them. The error message reflects that.- An error type can implement both
Unwrap() errorandUnwrap() []error. The latter wins forerrors.Is/As. Avoid having both unless you know exactly what you are doing. Joindoes not deduplicate. Pass the same error twice, you get it twice.
Test¶
package multi
import (
"errors"
"io/fs"
"testing"
)
func TestJoinFiltersNil(t *testing.T) {
err := errors.Join(nil, nil)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
}
func TestIsWalksJoin(t *testing.T) {
sentinel := fs.ErrNotExist
err := errors.Join(errors.New("other"), sentinel)
if !errors.Is(err, sentinel) {
t.Fatalf("Is should find sentinel inside join")
}
}
func TestJoinSingleStillWraps(t *testing.T) {
inner := errors.New("inner")
err := errors.Join(inner)
if err == inner {
t.Fatalf("single-arg Join should not return the original error")
}
if !errors.Is(err, inner) {
t.Fatalf("Is should still match")
}
}
Run with: go test ./...
Tricky Questions¶
-
What does
errors.Join()(zero args) return?nil. -
What does
errors.Join(nil, nil)return?nil. All-nil is the same as zero args after filtering. -
Is
errors.Join(err)the same value aserr? No. It is a*joinErrorof one element.errors.Is(err, original)is true;==is false. -
How does
errors.Iswalk a joined error? It checks the join itself, then visits each child in order, recursively (DFS pre-order). -
Why does
Joinuse newlines inError()? Because that is the standard library's choice for human readability. Custom multi-errors can override. -
Does
fmt.Errorf("%w; %w", a, b)produce the same shape aserrors.Join(a, b)? Almost. Both implementUnwrap() []error. The difference is thatErrorfuses your format string forError();Joinuses newline separation.
Cheat Sheet¶
import "errors"
// Combine
err := errors.Join(a, b, c)
// nil filtering
errors.Join(nil, nil) // nil
errors.Join(err, nil) // 1-element join
errors.Join() // nil
// From a slice
err := errors.Join(errs...)
// Walk
errors.Is(err, target) // visits every child
errors.As(err, &target) // finds first match
// Multi-%w (1.20+)
fmt.Errorf("a: %w; b: %w", a, b)
// Inspecting children (rare)
type unwrapper interface{ Unwrap() []error }
if u, ok := err.(unwrapper); ok {
children := u.Unwrap()
}
Self-Assessment Checklist¶
- I know
errors.Joinwas added in Go 1.20. - I know that
errors.Join(nil, nil)returnsnil. - I know that
errors.Join(err)is not the same value aserr. - I can use
errors.Isanderrors.Asagainst a joined error. - I can write a validator that returns all errors at once via
Join. - I know the difference between
%w(chain) andJoin(siblings). - I know that
Unwrap() []erroris the multi-error interface. - I do not mutate the slice returned by
Unwrap() []error.
Summary¶
errors.Join is the standard-library answer to "I have several errors and want to return them as one." It filters nils, returns nil if everything was nil, and produces a value whose Error() is newline-separated and whose Unwrap() []error exposes the children to errors.Is and errors.As. It complements — it does not replace — fmt.Errorf("%w", ...): chains are for causes, joins are for siblings. Reach for Join when validation errors should accumulate, when cleanup paths should not lose information, and whenever you used to import hashicorp/multierror or uber-go/multierr. Keep the children small, prefer one big Join(...) at the bottom of a loop over per-iteration nesting, and treat the slice returned by Unwrap() as read-only.
What You Can Build¶
- A validator function that collects every field error and returns them as a single value the HTTP layer can pretty-print.
- A
MultiClosertype that closes a list ofio.Closers, collecting every failure. - A simple parallel runner that runs N jobs and returns
errors.Joinof every job's error. - A migration shim that re-implements
multierror.Appendin terms oferrors.Joinso old call sites continue to work without third-party imports.
Further Reading¶
- Package errors — Join
- Go 1.20 release notes — errors
- Russ Cox — Working with Errors — background on
Is/As(the same walker now handles joins) - github.com/hashicorp/go-multierror — the older library
Joindisplaces - go.uber.org/multierr — Uber's variant
$GOROOT/src/errors/join.go— read it; ~50 lines.
Related Topics¶
- 05-wrapping-unwrapping-errors —
%wandUnwrap() error;Joinis the multi-error sibling. - 06-sentinel-errors —
errors.Isagainst a join still finds sentinel children. - 10-custom-error-types — implementing
Unwrap() []erroron your own multi-error type. - 12-error-design-best-practices — when to collect vs short-circuit.
Diagrams & Visual Aids¶
errors.Join(a, b, c):
+-------------+
| *joinError |
| errs: |
| [a, b, c] |
+-------------+
|
----------------------
| | |
v v v
a b c
fmt.Errorf("%w", err) vs errors.Join(a, b)
chain (single-error wrap) tree (multi-error)
wrap a *joinError
| / \
v a b
err