Last Updated: May 22, 2026
The Go standard library ships a handful of small interfaces that appear throughout the codebase: fmt.Stringer controls how a value prints, error represents anything that can go wrong, and io.Reader and io.Writer describe anything that produces or consumes a stream of bytes. Implementing them on your own types is how you plug into the rest of the standard library, so a Product you wrote yesterday can be printed by fmt, copied by io.Copy, sorted by the sort package, and serialized by encoding/json without any of those packages knowing your type exists. This lesson walks through each interface, what its single method does, and how to implement it on an e-commerce type like Product or Invoice.
A custom interface defined inside your package is useful for decoupling your own code. A standard interface from the standard library is useful for decoupling your code from the entire standard library. The fmt package can print any value, but if that value implements fmt.Stringer, fmt.Println calls your String() method instead of using the default reflection-based output. The io package can copy bytes between any reader and any writer, so a *bytes.Buffer, an os.File, an http.Response.Body, and a gzip.Reader are all interchangeable.
one struct, many interfaces, lots of free functionality.
A Product doesn't have to implement every interface on that diagram. It picks the ones that make sense. Implementing Stringer makes it print nicely. Implementing sort.Interface on a []Product makes it sortable. Implementing json.Marshaler lets you customize JSON output. Each one is opt-in, each one is one or two methods, and each one unlocks a whole subsystem of standard library code.
The rest of this lesson covers the four you'll meet most often: Stringer, error, Reader, and Writer. The last section walks through the rest at a glance so you know they exist and where to look when you need them.
The fmt package defines Stringer like this:
Any type with a String() string method satisfies Stringer. When you pass that value to fmt.Print, fmt.Println, fmt.Printf (with %s or %v), or fmt.Sprintf, the fmt package calls your String() method and uses the result. Without Stringer, fmt falls back to reflection and prints something like {BOOK-01 Go Guide 24.99 12}, which is useful for debugging but ugly for anything user-facing.
That's the default. The braces, the field order, the lack of labels, all decided by fmt because the type doesn't tell it otherwise. Now add a String() method:
One method, three printing functions, all of them now produce the same human-readable output. fmt checks for Stringer first and uses it whenever it finds it. You never call p.String() directly in user code; fmt does that for you.
The receiver type matters here. A value receiver (func (p Product) String() string) means both Product and *Product satisfy Stringer. A pointer receiver (func (p *Product) String() string) means only *Product satisfies it, and fmt.Println(p) for a non-pointer p will fall back to the default format. For printable types, a value receiver is almost always the right choice because it works in more places.
String() runs every time the value is formatted. If you call it inside a hot loop or a log line that gets filtered out, you're still paying for the fmt.Sprintf. For high-volume logging, prefer structured logging (log/slog) over fmt-based formatting.
One trap: don't call fmt.Sprintf("%v", p) inside p.String(). With %v, fmt will see that p is a Stringer and call p.String() again, which calls fmt.Sprintf("%v", p) again, and you get infinite recursion until the goroutine stack overflows. Use %s for strings and explicit verbs for everything else, or format each field one at a time.
error is a built-in interface, not part of any package. The full definition is:
Any type with an Error() string method is an error. Go's entire error-handling story rests on this one method. The point here is that errors are an interface, so you can define your own error types whenever a plain errors.New("...") isn't enough.
A simple custom error for an out-of-stock product:
First, the Error() method has the exact same shape as Stringer.String(), but Go's fmt package treats it differently. When you print an error with %s or %v or pass it to fmt.Println, fmt calls Error(), not String(). The two are separate interfaces so you can distinguish "this is the string form of a value" from "this is an error message".
Second, the custom error type carries data. OutOfStockError holds the product code, which lets calling code recover details that a plain string error would have lost. The errors.As helper checks whether the error chain contains an *OutOfStockError and assigns it to the local variable if so. Full coverage of error wrapping and inspection is in the Error Handling section. For this lesson, the takeaway is that the error interface is just another single-method interface, satisfied implicitly, and you can implement it on your own types whenever you need structured error information.
The io.Reader interface is the universal "source of bytes" in Go. Files, network connections, gzip streams, HTTP request bodies, and string readers all satisfy it. The definition is:
Read fills the byte slice p with up to len(p) bytes from the source, returns how many bytes it actually wrote (n), and returns an error. The error io.EOF is special: it means "the stream ended normally". Any other error means something went wrong.
The semantics catch people off guard, so they're worth stating directly. Read can return n > 0 along with err != nil. The convention is to process the n bytes first, then deal with the error. Read is also allowed to return fewer than len(p) bytes even when more data is available. Callers must loop until they've read everything they need or until they hit io.EOF.
Here's reading a small product catalog from an in-memory string. strings.NewReader wraps a string in a type that satisfies io.Reader, so the same code would work for an os.File or an HTTP response body:
Each call fills the 16-byte buffer with whatever's next in the stream. The last call returns 8 bytes because that's all that was left. The next call after that returns 0, io.EOF. The slice buf[:n] is the portion that was actually filled; reading beyond that would expose stale data from previous reads.
You almost never call Read directly in real code. The standard library wraps it in higher-level helpers that handle the loop and the partial-read semantics for you. The two most common are io.ReadAll, which reads everything into a single []byte, and bufio.Scanner, which reads one line (or one token) at a time:
bufio.NewScanner takes any io.Reader and gives back a scanner that handles all the buffering, line splitting, and EOF logic. Swap strings.NewReader(catalog) for os.Open("catalog.csv") and the rest of the code is identical. That's the composability win: code that takes an io.Reader works with strings, files, network streams, gzipped data, and anything else that produces bytes.
bufio.Scanner reads in chunks internally, which is much faster than calling Read directly with a small buffer. For files larger than a few kilobytes, prefer bufio.NewScanner or bufio.NewReader over raw Read.
io.Writer is the mirror image of io.Reader. Where Reader produces bytes, Writer consumes them:
Write takes a slice of bytes, writes them to the destination, and returns the count plus any error. Unlike Read, a Writer is expected to write the entire slice or return an error explaining why it couldn't. If n < len(p), the error must be non-nil.
The most common writers in the standard library are os.Stdout, os.Stderr, os.File (when opened for writing), *bytes.Buffer (an in-memory writer), strings.Builder (which also satisfies io.Writer), and http.ResponseWriter. Any of them can be passed to fmt.Fprintf, which is fmt.Printf but writes to an io.Writer of your choice:
Same function, same output, two completely different destinations. os.Stdout writes to the terminal; *bytes.Buffer captures everything into memory for later use (sending over the network, asserting in a test, writing to a file). The function renderInvoice doesn't know or care which one it got; it just calls Write.
You can also implement io.Writer yourself. Here's a writer that counts bytes as they pass through. Wrap it around another writer and you get the byte count :
CountingWriter satisfies io.Writer because it has a Write(p []byte) (int, error) method. It delegates to the wrapped writer and tallies the byte count on the way through. This decorator pattern shows up constantly in Go: gzip writers, base64 writers, encryption writers, all built by wrapping one io.Writer in another and adding behavior.
fmt.Fprintf allocates internally for formatting. In a tight loop writing the same shape of data over and over, building a strings.Builder or writing bytes directly is faster. For a small handful of writes per request, Fprintf is fine.
The pairing of Reader and Writer is also what powers io.Copy, which connects any reader to any writer:
io.Copy reads from the source until io.EOF, writes to the destination, and handles the loop, the partial reads, and the error checks. One line of code copies a string into stdout, or a file into a network connection, or a network connection into a gzip writer wrapped around a file. The same function works for all of them because both sides are interfaces.
io.Closer is the cleanup interface that pairs with readers and writers:
Anything that holds a resource (file handle, network connection, database row) should provide Close() to release it. The standard library combines Closer with the read and write interfaces using interface composition:
os.File, http.Response.Body, gzip.Reader, and sql.Rows are all ReadClosers. You read from them, then you close them.
The idiomatic pattern is to call Close() with defer so cleanup happens regardless of how the function exits:
The defer rc.Close() runs as loadCatalog returns, before the caller sees its result. The output order in the snippet above happens to show closing catalog stream first only because of how the print buffer flushes; in real code the close happens at the end of loadCatalog, just before the function returns to main. whatever path the function takes (a normal return, an early return on error, a panic), Close always runs.
Skipping Close is one of the most common resource leaks in Go programs. A leaked file descriptor stays open until the program exits. A leaked HTTP response body holds a network connection open and prevents connection reuse. The vet and staticcheck linters flag many missing-close cases, but the habit to form is: any function that returns an io.Closer should be paired with defer x.Close() on the next line.
The four above are the ones you'll meet most often. A few others are common enough to recognize.
`sort.Interface` lets the sort package sort any collection that knows how to count, compare, and swap its elements:
Implement those three methods on a type, and sort.Sort(yourType) sorts it in place:
Most code today uses the simpler sort.Slice(products, func(i, j int) bool {... }) or, in Go 1.21+, slices.SortFunc. sort.Interface is still the underlying mechanism, and is it in older codebases and library code.
`json.Marshaler` and `json.Unmarshaler` customize how a type encodes to and from JSON:
By default, encoding/json uses struct tags and reflection. If you implement MarshalJSON, the encoder calls it instead. This is useful for types like time values, money types, or anything where the JSON form differs from the in-memory form. The JSON chapter in the JSON & Encoding section covers these in detail.
`fmt.Formatter` is the heavyweight version of Stringer. It gives you direct control over the formatting verb (%v, %s, %d, %+v, and friends):
You almost never need this. Stringer covers 99% of cases. Formatter exists for types like big.Int and time.Time that want different output depending on the verb.
If you remember nothing else from this section, remember the rule: when you have a type and you want it to work with a part of the standard library, look for the one-method interface that the library calls into. fmt calls String(). errors calls Error(). io.Copy calls Read and Write. sort.Sort calls Len, Less, and Swap. json.Marshal calls MarshalJSON. Implement the interface, and your type slots into the existing machinery.
Putting it together, here's a Product that implements Stringer and error (for the out-of-stock case), and a []Product that satisfies sort.Interface. The same type works with fmt, with error handling, and with sort, just by adding methods:
The chain to look at: sort.Sort calls Len, Less, and Swap. fmt.Println(p) calls p.String(). fmt.Println(" [skip]", err) calls err.Error(), which itself uses %s on e.Product, which again triggers String(). None of the standard library code knows about Product. It only knows about the interfaces, and Product happens to satisfy three of them.
This is the payoff. A type that implements a few standard interfaces gets to ride on top of the standard library's existing machinery instead of building parallel versions of it.