skip to content

What is the difference between io.EOF and io.ErrUnexpectedEOF, and when does io.ReadFull return each?

level: middleimportance: should knowfreq 44%

answer

  1. same event, two meanings
  2. only a layer with an expectation can tell
  3. nothing read versus something read
  4. zero bytes is a legal ending
  5. a partial buffer is truncation

basics

~20 s

io.EOF means the stream ended where an end was allowed; io.ErrUnexpectedEOF means it ended mid-item with bytes still owed. io.ReadFull returns io.EOF if it read nothing at all, and io.ErrUnexpectedEOF if it read some but not all.

solid answer

~50 s

The two sentinels split one physical event — the bytes ran out — into two meanings that only a layer with an *expectation* can tell apart. `io.EOF` is a legal ending. `io.ErrUnexpectedEOF` is truncation: the reader had committed to more bytes and did not get them. `io.ReadFull(r, buf)` encodes the rule exactly: nil if it filled the buffer, `io.EOF` if it read zero bytes, and `io.ErrUnexpectedEOF` if it read at least one but fewer than `len(buf)`. `io.ReadAtLeast` follows the same rule against its `min`. That three-way result is what makes a length-prefixed or fixed-width record format safe to parse — reading a record header gives you "clean end between records", "good record" and "truncated file" as three distinct outcomes rather than one ambiguous `io.EOF`. Raw `Read` cannot make that distinction, because it was never told how much to expect.

code

go · 10 lines
go
hdr := make([]byte, 8)
n, err := io.ReadFull(r, hdr)
switch {
case err == nil:
	// got all 8 bytes
case err == io.EOF:
	// n == 0: clean end, between records
case err == io.ErrUnexpectedEOF:
	// 0 < n < 8: the file was truncated inside a header
}

go deeper

for a junior

Learn the one-line contrast: io.EOF is a permitted ending, io.ErrUnexpectedEOF means the data stopped in the middle of something that needed more bytes.

for a middle

Be able to recite io.ReadFull's three outcomes — nil when the buffer is filled, io.EOF when nothing was read, io.ErrUnexpectedEOF when some but not all was read — and say why the split needs an expected length.

for a senior

Demonstrate the parsing discipline: after a header commits you to a body, no ending is legal, so io.EOF in that position must be promoted to a failure rather than accepted as a finish.

for a principal

The tradeoff to own is framing: choosing length-prefixed records over an open-ended stream is what buys the whole system the ability to detect truncation at all, and it costs writers a length they must know up front.

## Two sentinels, one event At the byte layer there is only one thing that happens: the source stops producing. Whether that is fine or catastrophic depends entirely on what the reading code was expecting at that moment. Go gives the two interpretations two different values in the `io` package: - `io.EOF` — the stream ended, and an ending was permissible here. - `io.ErrUnexpectedEOF` — the stream ended in the middle of something that required more bytes. Neither can be derived from the stream itself. `io.ErrUnexpectedEOF` only ever comes from a layer that knew how many bytes it needed. ## The rule io.ReadFull implements `io.ReadFull(r Reader, buf []byte) (n int, err error)` reads exactly `len(buf)` bytes, looping over `Read` until the buffer is full. Its results are the canonical statement of the distinction: | bytes read | error | |---|---| | `len(buf)` | `nil` | | `0` | `io.EOF` | | `0 < n < len(buf)` | `io.ErrUnexpectedEOF` | Read that table as three answers to three different questions. Zero bytes means the stream ended cleanly *between* items — nothing was in progress, so the ending is legal and the caller usually treats it as "done". A partial read means an item was in progress and the source died inside it — that is corruption, and it must surface as a failure. `io.ReadAtLeast(r, buf, min)` generalises it: same three outcomes, measured against `min` rather than the whole buffer. ## Why this makes a record format parseable The practical payoff is in a loop over framed records. Suppose each record is a fixed 8-byte header followed by a payload whose length the header carries: ``` for { hdr := make([]byte, 8) _, err := io.ReadFull(r, hdr) if err == io.EOF { return nil // clean end, between records } if err != nil { return err // includes io.ErrUnexpectedEOF: truncated header } body := make([]byte, payloadLen(hdr)) if _, err := io.ReadFull(r, body); err != nil { return err // a short body is never a clean end } emit(hdr, body) } ``` Every termination is now classified. Compare that with a loop built on bare `Read` and a byte-slice accumulator: when the input stops, you get `io.EOF` and no information about whether you were mid-record. That ambiguity is how truncated inputs get accepted as complete. Notice the second `io.ReadFull`: once the header has been read, an ending is *never* legal until the body is complete, so any error — including `io.EOF` — is a failure there. In a body read you must not special-case `io.EOF`; letting it through is the classic truncation hole. ## Where else the pair shows up Several standard-library layers that decode fixed-size or length-delimited data promote a mid-item ending to `io.ErrUnexpectedEOF` before returning it to you, because they know the shape of what they were reading. When you write such a layer yourself, do the same: your caller has no way to know an ending was illegal unless you say so. Both values are ordinary sentinels, so both are testable with `errors.Is(err, io.ErrUnexpectedEOF)` as well as `==`. Because `io.ErrUnexpectedEOF` is a distinct value and not a wrapper around `io.EOF`, `errors.Is(err, io.EOF)` is **false** for it — a truncation will not accidentally match an end-of-stream test. ## The judgment to carry away If your code can articulate how many bytes it still owes the format, translate an ending at that point into `io.ErrUnexpectedEOF` (or a domain error that says which record died). If it genuinely has no expectation — a plain copy, a log tail — then an ending is just an ending and `io.EOF` is right. The distinction is not about the transport; it is about the contract the reading layer has with the format.

  • Reading a record's body with io.ReadFull returns io.EOF. Is that a clean end?
    No. Once the header committed you to a body of a known length, no ending is legal until those bytes arrive. A bare io.EOF there means zero body bytes were available, which is still truncation — treat it as a failure, or convert it to io.ErrUnexpectedEOF before returning it, rather than special-casing it as a clean finish.
  • Does errors.Is(err, io.EOF) match io.ErrUnexpectedEOF?
    No. They are two independent sentinels created separately in the io package; one does not wrap the other. That is deliberate — a truncation must not satisfy an end-of-stream test, or the very code that is supposed to reject a short stream would accept it.
  • How does io.ReadAtLeast differ from io.ReadFull?
    io.ReadAtLeast(r, buf, min) applies the same three-way rule against min instead of the whole buffer: nil once it has read at least min bytes, io.EOF if it read none, and io.ErrUnexpectedEOF if it read some but fewer than min. io.ReadFull is the case where min equals len(buf).

A sentence ending at a full stop is io.EOF; the page being torn off mid-word is io.ErrUnexpectedEOF.

saying these in an interview costs you the question

  • Believing io.ErrUnexpectedEOF wraps io.EOF
  • Treating any short read from io.ReadFull as a clean end
  • Special-casing io.EOF while reading a record body
  • Thinking the transport decides which of the two applies
  • Expecting a bare Read to distinguish truncation from an ending