Why must a custom io.Reader return io.EOF itself rather than a wrapped end-of-stream error?
answer
- the sentinel is a protocol token
- the plumbing compares, it does not unwrap
- errors.Is passes where the library fails
- context belongs above the stream layer
- an illegal ending has its own sentinel
basics
~20 sStandard 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 sThe 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 linesfunc (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
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.
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.
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.
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