skip to content

Why is io.EOF returned by a Read call treated as a normal end of input, not a failure?

level: juniorimportance: must knowfreq 82%

answer

  1. not a fault, just a finish
  2. Read has no separate done flag
  3. a package-level sentinel from errors.New
  4. ReadAll and Copy return nil at the end
  5. test for it, not just err != nil

basics

~20 s

io.EOF is a sentinel error the io.Reader contract uses to say the stream is finished, so nothing went wrong. Callers detect it, stop reading and return success; they never log it or pass it on as a fault.

solid answer

~50 s

`io.EOF` is a package-level sentinel, `var EOF = errors.New("EOF")`, and the `io.Reader` contract gives it exactly one meaning: there is no more input. It travels in the error slot only because `Read` has no separate "done" flag, not because anything broke. The idiom is to test for it specifically — `err == io.EOF`, or `errors.Is(err, io.EOF)` if the value may have passed through a layer that wrapped it — then stop the loop and return `nil`. Helpers that consume a whole stream absorb it for you: `io.ReadAll` and `io.Copy` return a nil error at a clean end, never `io.EOF`. Two mistakes follow from misreading it. Treating it as a failure means every successful read logs an error. Treating every error as an end of stream is worse: a genuine read failure then looks like a clean finish and a partial stream is processed as complete.

code

go · 6 lines
go
data, err := io.ReadAll(r)
if err != nil {
	// a real read failure; a clean end of stream gives err == nil
	return err
}
use(data)

go deeper

for a junior

Be ready to say in one sentence that io.EOF means the stream is finished and is not a failure, and to show a read loop that stops on it and returns success.

for a middle

Explain why the end arrives through the error slot at all, and which helpers absorb it: io.ReadAll and io.Copy give you a nil error at a clean end, while io.CopyN reports io.EOF because it was promised a length.

for a senior

Show that you know what io.EOF cannot tell you: a truncated stream and a complete one look identical at the byte layer, so the format must carry a length or terminator if truncation has to be detectable.

for a principal

The angle to own is the contract you publish: a stream API that reports its ending in some bespoke way forces every consumer to write special-case code, so keep the sentinel the whole ecosystem already tests for.

## What io.EOF is `io.EOF` is one variable in the standard library's `io` package, declared as `var EOF = errors.New("EOF")`. Because `errors.New` returns a fresh pointer every time it is called, that single value is unique: code recognises the end of a stream by comparing against **that** value, not by looking at the message text. Values used this way are called *sentinel errors*. ## Why an ending is reported through the error slot The reading contract in Go is one method: ``` Read(p []byte) (n int, err error) ``` There are only two return slots — how many bytes landed in `p`, and an error. There is no third boolean saying "the stream is over", so the end has to be reported through the error slot. `io.EOF` is that report. It is a *status*, not a fault: the producer said everything it had to say and closed the conversation politely. This is why the Go standard library's documentation is careful to say that `io.EOF` "means no more input is available", and why library code that consumes a stream is expected to convert it into ordinary success. ## The caller's idiom A hand-written read loop distinguishes three outcomes on every iteration: bytes arrived, the stream ended, something broke. ``` buf := make([]byte, 32*1024) for { n, err := r.Read(buf) if n > 0 { consume(buf[:n]) } if err == io.EOF { return nil // finished, and that is success } if err != nil { return err // a real failure } } ``` The two `if`s must be in that order and must be separate. Collapsing them into a single `if err != nil { return err }` turns a normal finish into an error return; collapsing them the other way, `if err != nil { break }` followed by `return nil`, turns a disk or network failure into a silent success. ## Helpers that absorb it for you Most real code never writes that loop, because the standard library already contains it: - `io.ReadAll(r)` reads to the end and returns `(data, nil)`. A successful call returns a nil error, not `io.EOF`. - `io.Copy(dst, src)` returns `(written int64, err error)` and likewise reports `nil` at a clean end; it never returns `io.EOF`. - `bufio.Scanner` never exposes `io.EOF` at all: `Scan` returns `false` at the end, and `Err` reports the first *non-EOF* error, so it is `nil` when the input simply ran out. The one common helper that deliberately does the opposite is `io.CopyN(dst, src, n)`: it must copy exactly `n` bytes, so if the source ends first it returns `io.EOF` as a genuine complaint. `io.ReadFull` behaves the same way in spirit, returning `io.EOF` only when it read nothing and `io.ErrUnexpectedEOF` when it read some but not all of the buffer. The pattern behind all of this: a helper that has no expectation of length treats the end as success, and a helper that was told how many bytes it needs treats an early end as an error. ## == or errors.Is Historically the test is `err == io.EOF`, and the standard library's own stream plumbing still compares by identity. `errors.Is(err, io.EOF)` does the same job and additionally unwraps an error chain, so it is the safer choice when the error has crossed a package boundary that may have annotated it. Reading `err.Error() == "EOF"` is always wrong — the message is not the contract. ## What io.EOF does not tell you It says the byte stream stopped. It does **not** say the producer meant to stop. A file copied halfway, an upload cut off mid-flight and a complete file all end the same way at the byte layer. If your format needs to know the difference, the *format* has to carry it — a length prefix, a record terminator, a checksum, or a trailer — and the code then compares what it actually read against what was promised. Detecting truncation is a framing problem, not something `io.EOF` can answer. ## And the mirror rule Never return `io.EOF` to signal a real failure. If your reader dies mid-record, return the underlying error or `io.ErrUnexpectedEOF`; handing a caller `io.EOF` tells it everything is fine and it will stop cleanly on corrupt data.

  • Would you write err == io.EOF or errors.Is(err, io.EOF)?
    Both work against a bare sentinel, and the standard library's own stream code compares by identity. `errors.Is(err, io.EOF)` additionally walks an `Unwrap` chain, so it is the safer form when the error came back from a package that may have annotated it. Comparing `err.Error()` to the string "EOF" is never acceptable — the message is not the contract.
  • What error does io.Copy return when the source reaches its end?
    Nil. `io.Copy` returns the number of bytes written and a nil error on a clean finish — it absorbs `io.EOF` and only reports errors from the source other than EOF, or errors from the destination. `io.CopyN` is the deliberate exception: because it was told to move exactly n bytes, it returns `io.EOF` if the source ends early.
  • Is it ever right for a Read implementation to return io.EOF for a genuine failure?
    No. `io.EOF` tells every caller the stream ended normally, so a caller will stop and report success on data that is actually broken. Return the underlying error, or `io.ErrUnexpectedEOF` when the stream stopped in the middle of something that needed more bytes.

It is the dial tone at the end of a call, not a dropped line: the other side finished speaking and hung up on purpose.

saying these in an interview costs you the question

  • Logging io.EOF as an error at the end of every read
  • Treating any non-nil error from Read as end of stream
  • Expecting io.Copy or io.ReadAll to return io.EOF on success
  • Assuming io.EOF means the file or connection broke
  • Detecting the end by comparing err.Error() to the text EOF