skip to content

What does errors.Join return when some arguments are nil, and what is its Error text?

level: juniorimportance: must knowfreq 52%

answer

  1. one error value, several causes
  2. nil arguments do not survive
  3. nothing left means plain nil out
  4. messages stack one per line

basics

~20 s

errors.Join skips every nil argument and returns a plain nil if all of them are nil. Otherwise it returns one error whose message is the non-nil errors' messages joined by newlines, one per line, with no trailing newline.

solid answer

~40 s

`errors.Join(errs ...error) error` builds a single error out of several. It counts the non-nil arguments first: if there are none it returns nil, so `errors.Join()` on an empty slice and `errors.Join(nil, nil)` both give you a nil error you can return straight from a function. The nil arguments are dropped entirely, not rendered as text, so a joined error never contains a `<nil>` line. The value it returns holds a slice of the surviving causes, and its `Error()` method concatenates their messages with a `\n` between each one and nothing after the last. That newline format is fixed: you cannot ask Join for commas or a prefix, so if you need a different rendering you format the causes yourself. Join arrived in Go 1.20 alongside `fmt.Errorf` accepting more than one `%w`.

code

go · 11 lines
go
a := errors.New("disk full")
b := errors.New("timeout")

err := errors.Join(a, nil, b)
fmt.Println(err.Error())
// prints:
// disk full
// timeout

fmt.Println(errors.Join(nil, nil) == nil)
// prints: true

go deeper

for a junior

Be ready to state the two nil rules out loud: nil arguments are dropped, and if none survive the result is a plain nil error. Know that the text puts one cause per line.

for a middle

An interviewer expects you to explain why the accumulate-into-a-slice-then-join shape needs no emptiness check, and to note that the joined value keeps the causes as values, not as text.

for a senior

Show that you think about where that multi-line string lands: log pipelines that assume one line per event, HTTP error bodies, and alert text all mangle it, so you enumerate the causes rather than dumping the message.

for a principal

Own the convention for the codebase: which operations are allowed to collect failures instead of failing fast, and what a joined error is permitted to carry across a package boundary other teams depend on.

## The problem Join solves Go's error idiom returns a single `error` value, which is a natural fit for a call chain that stops at the first failure. It is a poor fit for work that must keep going: validating ten fields, closing three resources, fetching forty modules. Before Go 1.20 every team hand-rolled a type with a `[]error` inside it. `errors.Join` put one such type in the standard library so packages could agree on what "several failures, returned as one" means. ## The signature and the nil rules ```go func Join(errs ...error) error ``` Two rules govern the nils, and interviewers ask about both: 1. **Every nil argument is discarded.** Join walks the arguments, counts the non-nil ones, allocates a slice of exactly that size, and copies only the non-nil values into it. A nil never reaches the result, so it never shows up in the text and never appears when you inspect the causes. 2. **If nothing survives, Join returns nil.** That means `errors.Join()` with no arguments, `errors.Join(nil)`, and `errors.Join(nil, nil, nil)` all produce an error value that compares `== nil`. It is a true untyped nil, not a non-nil interface holding an empty joined value, so the typed-nil trap does not apply here. Rule 2 is what makes the common accumulate-then-return shape work without a length check: build a `[]error`, append only real failures to it, and `return errors.Join(errs...)` at the end. When the run succeeded the slice is empty and the caller gets nil. The cost of rule 1 is that the joined value cannot tell you how many operations you attempted, only how many failed. If you were relying on the argument count to report "3 of 40 modules failed", the joined error does not carry the 40, and it does not carry the successes either. Count them yourself. ## The message format The returned value's `Error()` method produces the causes' messages separated by a newline character: ``` disk full timeout ``` There is exactly one `\n` between adjacent messages and none after the final one. With a single surviving cause the text is just that cause's own message, so a joined error of one is textually indistinguishable from the error itself. The format is not configurable. Nothing is prefixed, nothing is numbered, and the causes appear in the order you passed them. This has a practical consequence for logs and HTTP bodies: a joined error is multi-line, and a log pipeline that assumes one line per event will split it into unrelated records. If you are writing structured logs, prefer to enumerate the causes yourself and emit them as a list field rather than dumping `err.Error()` into a message field. It is also why you should never try to recover the individual causes by splitting the text on `\n` — an individual cause's own message may contain newlines, so the split lies. There is a proper accessor for that (an `Unwrap() []error` method), and it is the supported way to walk back to the causes. ## What Join does not do - **It does not deduplicate.** Ten identical `context deadline exceeded` causes give you ten lines. - **It does not flatten.** Passing a joined error to `errors.Join` again nests it as a single cause rather than splicing its children in, so repeated pairwise joining builds a deep tree instead of one flat list. - **It does not add context.** Join records what failed but not which item failed, so wrap each cause with its own identity before joining — `fmt.Errorf("fetch %s: %w", path, err)` — otherwise you get forty identical timeout lines and no way to tell which module produced each. - **It does not sort or prioritise.** If one of the causes matters more than the others, that is your judgment to encode, not Join's. ## Where it sits `errors.Join` and `fmt.Errorf` with two or more `%w` verbs both landed in Go 1.20, and both produce a value carrying several causes rather than one. Join is the right tool when the causes are a homogeneous list you accumulated; multiple `%w` in one `fmt.Errorf` is the right tool when you are writing a sentence that mentions two specific failures.

  • Why can you write `return errors.Join(errs...)` at the end of a loop without first checking whether the slice is empty?
    Because Join returns nil when no non-nil argument survives, and an empty variadic slice has none. The success path therefore returns a genuine nil error with no length check, which is exactly why the accumulate-then-join shape reads so cleanly.
  • How does a joined error's text differ from `fmt.Errorf("a: %w, b: %w", errA, errB)`?
    `fmt.Errorf` gives you the sentence: your format string decides the wording, punctuation and order, and both causes are still recoverable. Join has no format string at all — it always renders the causes on separate lines. Both produce a value with several causes; only one lets you write prose around them.
  • Is it safe to recover the individual causes by splitting the joined error's text on newlines?
    No. An individual cause's own message may contain a newline, so the split produces the wrong number of fragments, and you get strings rather than error values you can match against. Use the `Unwrap() []error` accessor the joined value provides instead.

saying these in an interview costs you the question

  • Says errors.Join returns a non-nil error even when every argument is nil
  • Thinks a nil argument is rendered as a <nil> line in the message
  • Expects comma-separated text or a trailing newline
  • Claims errors.Join panics or errors on a nil argument
  • Splits the joined message on newlines to recover the causes