AlgoMaster Logo

io.Reader & io.Writer

High Priority25 min readUpdated June 6, 2026

io.Reader and io.Writer are the two interfaces that hold most of Go's I/O ecosystem together. They're tiny (one method each), but almost every type that produces or consumes bytes in the standard library implements one or both: files, TCP connections, HTTP request bodies, gzip streams, JSON encoders, and in-memory buffers all look the same to code that only cares about reading or writing bytes. This lesson focuses on the two interfaces themselves, their exact contracts, and the composition functions in the io package that let you snap readers and writers together like Lego bricks.

The Two Interfaces

The whole stream-processing ecosystem in Go sits on these two definitions in the io package:

That's the entire surface area. Any type with a Read method matching that signature is an io.Reader. Any type with a matching Write is an io.Writer. Go interfaces are satisfied implicitly, so the implementer never has to declare "I implement io.Reader," it just has to have the method.

The power of these two methods is that they speak the universal language of "give me some bytes" and "take these bytes." A function that takes an io.Reader doesn't care whether the bytes come from a file on disk, a network socket, an HTTP response, or a hard-coded string in memory. The same JSON decoder reads from all of them. The same compression library compresses any of them. This is one of the cleanest examples of interface-based composition in any standard library.

Here's the smallest possible program that uses both interfaces, copying bytes from a string to standard output:

Two different types (*strings.Reader and *os.File), one common shape (io.Reader and io.Writer), and a single helper (io.Copy) that doesn't know or care what either side actually is.

The io.Reader Contract

The Read method looks deceptively simple. The signature is Read(p []byte) (n int, err error), and the description is "fill the buffer p with up to len(p) bytes." But the exact rules around n and err matter, and missing them leads to subtle bugs.

The contract, paraphrased from the io package docs, is:

  1. Read reads up to len(p) bytes into p. It returns the number of bytes read (n) and any error.
  2. When Read reaches the end of the underlying stream, it may return n > 0 along with io.EOF in the same call, or it may return n = 0 and io.EOF on the next call. Both are legal.
  3. Callers must process the n bytes returned before checking err. A non-zero n is meaningful even when err is non-nil.
  4. n == 0 and err == nil is discouraged but legal. Well-behaved callers treat it as a no-op and call Read again.

That third point is the one most readers miss. The instinct is to write code like this:

This is wrong for the same reason scanning input in any language is tricky at the boundary: the last successful read can return data and EOF together. If you return err first, you silently drop the final chunk of data. The correct shape is:

Process the bytes, then check the error. io.EOF means "stream finished cleanly," not "something went wrong." It's a sentinel value, designed to be compared with ==, and it should never be wrapped or treated as a failure.

The following reads a product catalog from a strings.Reader and counts the bytes by calling Read directly:

The third read returns 7 bytes plus io.EOF. The loop processes the 7 bytes first, then sees io.EOF and breaks. If the loop had checked err first, it would have lost the final 12.50\n.

The exact behavior at end-of-stream depends on the implementation. strings.Reader returns data and io.EOF together when the read drains the source. *os.File typically returns the data on one read and io.EOF on the next. *bytes.Buffer also tends to return io.EOF on a separate read. Don't write code that depends on which side of the boundary io.EOF shows up on. Always handle n > 0 and err == io.EOF independently.

The size of the buffer you pass matters for performance, but not for correctness. A Read call may return fewer bytes than the buffer holds even when more data is available, and that's fine. The reader is free to return as much or as little as it wants per call, as long as n is at least 1 when there's data and no error.

The io.Writer Contract

The Write contract is simpler than Read's because writes don't have the "end of stream" problem. The rules are:

  1. Write attempts to write all len(p) bytes from p to the underlying destination.
  2. It returns the number of bytes actually written and any error encountered.
  3. If n < len(p), Write must return a non-nil error. A short write is always an error.
  4. Write must not modify the slice p, and must not retain it after the call returns.

