skip to content

Why must a custom io.Reader return io.EOF itself rather than a wrapped end-of-stream error?

level: seniorimportance: nice to knowfreq 30%

answer

  1. the sentinel is a protocol token
  2. the plumbing compares, it does not unwrap
  3. errors.Is passes where the library fails
  4. context belongs above the stream layer
  5. an illegal ending has its own sentinel

basics

~20 s

Standard library stream code detects the end by comparing the error to io.EOF by identity, not by unwrapping it. A wrapped or custom ending therefore surfaces as a real failure from io.Copy and from a scanner, so return io.EOF unchanged.

solid answer

~50 s

The plumbing that consumes readers tests for the end with a direct comparison against `io.EOF`, not with `errors.Is`. `io.Copy` stops on any error and only suppresses the one that *is* `io.EOF`; `io.ReadAll` converts exactly that value to nil; `bufio.Scanner.Err` returns nil only when the error it stored is exactly `io.EOF`. So if your `Read` returns `fmt.Errorf("read chunk: %w", io.EOF)`, every one of those reports a failure at what should be a normal finish — `errors.Is` would have matched, but nothing in that path calls it. The rule is therefore: a `Read` implementation returns the bare sentinel. Add context higher up, at a boundary where the error is no longer being handed back to stream plumbing, and if the ending was actually illegal, return `io.ErrUnexpectedEOF` or a domain error instead — never `io.EOF` dressed up, and never `io.EOF` for a genuine fault.

code

go · 10 lines
go
func (r *recordReader) Read(p []byte) (int, error) {
	if r.done {
		return 0, io.EOF // bare: stream plumbing compares by identity
	}
	n, err := r.src.Read(p)
	if err != nil && err != io.EOF {
		return n, fmt.Errorf("record source: %w", err)
	}
	return n, err
}

go deeper

for a junior

Remember the rule as written: when your Read has no more data, return io.EOF exactly, with no formatting and no extra message around it.

for a middle

Explain why: io.Copy, io.ReadAll and a scanner's Err all detect the end by comparing against the sentinel value rather than unwrapping, so a wrapped ending is seen as a real failure.

for a senior

Show where context does belong — at a boundary above the stream, or as io.ErrUnexpectedEOF when the ending was actually illegal — and be able to spot the wrapped-end smell in review.

for a principal

The judgment to own is API shape: a type that satisfies a standard interface inherits its conventions wholesale, so deviating buys nothing and costs every future consumer, which is also the argument for a bool-plus-Err iterator instead.

## The rule An implementation of `Read(p []byte) (int, error)` signals the end of its data by returning the value `io.EOF` itself. Not a copy, not a wrap, not a package-specific `ErrEndOfFeed`. This is one of the few places in Go where an unadorned sentinel is mandatory rather than merely idiomatic, and the reason is mechanical. ## Why identity, not errors.Is The standard library's stream plumbing predates error wrapping and, more importantly, sits on the hottest path in every copy. It compares directly: - `io.Copy` loops until a read returns an error; it breaks either way, but records the error as a failure **unless** that error is `io.EOF`. The test is an equality comparison against the sentinel. - `io.ReadAll` does the same conversion: exactly `io.EOF` becomes nil, everything else is returned. - `bufio.Scanner` stores whatever error its reader gave it and its `Err` method returns nil only when the stored value is exactly `io.EOF`. None of these unwraps. So `fmt.Errorf("read chunk: %w", io.EOF)` is, from their point of view, an unknown error — even though `errors.Is(err, io.EOF)` on the same value returns true. Your caller's own check would pass; the library's would not. That asymmetry is the whole trap. The visible consequences are concrete: `io.Copy` returns a non-nil error at the end of every successful copy, so callers log a failure on every good run; `io.ReadAll` returns your data *and* an error; a scan loop reports an error for input that was perfectly fine. Worse, the errors look plausible — "read chunk: EOF" reads like something genuinely went wrong — so the bug is often "fixed" downstream by suppressing errors whose text contains EOF, which then hides real failures. ## What to return instead, and where to add context Three cases, three answers: 1. **The stream ended and that is legal.** Return `io.EOF`, bare. Nothing else. 2. **The stream ended and that is not legal** — you had committed to more bytes. Return `io.ErrUnexpectedEOF`, or a domain error that names what was truncated. This is the honest place for context, and no stream helper will mistake it for a finish. 3. **Something actually failed** — a socket reset, a bad checksum, a decompression error. Return that error. Never substitute `io.EOF`, which would tell every caller the data was complete. Context belongs at a boundary where the value stops being a stream signal and becomes a result: the function that returns "failed to load the config" to your caller can wrap freely, because nothing there is going to feed the value back into `io.Copy`. ## The mirror rule for consumers The same trap runs the other way. If your package exposes an iterator-style `Next() (Record, error)` that reports the end with `io.EOF`, then propagating it wrapped breaks callers who wrote `err == io.EOF` — the idiom the sentinel invites. Either return it bare, or design the API so the ending is not an error at all: a `Next() bool` with a separate `Err() error`, which is the shape `bufio.Scanner` chose and which removes the question entirely. ## Reading it in review Two review smells cover most of this. A `Read` method whose returned error is built with `fmt.Errorf` and includes the end-of-stream path. And a custom sentinel like `var ErrDone = errors.New("done")` returned from something that satisfies `io.Reader` — that type will work with your code and fail with everyone else's, which is the worst possible failure mode for a type whose entire value is being interchangeable. ## The one sentence `io.EOF` is not just a value you return; it is a protocol token the whole ecosystem compares by identity, so it must arrive unchanged.

  • A Reader returns an error wrapping io.EOF at the end of its data. What does io.Copy do?
    It stops and returns that error as a copy failure. io.Copy suppresses only the value that is exactly io.EOF, so a wrapped one is treated like any other read error — every successful copy of that reader now reports a failure, even though errors.Is on the same value would report a clean end.
  • So is wrapping io.EOF always wrong?
    Not everywhere — wrong inside a Read implementation and anywhere the value is still travelling as a stream signal. Above that, at a boundary where the error has become a result your own caller inspects, adding context is fine. The test is simple: could this value be handed back to stream plumbing? If yes, keep it bare.
  • Your iterator API needs to report the end. Should it use io.EOF?
    Either return io.EOF bare, so the familiar comparison works, or avoid the question by copying the scanner shape: a Next method returning bool plus a separate Err method. The second is often cleaner, because the end stops being an error value at all and there is nothing for a caller to mishandle.

It is a standard-sized coupling on a rail wagon: decorate it however you like and it no longer fits the rest of the train.

saying these in an interview costs you the question

  • Wrapping io.EOF with fmt.Errorf inside a Read implementation
  • Assuming stream helpers call errors.Is on the read error
  • Inventing a package-specific end-of-stream sentinel for a Reader
  • Returning io.EOF for a genuine read failure
  • Suppressing errors whose message text contains EOF