Go’s io.Reader interface is tiny:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Its small surface hides an important contract: a read is allowed to return fewer bytes than the buffer can hold, and it can return useful bytes together with an error. Correct stream processing must handle both cases.

A short read is not an error

Code should never assume that Read fills the supplied buffer.

buf := make([]byte, 4096)
for {
    n, err := r.Read(buf)
    if n > 0 {
        process(buf[:n])
    }
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }
}

A network connection, compressed stream, pipe, or custom reader may return any positive number of bytes up to len(buf). The only bytes that belong to the current read are buf[:n].

Processing the entire buffer can accidentally include stale bytes left from an earlier iteration.

Process bytes before handling EOF

A reader may return both data and io.EOF in the same call. That means this order is unsafe:

n, err := r.Read(buf)
if err == io.EOF {
    break
}
process(buf[:n])

If the final call contains bytes, they are discarded.

The safer order is:

  1. process n > 0 bytes;
  2. inspect the error;
  3. stop on io.EOF;
  4. return other errors.

Not every standard-library reader returns data and EOF together, but callers should honor the interface contract instead of depending on one implementation.

Use io.ReadAll only for bounded input

io.ReadAll is convenient when the entire stream is intentionally loaded into memory:

body, err := io.ReadAll(r)
if err != nil {
    return err
}

The trade-off is memory growth. If r can contain user-controlled or otherwise unbounded data, reading until EOF can consume far more memory than intended.

Bound the reader first when the protocol has a maximum acceptable size:

const max = 1 << 20 // 1 MiB
limited := io.LimitReader(r, max+1)
data, err := io.ReadAll(limited)
if err != nil {
    return err
}
if len(data) > max {
    return errors.New("input too large")
}

Reading one extra byte lets the program distinguish an input exactly at the limit from one that exceeds it.

Reach for io.Copy for ordinary streaming

If the task is simply to move bytes from a reader to a writer, avoid writing a manual loop:

_, err := io.Copy(dst, src)

io.Copy already implements the standard read/write loop and may use optimized paths exposed by the source or destination.

Use a custom loop when you need to inspect, transform, meter, frame, or otherwise act on chunks.

Buffering changes call patterns, not correctness

bufio.Reader can reduce small underlying reads and provides helpers such as ReadString, ReadBytes, and ReadSlice.

br := bufio.NewReader(r)
line, err := br.ReadString('\n')

Buffering is useful for protocols organized around delimiters, but it does not remove the need to understand EOF and errors. The last line of a file, for example, may be returned without a trailing newline together with io.EOF.

Process non-empty data before treating EOF as completion.

Do not assume Read returns progress forever

Well-behaved readers should not repeatedly return (0, nil), because that gives callers no progress to act on. Generic code should avoid tight loops that spin forever if a broken custom reader does so.

Standard helpers in io handle many of these edge cases, which is another reason to prefer them when they match the task.

Distinguish framing from transport chunks

A Read call does not correspond to an application message. TCP is the classic example: one sender write may arrive through several reads, or several writes may be available in one read.

If a protocol says each message starts with a length prefix, parse that framing explicitly. If it is line-oriented, parse delimiters. Never treat the boundaries returned by Read as message boundaries unless the specific reader contract guarantees that behavior.

Common mistakes

Ignoring n when err is non-nil

Useful data can accompany an error. Process n first unless the higher-level API documents different semantics.

Reusing the whole buffer

Only buf[:n] contains bytes from the current call.

Reading untrusted streams without a limit

Convenient whole-stream helpers can become memory-exhaustion paths when input size is uncontrolled.

Reimplementing io.Copy

Manual loops add room for partial-read and partial-write bugs when all you need is byte transfer.

Treating EOF as failure

io.EOF normally means the stream ended successfully. It is a control signal, not an application error to wrap and report as a failure in ordinary read-until-end logic.

Prefer interfaces at boundaries

Functions that accept io.Reader can consume files, request bodies, compressed data, byte slices, pipes, and test fixtures without knowing where bytes originate.

That flexibility is most valuable when callers also respect the interface’s semantics. Write code around partial progress, explicit framing, and bounded resource use, and Go stream processing stays predictable across many different data sources.