skip to content

io.EOF and Stream Endings

io.EOF is not a failure, it is how a stream says it ended, and code that wraps or logs it manufactures phantom errors. Interviewers use it to check that you read package documentation.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

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
open as a page

Why can an io.Reader's Read return n > 0 bytes and io.EOF in the same call?

level: middleimportance: must knowfreq 58%

basics

~20 s

The io.Reader contract lets a reader that hits the end of its data while filling your buffer report both at once. Process the n bytes returned before you inspect the error, or the last chunk of the stream is silently dropped.

open as a page

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

level: middleimportance: should knowfreq 44%

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.

open as a page

A Go ingest job read a truncated line-delimited feed as a complete batch with no error. How do you locate the faulty end-of-stream check?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Reproduce it with a deliberately truncated fixture first, then audit where the loop decided it was finished: an unchecked bufio.Scanner.Err, or a break on any error, both turn a failure into a clean end. Then compare bytes actually read against the length the source promised.

open as a page

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

level: seniorimportance: nice to knowfreq 30%

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.

open as a page