The universal adapters of Go
You are building a tool that processes data. The data might come from a file on disk, a network socket, or a string sitting in memory. You need to read it, transform it, and write it somewhere else. You also have a custom struct that holds configuration, and when you print it for debugging, you want it to look like readable text, not a raw memory dump.
Go solves this with three tiny interfaces that appear everywhere in the standard library. They are io.Reader, io.Writer, and fmt.Stringer. You will see them in file handling, HTTP servers, database drivers, and compression libraries. They work because Go interfaces are implicit. You do not declare that a type implements an interface. You simply define the methods the interface requires, and the compiler connects the dots.
Interfaces are contracts. You don't sign them; you just fulfill them.
How the interfaces work
An interface in Go defines a set of methods. Any type that has those methods automatically satisfies the interface. There is no implements keyword. There is no registration step. The match happens at compile time based on the method signatures.
io.Reader defines a single method: Read(p []byte) (n int, err error). It asks for a byte slice (a buffer), fills it with data, and returns how many bytes it wrote plus an error.
io.Writer defines a single method: Write(p []byte) (n int, err error). It takes a byte slice, consumes the data, and returns how many bytes it accepted plus an error.
fmt.Stringer defines a single method: String() string. It returns a human-readable representation of the type.
Think of io.Reader like a tap. You hold a bucket (the buffer) under it, turn the handle, and water (bytes) flows in. You don't care if the water comes from a well, a pipe, or a tank. You just care that you can fill your bucket. io.Writer is the drain. You pour water in, and it goes away. fmt.Stringer is a label maker. You hand it an object, and it spits out a sticker with the object's name on it.
The caller provides the buffer for Read and Write. This is a deliberate design choice. It avoids memory allocations. The caller reuses the same buffer across multiple calls, and the implementer just fills it. This keeps the garbage collector happy and the performance high.
Minimal example
Here is a type that implements all three interfaces. It stores a string and allows you to read from it, write to it, and print it.
package main
import (
"fmt"
"io"
)
// Echo holds a string and a read position.
// It implements io.Reader, io.Writer, and fmt.Stringer.
type Echo struct {
data string
pos int
}
// Read implements io.Reader.
// It copies bytes from the internal data into the provided buffer p.
// It returns the number of bytes copied and an error.
func (e *Echo) Read(p []byte) (n int, err error) {
// Check if we have reached the end of the data
if e.pos >= len(e.data) {
return 0, io.EOF // Signal end of data to the caller
}
// Copy as much as fits into the buffer p
// copy returns the number of bytes actually copied
n = copy(p, e.data[e.pos:])
// Advance the position by the number of bytes read
e.pos += n
return n, nil
}
// Write implements io.Writer.
// It appends bytes to the internal data buffer.
// It returns the number of bytes written and an error.
func (e *Echo) Write(p []byte) (n int, err error) {
// Append the new bytes to the existing data
e.data += string(p)
// Return the length of the input slice
return len(p), nil
}
// String implements fmt.Stringer.
// It returns a human-readable representation of the Echo state.
// This method is called automatically by fmt.Println and fmt.Printf.
func (e *Echo) String() string {
return fmt.Sprintf("Echo{data: %q, pos: %d}", e.data, e.pos)
}
func main() {
// Create an instance with initial data
echo := &Echo{data: "Hello"}
// Use it as a Stringer
// fmt.Println checks if the argument implements fmt.Stringer
// If it does, it calls String() to get the output
fmt.Println("Debug:", echo)
// Use it as a Writer
// Write expects a byte slice
echo.Write([]byte(" World"))
// Use it as a Reader
// Create a buffer to hold the data
buf := make([]byte, 10)
// Read fills the buffer and returns the count
n, err := echo.Read(buf)
if err != nil && err != io.EOF {
fmt.Println("Error:", err)
return
}
// Print only the bytes that were actually read
fmt.Printf("Read %d bytes: %s\n", n, buf[:n])
}
The compiler checks the methods. If the signatures match, the interface is satisfied.
Notice the receiver type. Read and Write modify the state of Echo (the position and the data). They use a pointer receiver (e *Echo). String does not modify state, so it could use a value receiver, but using a pointer receiver is common for consistency and to avoid copying large structs. The community convention for receiver names is one or two letters matching the type, like e for Echo.
Walking through the runtime
When you call fmt.Println(echo), the fmt package checks if echo implements fmt.Stringer. It does. The package calls echo.String() and prints the result. If Echo did not have a String method, fmt would fall back to printing the struct fields in a default format.
When you call echo.Read(buf), the method copies bytes from e.data into buf. It updates e.pos. It returns the count and nil error. If the data is exhausted, it returns io.EOF. io.EOF is a special error value defined in the io package. It is not a failure. It is a signal that there is no more data. The caller must check for it.
The Read method does not guarantee it will fill the buffer. It might return fewer bytes than the buffer size. This is normal. The caller must loop until it gets io.EOF or an error. This design allows Read to work efficiently with system calls, which might return partial data.
Realistic example
In real code, you rarely implement io.Reader from scratch. You usually compose existing types. Here is a wrapper that counts bytes written. It implements io.Writer by delegating to an underlying writer.
package main
import (
"fmt"
"io"
"strings"
)
// CountingWriter wraps an io.Writer and counts bytes written.
// It demonstrates composition and implementing io.Writer.
type CountingWriter struct {
w io.Writer
n int64
}
// Write implements io.Writer.
// It delegates to the underlying writer and updates the count.
func (c *CountingWriter) Write(p []byte) (n int, err error) {
// Call the underlying writer
n, err = c.w.Write(p)
// Update the count with the number of bytes written
c.n += int64(n)
return n, err
}
// ProcessData reads from src, writes to dst, and prints stats.
// It accepts interfaces to remain flexible.
// It works with files, network connections, strings, or any custom type.
func ProcessData(src io.Reader, dst io.Writer) error {
// Wrap dst to count bytes
counter := &CountingWriter{w: dst}
// io.Copy uses the interfaces to move data
// It handles the looping and buffering internally
_, err := io.Copy(counter, src)
if err != nil {
return err
}
fmt.Printf("Processed %d bytes\n", counter.n)
return nil
}
func main() {
// Create a source reader from a string
src := strings.NewReader("Hello, Go interfaces!")
// Create a destination writer (os.Stdout)
dst := &CountingWriter{w: io.Discard}
// Process the data
err := ProcessData(src, dst)
if err != nil {
fmt.Println("Error:", err)
return
}
// Print the count
fmt.Println("Total bytes:", dst.n)
}
Composition makes code reusable. Wrap the behavior you need.
The ProcessData function accepts io.Reader and io.Writer. It does not care about the concrete types. It works with strings.Reader, os.File, net.Conn, or any custom type. This is the power of interfaces. You write the logic once, and it works with any data source. The convention is "Accept interfaces, return structs." Functions should take interfaces as arguments to stay flexible. They should return concrete structs to give the caller full access to the type.
Pitfalls and compiler errors
The biggest pitfall with io.Reader is assuming Read fills the buffer. It does not. You must loop. If you call Read once and assume you got all the data, you will miss bytes.
// BAD: Assumes Read fills the buffer
buf := make([]byte, 1024)
n, err := reader.Read(buf)
data := buf[:n] // Might be incomplete
// GOOD: Loops until EOF or error
var data []byte
buf := make([]byte, 1024)
for {
n, err := reader.Read(buf)
if n > 0 {
data = append(data, buf[:n]...)
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
The same applies to Write. It might not write all the bytes. You must loop or use io.WriteAll which handles the looping for you.
Another pitfall is receiver type mismatch. If you define String() on a value receiver but pass a pointer to fmt.Println, it works. Go automatically dereferences the pointer to call the method. If you define it on a pointer receiver but pass a value, the compiler rejects it with cannot use x (type MyType) as fmt.Stringer in argument: MyType does not implement fmt.Stringer (method requires pointer receiver). This error is common when you forget the pointer.
If you try to pass a type that does not implement the interface, the compiler yells. cannot use x (type MyType) as io.Reader in argument: MyType does not implement io.Reader (missing Read method). The error message tells you exactly which method is missing.
Runtime panics happen if you dereference a nil pointer in String(). If your struct is nil, calling String() will panic unless you check for nil. fmt.Println does not check for nil before calling String(). It trusts the method to handle it.
Loops are mandatory. Partial reads are the norm, not the exception.
Decision matrix
Use io.Reader when you need to consume data from a source without caring where it comes from. Use io.Writer when you need to send data to a destination without caring where it goes. Use fmt.Stringer when you want your type to print nicely in logs or debug output. Use io.ReadWriter when your type supports both reading and writing, like a network connection. Use a custom struct method when you only need specific behavior and don't want the overhead of a general interface.
Pick the interface that matches your data flow.