- Published on
The io.Reader and io.Writer Interfaces in Go
- Authors

- Name
- Duncan Leung
- @leungd
One of the patterns I appreciate most about Go is how much of the standard library is built on top of two tiny interfaces: io.Reader and io.Writer.
Once you understand these two interfaces, a surprising amount of the standard library "just composes" — os.File, bytes.Buffer, http.ResponseWriter, net.Conn, gzip.Writer, all of them speak the same language.
The Interfaces
Both interfaces are intentionally minimal. They each define a single method:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
- A
Readerreads some bytes into the bufferp, and returns the number of bytes read. - A
Writerwrites the bytes frompto its underlying destination, and returns the number of bytes written.
Many of the most common types in the standard library implement one or both of these:
os.Filebytes.Bufferstrings.Readerhttp.Response.Body(Reader)http.ResponseWriter(Writer)net.Conn(both)
Why Pass a Buffer Instead of Returning a Slice?
The Read signature can look strange the first time you see it. Why pass in p []byte instead of returning a new slice?
Read(p []byte) (n int, err error)
The reason is allocation. If Read returned a new []byte on every call, every read would allocate. By passing the same buffer back in, the caller can reuse memory across reads — which matters a lot when you are streaming bytes off a network connection or a large file.
A few subtle rules to keep in mind:
io.EOFis returned as part of normal usage when the stream is done. It is not an "error" in the failure sense.- The buffer is not guaranteed to be filled on each call. Always check
nto see how many bytes were actually read.
Example: Writing a Struct to a File
Let's start with an implementation that gets the job done, but does not yet use the interfaces idiomatically.
package main
import (
"encoding/json"
"os"
)
type Person struct {
Name string
Age uint
Occupation []string
}
func MakeBytes(p Person) []byte {
b, _ := json.Marshal(p)
return b
}
func main() {
gandalf := Person{
Name: "Gandalf",
Age: 56,
Occupation: []string{"sorcerer", "foo fighter"},
}
myFile, err := os.Create("output1.json")
if err != nil {
panic(err)
}
myBytes := MakeBytes(gandalf)
myFile.Write(myBytes)
}
This works — gandalf is serialized to JSON and written to a file. But the struct itself has no idea how to write itself, and the code is locked to *os.File as the destination.
What if I wanted to write the JSON to an HTTP response instead? Or to os.Stdout for debugging? Or to a bytes.Buffer in a test?
I would have to rewrite main. That is the smell we want to fix.
Wrong: A Write Method With the Wrong Signature
A first instinct might be to give Person a Write method that accepts an io.Writer:
// 🚨 This does NOT implement the io.Writer interface
func (p *Person) Write(w io.Writer) {
b, _ := json.Marshal(*p)
w.Write(b)
}
This is misleading. By naming the method Write, we are implying that *Person implements io.Writer — but the signature does not match the interface:
// io.Writer
Write(p []byte) (n int, err error)
// Our method
Write(w io.Writer)
Two problems:
- The method takes an
io.Writerinstead of[]byte, so it does not satisfy the interface. - The method does not return
(int, error), so callers have no way to handle write failures.
Naming a method Write that does not satisfy io.Writer will confuse anyone reading the code.
Correct: Accept an io.Writer, Return an Error
The fix is to give the method a more descriptive name (WriteJson) and have it accept an io.Writer as a parameter, returning an error:
// WriteJson writes the JSON representation of Person to w.
func (p *Person) WriteJson(w io.Writer) error {
b, err := json.Marshal(*p)
if err != nil {
return err
}
_, err = w.Write(b)
if err != nil {
return err
}
return nil
}
Now the full program looks like this:
package main
import (
"encoding/json"
"io"
"os"
)
type Person struct {
Name string
Age uint
Occupation []string
}
func (p *Person) WriteJson(w io.Writer) error {
b, err := json.Marshal(*p)
if err != nil {
return err
}
_, err = w.Write(b)
if err != nil {
return err
}
return nil
}
func main() {
gandalf := Person{
Name: "Gandalf",
Age: 56,
Occupation: []string{"sorcerer", "foo fighter"},
}
myFile, err := os.Create("output2.json")
if err != nil {
panic(err)
}
if err := gandalf.WriteJson(myFile); err != nil {
panic(err)
}
}
This is the same behavior as before, but the struct no longer knows or cares what it is writing to.
Why This Pattern Pays Off
Because WriteJson accepts an io.Writer, the same method now works against anything that implements the interface:
// Write to a file
gandalf.WriteJson(myFile)
// Write to an HTTP response body
func handler(w http.ResponseWriter, r *http.Request) {
gandalf.WriteJson(w)
}
// Write to stdout for debugging
gandalf.WriteJson(os.Stdout)
// Write to an in-memory buffer in a test
var buf bytes.Buffer
gandalf.WriteJson(&buf)
fmt.Println(buf.String())
That last one is especially useful. Testing the JSON output no longer requires touching the filesystem — you just write into a bytes.Buffer and assert on its contents.
This is the Go idiom of accepting interfaces and returning concrete types in action. The struct's behavior — "I know how to encode myself as JSON and write the bytes somewhere" — is fully decoupled from the destination.
The Bigger Idea
io.Writer is not a class to inherit from. It is a behavior — a single method that a type either implements or it doesn't.
Anything that implements
Write(p []byte) (n int, err error)is aWriter.
The same goes for io.Reader. Once your code speaks in io.Reader and io.Writer instead of concrete types, you can compose your code freely with files, network connections, in-memory buffers, compression streams, and HTTP request/response bodies — without changing a line of the code that does the actual work.
That is the payoff: a small interface that you find everywhere, and code that becomes trivially reusable because it accepts the interface instead of a concrete type.