The io.Reader pattern I keep getting wrong
I have been writing Go professionally since 2016 and I still get io.Reader wrong. Specifically, I keep writing code that assumes Read(buf) fills the buffer, which it does not. This post is partly for me, partly for anyone who has also been caught out.
The signature is:
type Reader interface {
Read(p []byte) (n int, err error)
}
The subtle contract: Read is allowed to fill p with any number of bytes from 0 to len(p), and return. It is also allowed to return io.EOF alongside a non-zero n. The caller must check n first, handle that data, and only then worry about the error.
The common mistake, which I made in a serialization library in 2019 and didn’t notice until a flaky test popped in 2021:
// WRONG
buf := make([]byte, 8)
_, err := r.Read(buf)
if err != nil {
return err
}
length := binary.BigEndian.Uint64(buf)
Over a TCP connection, Read might return after 3 bytes, because that’s all the kernel had to give you. The next 5 bytes would arrive later. My code assumed those 5 bytes were already in buf, zero-valued, and my length decoding silently used garbage.
The fix is to use io.ReadFull:
buf := make([]byte, 8)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
length := binary.BigEndian.Uint64(buf)
ReadFull keeps calling Read until the buffer is full or an error occurs. If you get io.EOF before the buffer fills, it returns io.ErrUnexpectedEOF, which is useful to distinguish “clean end of stream” from “truncation in the middle of a frame.”
There’s also io.ReadAtLeast(r, buf, n), which keeps reading until you have at least n bytes. I use this when decoding a framed protocol where the frame header has a length and I want to read just the header first, then exactly the payload.
A real example of a correct frame-decoding loop:
func readFrame(r io.Reader) (Frame, error) {
var header [8]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return Frame{}, err
}
length := binary.BigEndian.Uint32(header[0:4])
kind := binary.BigEndian.Uint32(header[4:8])
if length > maxFrameSize {
return Frame{}, fmt.Errorf("frame too large: %d", length)
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return Frame{}, fmt.Errorf("short read: %w", err)
}
return Frame{Kind: kind, Payload: payload}, nil
}
A few patterns I’ve seen go wrong:
Mistake: ignoring n when err is non-nil.
n, err := r.Read(buf)
if err != nil {
return nil, err
}
return buf[:n], nil
This is wrong because Read may return bytes AND io.EOF at the same time. Read, process bytes, THEN handle error. Always:
n, err := r.Read(buf)
if n > 0 {
// do something with buf[:n]
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
Mistake: using Read when you want io.Copy.
If you’re copying between readers and writers, use io.Copy. It handles all the Read/Write dance correctly, plus it’ll use WriteTo or ReadFrom if either side implements them, which lets things like *os.File call sendfile(2) for a kernel-side copy.
func relay(w io.Writer, r io.Reader) (int64, error) {
return io.Copy(w, r)
}
I’ve seen people write this loop manually and either forget to handle short reads or allocate a fresh buffer every iteration. Don’t.
Mistake: io.ReadAll for untrusted input.
io.ReadAll reads until EOF into memory. If the reader is an HTTP body from the network, and the client wants to be annoying, they can stream you a 20GB gzip bomb. Always use io.LimitReader first:
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
Or better, use http.MaxBytesReader which also nicely formats errors when the limit is exceeded.
Mistake: not closing the reader.
io.Reader doesn’t include Close, but many real readers (HTTP bodies, file handles, decompressors) do, via io.ReadCloser. Always close them. For HTTP, also always read to EOF before closing, or the connection won’t be reused.
defer resp.Body.Close()
// ... process body ...
// if you're bailing early:
io.Copy(io.Discard, resp.Body) // drain before returning
This one I’ve ACTUALLY fixed in production, twice. The symptom is that connection pool size grows and you run out of available connections. You find it with netstat -tan | grep ESTABLISHED | wc -l climbing over time. The fix is to drain.
Mistake: bufio.Scanner with long lines.
bufio.Scanner has a default max token size of 64KB. If your lines can exceed that, Scan() returns false silently and you have to check scanner.Err() for bufio.ErrTooLong. The fix is scanner.Buffer(buf, maxSize). I’ve had a log-tailing tool silently drop lines for a week because of this.
I think what makes io.Reader slippery is that the interface is small enough to look trivial, but it has enough subtle contract that every junior-ish Go engineer writes a subtle bug with it at least once. The helpers — ReadFull, ReadAtLeast, LimitReader, Copy, TeeReader, MultiReader — are worth knowing by heart.
When in doubt, reach for the standard library helper instead of calling Read directly. You’ll dodge a whole class of bugs.