skip to content

Why must a caller process the n bytes from io.Reader.Read before checking its error?

level: seniorimportance: should knowfreq 40%

answer

  1. one call can return data and an error
  2. the exception to the zero-value rule
  3. handle buf[:n] first
  4. EOF means finished, not failed
  5. in-memory readers hide it in tests

basics

~20 s

io.Reader.Read may return n greater than zero together with a non-nil error such as io.EOF. Those bytes are real data: handle buf[:n] first, then act on the error, or the stream's tail is silently lost.

solid answer

~50 s

The general Go convention is that results beside a non-nil error are meaningless, and `io.Reader.Read` is the documented exception: it is allowed to return `n > 0` and a non-nil error in the same call, and its contract tells callers to process those n bytes before considering the error. A reader that hits the end of its data can legitimately return the final bytes together with `io.EOF`. Code written as `if err != nil { return err }` before touching `buf[:n]` therefore truncates input — and only against readers that behave that way, so it passes tests over a `bytes.Reader` and drops the last chunk from a network or compressed stream. The loop shape is: read, handle `buf[:n]` if `n > 0`, then treat `io.EOF` as normal termination and any other error as a failure. Most code should avoid the question entirely by using `io.Copy`, `io.ReadFull` or `bufio.Scanner`, which encode the rule correctly.

code

go · 13 lines
go
buf := make([]byte, 32*1024)
for {
	n, err := r.Read(buf)
	if n > 0 {
		process(buf[:n]) // these bytes are real even when err != nil
	}
	if err != nil {
		if err == io.EOF {
			return nil // normal end of stream
		}
		return err
	}
}

go deeper

for a junior

Know that reading a stream ends at io.EOF and that io.Copy or bufio.Scanner does the loop for you. Recognising that io.EOF is a normal ending rather than a problem is enough here.

for a middle

Explain the contract: Read may return a positive n together with a non-nil error, so the data is handled first and the error is classified second. Be able to write the loop correctly on a whiteboard.

for a senior

Show the production judgment — why in-memory readers hide the bug, what silent size-dependent data loss looks like on call, and why you would reach for io.Copy, io.ReadFull or bufio.Scanner rather than hand-rolling the loop at all.

for a principal

Own the API-shape lesson: this is what it costs when one interface deviates from a codebase-wide convention. Be ready to argue when a deviation is worth the syscall it saves and how you document it so callers cannot get it wrong quietly.

## The convention and its exception Go's baseline rule is simple: if a function returns a non-nil `error`, its other results are zero values and mean nothing. `io.Reader` deliberately breaks that rule, and it is the most consequential exception in the standard library. ```go type Reader interface { Read(p []byte) (n int, err error) } ``` The documented contract says an implementation may return the bytes it managed to read **and** the error that stopped it, in the same call — and that callers should always process the `n > 0` bytes returned before considering the error. The reason is physical: a stream can hand you the last 300 bytes of a file and discover the end of the file in the same underlying operation. Forcing it to report those separately would mean an extra syscall on every stream, so the interface allows both at once and pushes the responsibility onto the caller. ## The bug this creates The habit built by every other Go API produces exactly the wrong loop: ```go for { n, err := r.Read(buf) if err != nil { return err // wrong: buf[:n] may hold real data } process(buf[:n]) } ``` This truncates. It also truncates *intermittently*, which is what makes it expensive. Whether a given reader returns `(300, io.EOF)` or `(300, nil)` followed by `(0, io.EOF)` is entirely up to the implementation. `bytes.Reader` and `strings.Reader` tend to take the second path, so a unit test built on an in-memory reader passes forever. A file, a decompressing reader, a TLS connection or an HTTP response body may take the first, so the same code drops the final record in production against a real source, and only sometimes. The correct loop handles data first and classifies the error afterwards: ```go for { n, err := r.Read(buf) if n > 0 { process(buf[:n]) } if err != nil { if err == io.EOF { return nil // normal end of stream } return err } } ``` ## io.EOF is not a failure The second half of the rule: `io.EOF` reports that the stream ended normally, so it is a termination signal rather than an error condition, and it must be distinguished from every other error. Code that returns `io.EOF` up its own call chain teaches its callers to treat a successful read of a whole file as a failure. The standard library is careful about this in both directions. `io.Copy` copies until the source reports EOF and then returns a **nil** error — a successful `Copy` never returns `io.EOF`. `io.ReadFull` inverts it: it returns `io.EOF` only if no bytes were read at all, and `io.ErrUnexpectedEOF` if it read some but not all of what you asked for, so a truncated record is distinguishable from an empty one. ## The other permitted oddity A `Read` may also return `(0, nil)` — no data, no error, nothing happened. The contract says callers should treat that as a no-op and try again rather than concluding the stream is finished, and that implementations should not return it habitually. A hand-written loop that treats `n == 0` as end-of-stream will hang or exit early depending on which mistake it makes; a loop that keys termination on `io.EOF` alone is immune. ## What to do instead of writing the loop Almost no application code should be calling `Read` directly. The standard library provides shapes that already encode the contract: - `io.Copy(dst, src)` for streaming everything from one place to another, with no buffer management and no EOF handling of your own. - `io.ReadFull(r, buf)` when you need exactly `len(buf)` bytes and want a distinct error for a short read. - `io.ReadAll(r)` when the input is bounded and you want it in memory — bounded being the operative word, since it will happily allocate whatever the source sends. - `bufio.Scanner` for line- or token-oriented input, where `Scan` returns false at the end and `Err` afterwards reports whether that end was EOF or a real failure. Note its default token size limit, which turns an over-long line into an error rather than a silent truncation. When you *are* implementing a `Reader` — a decorator that counts bytes, decrypts, or rate-limits — the same contract binds you as the author: return the bytes you have, do not swallow an error you have already observed, and return `io.EOF` (not a wrapped variant) at the end of the stream, because callers compare against it. ## How this shows up on call The symptom is data loss without an error: the last line of a file missing from an import, a record count off by one that varies with input size, or a checksum mismatch on large payloads only. Nothing logs, because nothing failed — the code faithfully reported the `io.EOF` it was handed and discarded the bytes that came with it. That combination, silence plus size-dependence, is the signature worth recognising.

  • Why does this bug survive unit tests?
    Whether a reader returns the final bytes with io.EOF or on a separate call is implementation-defined. In-memory readers such as bytes.Reader typically return the data first and EOF on the next call, so the wrong loop passes; files, compressed streams and network connections often combine them, so production loses the tail.
  • What does io.Copy return when the source reaches the end of its data?
    The number of bytes copied and a nil error. Copy treats io.EOF as the normal stop condition and never reports it as a failure, which is exactly why using Copy is safer than hand-rolling a read loop for a straight transfer.
  • You are writing your own io.Reader wrapper. What does the contract oblige you to do?
    Return the bytes you actually produced along with whatever error stopped you rather than discarding either, return plain io.EOF at the end of the stream because callers compare against that value, and avoid returning (0, nil) as a habit since callers must treat it as a no-op and retry.

saying these in an interview costs you the question

  • Returns immediately on any error without using buf[:n]
  • Treats io.EOF as a failure to report upward
  • Assumes n is always zero when the error is non-nil
  • Ends the loop when n is zero instead of on EOF
  • Says a bytes.Reader test proves the loop is correct