23.1 io.Reader and io.Writer: The Universal I/O Interfaces
Let’s get one thing straight: most of what you think of as “file handling” in Go is just io.Reader and io.Writer in a trench coat. These two single-method interfaces are the foundation of nearly all data movement in the language, and understanding them is the master key to unlocking Go’s I/O model. Forget learning a dozen different APIs; if you can handle these two interfaces, you can handle data from files, networks, memory, and even the kitchen sink (if it had a Go driver).
The genius is in the brutal simplicity. An io.Reader is anything that has a Read(p []byte) (n int, err error) method. That’s it. Its job is to pop some data into the byte slice p you provide and tell you how many bytes it read (n) or if it ran into an error (err). Similarly, an io.Writer defines Write(p []byte) (n int, err error), taking the data from p and squirting it… somewhere. This level of abstraction is so powerful because it lets you compose functionality. You can take a reader from a network connection and “plug” it directly into a reader that decodes JSON without either of them knowing or caring about the other’s existence. It’s I/O legos.
The Beautiful, Annoying Contract of Read
You must always check the number of bytes read (n) and the error. This is non-negotiable. The Read method can return data and an error simultaneously. For instance, it might read 512 bytes from a file and then hit the end of the file (io.EOF) on the next call. If you get an error, you still have to process the n bytes that were successfully read before the error occurred. The only exception is io.EOF, which is literally the signal that there’s no more data coming, but even then, you might have gotten some bytes in that final read.
Here’s the classic rookie mistake, and I’ve made it a dozen times:
data := make([]byte, 1024)
_, err := myReader.Read(data) // Ignoring the 'n' return value? You monster.
if err != nil {
log.Fatal(err)
}
// Process data... but wait, what if it only read 10 bytes?
// You're processing 1024 bytes of which 1014 are leftover garbage from the last use of the slice.
The correct way is to always use n:
data := make([]byte, 1024)
n, err := myReader.Read(data)
if err != nil && err != io.EOF {
log.Fatal(err)
}
data = data[:n] // Reslice to get only the actual data read.
// Now process 'data'
Why io.EOF is a Special Little Snowflake
io.EOF is arguably the most famous error in Go, and it’s the only one you’re expected to check for explicitly. It’s not a failure; it’s a notification. It says, “I have no more data for you this time.” But crucially, a reader might return n > 0 and err == io.EOF in the same call. That’s the final chunk of data. You process the n bytes, then handle the EOF. Treating EOF like any other error and bailing out immediately is a surefire way to truncate data.
Writers are Less Drama
io.Writer is generally more straightforward. You give it data, it writes it. Its contract is that it will write some of the data (n > 0) unless there’s an error. It’s not guaranteed to write all the data in p in one go. This is why we have helper functions like io.Copy and io.WriteString that handle the looping for you. If you’re writing everything yourself, you need to loop until your entire buffer is written or an error occurs.
data := []byte("Hello, World!")
n, err := myWriter.Write(data)
if err != nil {
log.Fatal(err)
}
if n != len(data) {
log.Fatalf("only wrote %d out of %d bytes", n, len(data))
}
The Power of Composition: Chaining Readers and Writers
This is where the magic happens. The standard library is littered with functions that take a Reader or Writer and return another, more specialized one. Need to read compressed data? gzip.NewReader(myReader) wraps your original reader and handles decompression on the fly. Need to write UTF-8 encoded text to a network connection? bufio.NewWriter(conn) wraps your writer and buffers the output efficiently.
// Read a gzipped file and print its contents
file, err := os.Open("data.txt.gz")
if err != nil {
log.Fatal(err)
}
defer file.Close() // Please, for the love of all that is holy, don't forget this.
gzReader, err := gzip.NewReader(file) // file is an io.Reader
if err != nil {
log.Fatal(err)
}
defer gzReader.Close()
// Now gzReader is also an io.Reader, but it decompresses the data from 'file'
// We can plug it into anything that expects a reader.
written, err := io.Copy(os.Stdout, gzReader) // os.Stdout is an io.Writer
if err != nil {
log.Fatal(err)
}
fmt.Printf("\n\nUncompressed %d bytes.\n", written)
This pattern is everywhere. By standardizing on io.Reader and io.Writer, Go lets you build complex data processing pipelines from simple, reusable, and testable components. It’s I/O nirvana, and once you get used to it, every other language’s approach feels clunky and bureaucratic.