The destination doesn't matter
You are building a data processor. It reads a configuration file, transforms the records, and needs to output the result. First you want to print it to the terminal for debugging. Then you want to save it to a JSON file. Later you will stream it over an HTTP response. Writing three separate functions feels repetitive. The transformation logic is identical. Only the final destination changes.
Go solves this with a single interface: io.Writer. It strips away the specifics of files, networks, and memory buffers. It leaves only one question: can this type accept a slice of bytes and tell you how many it actually wrote?
A contract for bytes
An interface in Go is a list of methods. If a type has all the methods on that list, it satisfies the interface. You do not declare it. The compiler figures it out automatically. io.Writer is the simplest and most famous interface in the standard library. It requires exactly one method:
Write(p []byte) (n int, err error)
Think of it like a universal power socket. You do not care whether the device plugged in is a laptop, a phone charger, or a desk lamp. You only care that it accepts electricity and tells you how much it drew. io.Writer is the socket. Any type that accepts a byte slice and returns a byte count plus an error fits into it.
The method signature carries important information. The p []byte parameter is the data you want to send. The n int return value is the number of bytes actually written. The err error return value reports failures. Notice that n can be smaller than len(p). This is not a bug. It is a feature. Network sockets, pipes, and some file systems cannot guarantee they will consume an entire buffer in one call. The interface forces you to handle partial writes correctly.
Interfaces are accepted, structs are returned. That is the standard Go style. You pass io.Writer into functions so callers can decide where the data goes. You return concrete types from functions so callers know exactly what they got.
The minimal implementation
Here is the interface definition from the standard library, followed by a custom type that satisfies it.
package main
import (
"fmt"
"io"
)
// LogWriter implements io.Writer by printing bytes to stdout.
type LogWriter struct{}
// Write satisfies the io.Writer interface.
func (l LogWriter) Write(p []byte) (n int, err error) {
// Convert bytes to string for printing.
// In production code, use string(p) or bytes.NewBuffer.
fmt.Print(string(p))
// Return the full length because fmt.Print consumed everything.
return len(p), nil
}
func main() {
// Pass the custom writer to a function expecting io.Writer.
var w io.Writer = LogWriter{}
w.Write([]byte("ready\n"))
}
The compiler checks the method signature. It matches exactly. The LogWriter type now satisfies io.Writer without any explicit registration. You can pass it anywhere the standard library expects a writer.
Implicit satisfaction keeps the interface lightweight. You do not need to modify existing types to make them compatible. You just add the method.
How the compiler and runtime handle it
When you compile a program that uses io.Writer, the Go compiler performs a structural type check. It looks at the method set of the type you are passing. If it finds a Write([]byte) (int, error) method, the assignment is valid. If the method is missing, or if the parameter types differ by even one letter, the compiler rejects the program with cannot use myType (variable of type MyType) as io.Writer value in argument: MyType does not implement io.Writer (missing Write method).
At runtime, the interface value is a two-word structure. The first word points to a type descriptor. The second word points to the actual data. When you call w.Write(data), the runtime looks up the method in the type descriptor and dispatches the call. This indirection is cheap. It adds a single pointer dereference and a virtual call. The overhead is negligible compared to the actual I/O operation.
The real work happens inside your Write implementation. If you write to a file, the operating system buffers the data. If you write to a network socket, the kernel copies bytes into a send buffer. If you write to memory, you are just moving pointers and copying slices. The interface abstracts all of that away. You only interact with the contract.
Convention matters here. The receiver name for methods is usually one or two letters matching the type. You will see (w io.Writer) Write(...) or (l LogWriter) Write(...). You will rarely see (this LogWriter) or (self LogWriter). Keep it short. The community reads Go code at speed.
Trust the implicit contract. Add the method, and the type fits.
Wrapping writers in real code
Real programs rarely write directly to raw destinations. They wrap writers to add buffering, compression, or logging. The io.Writer interface makes wrapping trivial because the wrapper itself implements io.Writer.
Here is a counting wrapper that tracks how many bytes pass through it. It delegates the actual writing to an underlying writer.
package main
import (
"io"
"os"
)
// CountingWriter wraps an io.Writer and tracks total bytes written.
type CountingWriter struct {
w io.Writer
n int64
}
// Write delegates to the underlying writer and updates the counter.
func (c *CountingWriter) Write(p []byte) (int, error) {
// Delegate the actual I/O to the wrapped writer.
n, err := c.w.Write(p)
// Accumulate the successful byte count.
c.n += int64(n)
return n, err
}
func main() {
// Wrap stdout with the counter.
cw := &CountingWriter{w: os.Stdout}
// Write some data through the wrapper.
cw.Write([]byte("hello world\n"))
// The counter reflects exactly what was written.
// In a real program, you would expose c.n via a method.
}
This pattern appears everywhere in the standard library. bufio.Writer adds a memory buffer to reduce system calls. gzip.Writer compresses bytes before passing them downstream. httptest.ResponseRecorder captures HTTP responses in memory for testing. They all implement io.Writer. They all chain together seamlessly.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an I/O error when the signature forces you to name it.
Wrap carefully. Chain deliberately. Let the interface handle the rest.
Where things go wrong
The interface is simple, but the semantics trip up beginners. The most common mistake is assuming Write always consumes the entire slice. It does not. A network connection might only accept 4096 bytes at a time. A pipe might block after filling its kernel buffer. If you ignore the n return value, you will silently drop data.
The correct pattern is a loop that continues until all bytes are written or an error occurs.
func writeAll(w io.Writer, data []byte) error {
// Track how many bytes remain to be written.
remaining := data
for len(remaining) > 0 {
// Attempt to write the remaining slice.
n, err := w.Write(remaining)
// Advance the slice past the bytes that were accepted.
remaining = remaining[n:]
// Stop immediately if the writer reports a failure.
if err != nil {
return err
}
}
return nil
}
Another pitfall is ignoring the error entirely. You will see code that calls w.Write(data) and discards both return values. The compiler will complain with w.Write(data) (no value) used as value if you try to assign it, but if you use the blank identifier _ or simply call it without assignment, the error vanishes. Never drop the error from an I/O call. The worst goroutine bug is the one that never logs.
A third issue is signature mismatch. The Write method must return exactly (n int, err error). Returning just error or (int, int) breaks the interface. The compiler catches this early with missing method Write or Write has wrong type. Fix the signature, and the type satisfies the interface again.
Convention aside: io.Writer is always passed by value in function signatures. You will see func process(w io.Writer). You will not see func process(w *io.Writer). Interfaces are already reference-like under the hood. Adding a pointer is redundant and confusing.
Handle partial writes. Respect the error. Match the signature exactly.
Picking the right abstraction
Go gives you several ways to pass data around. Choosing the wrong one creates friction. Use the right tool for the data flow.
Use io.Writer when you need to send bytes to an unknown destination and want to support files, networks, memory, and custom sinks without changing your function signature. Use a concrete type like *os.File or bytes.Buffer when the destination is fixed and you need access to type-specific methods like Truncate or Bytes. Use io.Reader when you need to pull data from an unknown source instead of pushing it. Use generics with a custom constraint when you need to enforce multiple methods or type parameters that interfaces cannot express. Use plain sequential code when you do not need abstraction: the simplest thing that works is usually the right thing.