Rule 3 is what makes writers easier than readers. A reader can legally return a partial result with no error (just call it again). A writer cannot. If a writer can't write everything you gave it, it owes you an error explaining why. This means you don't need a loop around most Write calls. One call either writes everything or fails.

The matching rules for the slice p are easy to forget. The writer must not change the bytes in p, even temporarily, and must not keep a reference to p after Write returns. If a writer wants to remember the data, it has to copy it. This rule lets callers reuse buffers safely:

Here's a writer in action, writing a small order summary to standard output:

io.WriteString is a small helper. It avoids allocating a temporary []byte from the string when the writer implements an optimized WriteString(string) (int, error) method. *os.File, *bytes.Buffer, and *bufio.Writer all do, so most of the time io.WriteString is strictly cheaper than w.Write([]byte(s)).

Notice that the function takes an io.Writer, not a *os.File or a *bytes.Buffer. The caller can pass anything that implements Write. To prove it, we can swap os.Stdout for a bytes.Buffer and capture the output in memory:

Same function, different sink. This is the practical payoff of writing to io.Writer instead of a concrete type: tests can capture output into a bytes.Buffer and assert on the result without touching disk.

Who Implements These Interfaces

A short tour of the types you'll see most often. Each of these is either an io.Reader, an io.Writer, or both:

TypeReader?Writer?Used for
*os.FileyesyesFiles on disk
*bytes.BufferyesyesIn-memory growable byte buffer
*bytes.ReaderyesnoRead-only view over a []byte
*strings.ReaderyesnoRead-only view over a string
*bufio.ReaderyesnoBuffered wrapper around any reader
*bufio.WriternoyesBuffered wrapper around any writer
net.ConnyesyesTCP/UDP connection
http.Request.BodyyesnoHTTP request body (also a Closer)
*gzip.ReaderyesnoDecompresses a gzip stream
*gzip.WriternoyesCompresses to a gzip stream
io.DiscardnoyesThrows every write away

Almost everything that produces bytes is an io.Reader and almost everything that consumes them is an io.Writer. When you're stuck, "how do I pipe X into Y" is usually answered by checking what interfaces each side satisfies.

Here's a tiny program that uses three different readers (an in-memory string, a bytes.Buffer, and a slice via bytes.Reader) and writes their concatenation to standard output:

The dump function never asks "what kind of reader is this?" It just calls io.Copy, which calls Read through the interface. Three different concrete types, one function, no special-casing.

Composing Streams with io Helpers

The io package has a small set of helpers that combine readers and writers in standard ways.

io.Copy and friends

io.Copy(dst io.Writer, src io.Reader) (int64, error) reads from src until io.EOF and writes everything to dst. It returns the total number of bytes copied. This is the function you'd write yourself if you knew the contracts well; the standard library just provides it pre-tuned.

io.CopyN(dst, src, n) copies exactly n bytes or returns an error. It's useful when a protocol says "the next 4 bytes are a length, then read that many." io.CopyBuffer(dst, src, buf) is the same as io.Copy but lets you supply the temporary buffer instead of letting io.Copy allocate one. This is sometimes worth doing in a hot loop where you copy many small streams in sequence.

io.ReadAll

io.ReadAll(r io.Reader) ([]byte, error) reads everything from a reader into a single []byte. It's the convenient option when you know the input is small enough to fit in memory. The function used to live in ioutil.ReadAll; since Go 1.16 it's directly in io. Use io.ReadAll.

io.ReadAll is convenient, but it has one obvious risk: it buffers everything in memory. Reading a 10 GB log file with io.ReadAll is a bad idea. For large or untrusted input, stream the data with io.Copy or wrap the source with io.LimitReader (covered below) before reading.

io.ReadFull

io.ReadFull(r io.Reader, buf []byte) (int, error) reads exactly len(buf) bytes from r. If the stream ends before the buffer is full, it returns io.ErrUnexpectedEOF. This is the right helper when a protocol specifies a fixed-size record:

