Introduction
If you learn only two interfaces in Go, make them io.Reader and io.Writer. These tiny contracts power everything from file operations to HTTP servers to compression pipelines. Once you understand how they work, you can plug any data source into any data sink without either side knowing about the other.
Key Concepts
- io.Reader: An interface with a single
Read(p []byte) (n int, err error)method. Any type that can produce bytes implements it. - io.Writer: An interface with a single
Write(p []byte) (n int, err error)method. Any type that can consume bytes implements it. - io.EOF: A sentinel error value that signals the end of a data stream. It is not a failure—it is the normal way a Reader says "I have no more data."
- io.Copy: A utility that streams bytes from any Reader to any Writer using a small internal buffer.
Real World Context
Imagine you are building a service that downloads a gzipped CSV file from S3, decompresses it, and writes the results into a database. With Reader and Writer, you chain an HTTP response body into a gzip decompressor into a CSV parser—all streaming, all using constant memory. Without these interfaces, you would need to load the entire file into memory first, which could crash your service on large datasets.
Deep Dive
The Interface Definitions
The io package defines the two core interfaces that nearly every I/O operation in Go relies on.
gotype Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) }
Notice how minimal they are: one method each. This simplicity is intentional—it makes them easy to implement and easy to compose.
How Read Works
The Read method fills a caller-provided byte slice and returns two values: n (the number of bytes actually placed into the slice) and err (an error, or io.EOF when data is exhausted). You must always process the n bytes before checking err, because a Read can return both data and io.EOF in the same call.
gobuf := make([]byte, 1024) n, err := reader.Read(buf) data := buf[:n] // Only use the bytes that were actually read if err == io.EOF { // End of input reached }
The key detail here is buf[:n]. The buffer may be 1024 bytes, but the Reader might only fill 37 of them. Always slice down to n.
Reading in a Loop
In practice you rarely call Read once. You loop until io.EOF.
govar all []byte buf := make([]byte, 512) for { n, err := reader.Read(buf) all = append(all, buf[:n]...) if err == io.EOF { break } if err != nil { log.Fatal(err) } }
This loop processes bytes returned on every call, even the final one that also returns io.EOF. That ordering matters.
Streaming with io.Copy
You can copy from any Reader to any Writer using io.Copy. It uses a small 32KB internal buffer, so even multi-gigabyte transfers consume only kilobytes of memory.
gof, _ := os.Open("file.txt") defer f.Close() io.Copy(os.Stdout, f)
This streams the file contents directly to standard output. No intermediate byte slice holds the entire file.
io.ReadAll
io.ReadAll reads every byte from a Reader into memory. In Go 1.26, io.ReadAll is 2x faster with 50% less memory allocation than in previous versions, but it still loads the entire content into RAM—so use it only when you know the data fits comfortably in memory.
godata, err := io.ReadAll(reader) if err != nil { log.Fatal(err) }
This is convenient for small payloads like config files or API responses, but dangerous for unbounded streams.
Common Pitfalls
- Ignoring
nbefore checkingerr— A Read call can return both valid bytes andio.EOFsimultaneously. If you checkerrfirst and break, you lose those final bytes. Always processbuf[:n]before inspecting the error. - Using the full buffer instead of
buf[:n]— The slice you pass toReadis not guaranteed to be filled. Usingbufinstead ofbuf[:n]includes leftover garbage from previous reads. - Calling
io.ReadAllon unbounded streams — Reading an entire HTTP body or a multi-GB file into memory can exhaust RAM and crash your program. Useio.Copyor buffered reading for large or unknown-size data.
Best Practices
- Prefer
io.Copyfor transferring data between a Reader and Writer — It handles the read-write loop, buffer management, and EOF detection for you with constant memory usage. - Always
defer Close()on Readers and Writers that implementio.Closer— Files, HTTP bodies, and gzip readers all hold OS resources. Forgetting to close them causes resource leaks. - Use
io.LimitReaderto cap how much data you read — When reading from untrusted sources (e.g., user uploads), wrap the Reader withio.LimitReader(r, maxBytes)to prevent memory exhaustion.
Summary
io.Readerandio.Writerare single-method interfaces that form the backbone of Go's I/O system.Readfills a byte slice and returnsnbytes read plus an error; always processbuf[:n]before checkingerr.io.Copystreams data between any Reader and Writer using constant memory (32KB buffer).io.ReadAllreads everything into memory (2x faster in Go 1.26) but should only be used for small, bounded data.- These interfaces enable powerful composition: you can chain files, network connections, compressors, and buffers without any of them knowing about each other.
Code Examples
func main() {
r := strings.NewReader("Hello, Reader!")
b := make([]byte, 8)
for {
n, err := r.Read(b)
fmt.Printf("n = %v err = %v b = %v\n", n, err, b)
if err == io.EOF {
break
}
}
}