If the second io.ReadFull had asked for 9 bytes from an 8-byte remainder, it would have returned io.ErrUnexpectedEOF, not io.EOF. The distinction is deliberate: io.EOF means "stream finished at a record boundary," io.ErrUnexpectedEOF means "stream finished in the middle of a record."

Reader and Writer Composition

The most useful part of the io package is the set of functions that take readers or writers and produce new readers or writers. These are how you build little I/O pipelines without writing your own goroutines or buffers.

The picture below shows the kinds of compositions covered in this section. A single source reader can be wrapped to limit its length, split into two consumers, or chained together with other readers. A single destination can be fanned out so one write goes to multiple sinks at once.

Each box on the orange row is a composer: it takes existing readers or writers and returns a new one with a different behavior. The composers are small (often a few dozen lines of source), and they're stateless wrappers, not heavy abstractions. Let's walk through each one.

io.MultiWriter

io.MultiWriter(writers ...io.Writer) io.Writer returns a writer that writes everything to all the underlying writers. Every Write call fans out: write once, land in many places. The classic use case is "save to file and also print to the screen," but the same pattern works for "write to file and also send to an audit log."

(The temp file path will differ on each run.) Every Fprintln call goes to three sinks at once: the terminal, the temp file, and the in-memory audit buffer. If any of the writers returns an error, MultiWriter stops and returns that error. The remaining writers do not receive the rest of the data, so don't use MultiWriter if you need each sink to operate independently.

io.TeeReader

io.TeeReader(r io.Reader, w io.Writer) io.Reader returns a reader that, every time it's read from, also writes the read bytes to w. It "tees" the stream the way the Unix tee command does, hence the name. The reader behaves identically to the original; the side effect is that everything flowing through it also lands in w.

This is invaluable when you want to consume a stream and simultaneously log it, hash it, or count it without storing the whole thing in memory.

(The hash value depends on the exact input bytes.) The downstream consumer (io.Copy) sees a normal stream. The hasher observes every byte that flows through. No extra goroutines, no double-buffering, no manual coordination.

A common e-commerce use: log every byte of an order-export stream to a file while it streams to an HTTP client. The client gets the bytes; the audit file gets a copy; neither side knows about the other.

io.LimitReader

io.LimitReader(r io.Reader, n int64) io.Reader returns a reader that reads at most n bytes from r and then returns io.EOF. This is the standard way to enforce "no more than 1 MB of input" without trusting the caller to stop on time.

The original source has thousands of bytes, but the limited reader cuts it off cleanly at 50. io.ReadAll saw a normal io.EOF and stopped without complaint. For untrusted input (request bodies, file uploads, user-submitted reviews), wrap the source in io.LimitReader before reading. Otherwise a single huge request can exhaust your process's memory.

io.MultiReader

io.MultiReader(readers ...io.Reader) io.Reader returns a reader that reads from each underlying reader in sequence. When the first one returns io.EOF, it moves to the second, and so on. To the consumer, it looks like one continuous stream.

Use this when you want to prepend a header to a stream without materializing the whole stream in memory. The consumer (here, io.Copy) sees one logical stream of 56 bytes; the source is three separate readers concatenated lazily.

io.SectionReader

io.NewSectionReader(r io.ReaderAt, offset, length int64) *SectionReader returns a reader that exposes only a slice of a larger source. The underlying source has to support ReadAt (which *os.File and *bytes.Reader both do), since SectionReader needs random access.

SectionReader is the right primitive when you want to expose a subrange of a file to a downstream consumer without copying. Image and archive formats use this pattern heavily: the index says "the third file's contents are at offset 4096, length 8192," and you hand a SectionReader to the decompressor.

io.Pipe

io.Pipe() returns a connected reader/writer pair. Whatever is written to the writer becomes available on the reader. The two ends typically live in different goroutines: one produces, one consumes, and the pipe handles the handoff.

io.Pipe is the bridge between an API that wants to write somewhere and an API that wants to read from somewhere, when neither will yield control to the other. A common case: http.Request expects an io.Reader for the body, but your encoder wants to Write JSON. Hand the encoder the pipe writer in one goroutine, hand http.Request the pipe reader, and they stream to each other without a temporary buffer.

Helper Interfaces

io.Reader and io.Writer are the foundation, but the io package defines several composite and capability interfaces that show up in real code. You don't have to memorize them, just recognize the names when you see them in function signatures.

InterfaceMethodsUsed for
io.CloserClose() errorReleases resources (files, connections, request bodies)
io.ReadCloserRead, CloseStreams that need cleanup, like http.Response.Body
io.WriteCloserWrite, CloseSinks that need cleanup, like a gzip writer
io.ReadWriterRead, WriteBidirectional streams, like net.Conn and bytes.Buffer
io.ReaderAtReadAt(p, off)Random-access reads at a given offset
io.WriterAtWriteAt(p, off)Random-access writes at a given offset
io.SeekerSeek(offset, whence)Move the position within a stream
io.ReadSeekerRead, SeekStreams that support both sequential and random access

io.Closer is the one you'll see daily. Any resource that needs explicit cleanup (a file, a database row iterator, an HTTP body) implements Close() error. The usual pattern is defer x.Close() immediately after opening, so even an early return cleans up.

The *At interfaces are for random access. io.ReaderAt lets you read from any offset without changing the reader's position, which is what makes io.SectionReader work. Files and bytes.Reader support it; pipes and network connections don't.

io.Seeker is the third random-access interface, and it's the one with the trickiest signature: Seek(offset int64, whence int) (int64, error). The whence argument is one of io.SeekStart, io.SeekCurrent, or io.SeekEnd, and it determines whether the offset is absolute, relative to the current position, or relative to the end. Most file APIs accept an io.ReadSeeker (combining io.Reader and io.Seeker) when they need both sequential reads and random jumps.

strings.Reader implements Seeker, so we can jump past the header without re-reading. Pipes and network connections don't implement Seeker, because you can't rewind a TCP stream.

io.Discard and io.NopCloser

Two small helpers worth knowing:

io.Discard is an io.Writer that throws every byte away. It's used when you need a writer but don't care about the output, often in benchmarks or when a function requires a destination but the side effect is what you want. Writing to io.Discard is essentially free, the implementation does nothing.

io.NopCloser(r io.Reader) io.ReadCloser wraps an io.Reader and adds a no-op Close method. It's useful when an API requires an io.ReadCloser (like http.Request.Body) but your source is a plain reader that doesn't need closing.

Both helpers are tiny but they remove friction at interface boundaries.

Implementing Your Own Writer

The interfaces are small enough that you'll write your own implementations all the time. Most custom writers are wrappers: they take an existing writer, do something extra (count bytes, log, compress, encrypt), and forward the rest. Here's a CountingWriter that wraps any io.Writer and tracks how many bytes have been written through it.

The wrapper sits in the middle of the call chain, passing data through to the inner writer while observing it. The inner writer doesn't know it's being wrapped; the caller doesn't know whether the destination is wrapped or bare.

A few details matter for the contract:

  1. The Write method accepts the contract that the writer must return a non-nil error if n < len(p). CountingWriter honors this implicitly by forwarding whatever the inner writer returns. It doesn't second-guess.
  2. The counter increments by the actual bytes accepted (n), not by len(p). If the inner writer accepts fewer bytes and returns an error, the counter reflects reality.
  3. The wrapper composes cleanly with io.MultiWriter. In the example, every write goes through the counter, then fans out to both a buffer and stdout. The counter sees the bytes once; both downstream sinks receive them.

This pattern (wrap a writer to add a behavior) shows up everywhere in the Go ecosystem. gzip.NewWriter wraps a writer to compress on the way through. bufio.NewWriter wraps to buffer. hash.Hash types are writers whose side effect is updating an internal digest. Once you see the wrapper pattern, you'll notice it constantly.

Quiz

io.Reader & io.Writer Quiz

10 quizzes