all_lessons/Go/13 · The standard librarylesson 14 / 18

The standard library as a teacher

Go's standard library is not just a pile of useful packages — it is the language's own answer to the question every lesson has been circling: what does abstraction look like when complexity must be earned? The answer is concentrated in two one-method interfaces, io.Reader and io.Writer, from which an astonishing amount of the library composes. This lesson reads the stdlib the way you'd read a master's sketchbook — not for the API, but for the taste it encodes.

The thesis, here
The Go bargain says complexity must be earned, judged against the reader and the team. The standard library is the proof that the bargain produces good code, not just small code: it is written by the language designers, read by every Go programmer alive, and maintained across a decade and dozens of releases — the exact "programming in the large" situation Go was built for. So its idioms aren't suggestions; they are the worked example of how to spend a complexity budget well. When you imitate io.Reader, you are not copying syntax — you are inheriting the taste of people who paid the maintenance bill in public.
Linear position
Prerequisite: Lesson 05 gave you interfaces satisfied implicitly and the "accept interfaces, return structs" rule; Lesson 06 gave you composition and embedding; Lesson 12 gave you the package as the unit of encapsulation. This lesson is where all three pay off at once.
New capability: read any stdlib package and recognize why its interfaces are shaped the way they are; design your own APIs around small interfaces so they compose with the library instead of fighting it; and explain how io.Copy can move bytes between things it has never heard of.
The plan
Four moves. (1) Start from the engineering pain — combinatorial explosion of "copy from X to Y" functions — and watch io.Reader/io.Writer dissolve it. (2) See composition in action: how bufio, gzip, and io.Copy stack on those interfaces without any of them knowing about the others. (3) Read two more idiom-defining packages — net/http and encoding/json — for the taste they teach. (4) Pull out the transferable rules and the honest costs.

1 · The problem: the N×M explosion

Imagine you have no abstraction for "a source of bytes." You want to copy data, and your sources are: a file, a network socket, an in-memory buffer, a decompressing stream, an HTTP response body. Your destinations are the same list. Without a shared abstraction you write a copy function for every pair — file-to-socket, buffer-to-file, socket-to-gzip — and that is an N×M grid of nearly identical functions. Add one new source and you write M new functions. This is the concretion trap from question 4 of the five: every function is welded to two specific types, so the library grows like a multiplication table and no two halves of it compose.

Go's answer is to name the behavior, not the types. A thing you can read bytes from is anything with one method; a thing you can write bytes to is anything with one method:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

That is the entire contract. Read fills as much of p as it can, returns how many bytes it wrote into the slice and possibly an error (with the sentinel io.EOF marking the end — Lesson 07's errors-as-values, used as a stream terminator). Write consumes p and reports how many bytes it accepted. Notice the contract is deliberately minimal and a little awkward: short reads are legal, EOF may arrive with the last data or on its own, and the caller must loop. That awkwardness is the price of having exactly one method — and one method is what makes the universe of implementers enormous. Because Go satisfies interfaces structurally (Lesson 05), *os.File, net.Conn, *bytes.Buffer, *strings.Reader, and a gzip stream are all io.Readers without any of them mentioning the interface.

Now the N×M grid collapses to a single function. The whole point of the design is this signature:

func Copy(dst Writer, src Reader) (written int64, err error)

One function moves bytes from any reader to any writer. The combinatorial table became one cell, and a new source type costs zero new copy functions. This is question 4 answered correctly at the scale of an entire standard library: depend on a behavior, and the dependency count stops multiplying.

2 · Composition: stacking readers and writers

The deeper payoff is that the interface is closed under composition — a thing that wraps a reader and transforms its bytes is itself a reader. So you build pipelines by nesting, and each layer knows only "I read from some reader; I am a reader." Decompress a gzip-compressed file:

f, err := os.Open("data.json.gz")   // *os.File  — an io.Reader
if err != nil {
    return err
}
defer f.Close()

gz, err := gzip.NewReader(f)         // wraps the file; gz is ALSO an io.Reader
if err != nil {
    return err
}
defer gz.Close()

n, err := io.Copy(os.Stdout, gz)     // os.Stdout is an io.Writer

io.Copy has never heard of gzip or files. It calls gz.Read; gz calls f.Read, decompresses what it gets, and hands plaintext back up. Each layer is independently written, tested, and replaceable. Swap os.Open for an HTTP response body and not one other line changes — both are io.Readers. This is the spine of the design:

io.Copy(dst, src) | pulls bytes via src.Read(buf), pushes via dst.Write(buf) v +----------------------+ +----------------------+ | src: io.Reader | | dst: io.Writer | +----------------------+ +----------------------+ | gzip.Reader --reads-from--> bufio.Reader --> *os.File (the network, | (decompress) (batches I/O) a file, a socket...) +----------------------+ +----------------------+ each wrapper IS the interface it wraps -> layers compose blind

The composition is also where you buy back the awkward minimal contract. The raw Read loop is tedious, so bufio.NewReader wraps any reader and adds buffering plus convenience methods like ReadString('\n') — and a bufio.Reader is, of course, still an io.Reader. bufio matters for a concrete reason: an unbuffered Read on a file or socket is a syscall, and a syscall costs on the order of a microsecond plus a kernel/user transition; reading a 64 KB buffer once and serving 4096 small reads from it turns ~4096 syscalls into 1. The library hands you the small interface for composability and the buffering wrapper for performance, and they are different types so you only pay for buffering when you ask.

The small interfaceOne methodWhy it composes
io.ReaderRead([]byte) (int, error)any byte source; wrappers (gzip, bufio, tar) are themselves readers
io.WriterWrite([]byte) (int, error)any byte sink; fmt.Fprintf, loggers, hashers, gzip writers all target it
io.CloserClose() errorcomposed with the others — io.ReadCloser is Reader+Closer (Lesson 06 embedding)
fmt.StringerString() stringone method makes your type printable by every fmt verb

Look at io.ReadCloser: it is literally interface { Reader; Closer } — interface embedding from Lesson 06, used to assemble a bigger contract out of small ones rather than declaring a fat interface up front. Small interfaces are not just easy to satisfy; they are easy to combine, which is the same property that made the bytes compose.

3 · Reading net/http and encoding/json for taste

Two more packages are worth reading not for their API surface but for the design decisions they make visible. net/http shows you the small-interface habit applied to a whole subsystem. A server handler is, in its purest form, one method:

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

And http.ResponseWriter — what you write the response into — embeds io.Writer's behavior: you write a body with w.Write or fmt.Fprintf(w, ...), the exact same verbs that write to a file or a buffer. So io.Copy(w, someReader) streams a file straight to an HTTP client, no special case. The decompression pipeline from §2 and the web server share one vocabulary. This is the standard library being self-consistent on purpose: learn io.Writer once and you have learned how to write to nearly everything in Go.

encoding/json teaches a different lesson — about boundaries and reflection. It converts between Go values and JSON using struct tags, metadata strings the package reads at runtime via reflection:

type User struct {
    Name  string `json:"name"`
    Email string `json:"email,omitempty"`
    id    int    // unexported -> invisible to json, by Lesson 12's rule
}

data, err := json.Marshal(u)           // Go value -> []byte
err = json.Unmarshal(data, &u)         // []byte -> Go value (note &u)

Three deliberate decisions are on display. First, encapsulation drives the wire format: json can only see exported (Capitalized) fields, so Lesson 12's "exported = part of the contract" extends literally to what crosses the network — the unexported id is structurally invisible. Second, the streaming forms json.NewEncoder(w).Encode(v) and json.NewDecoder(r).Decode(&v) take an io.Writer and an io.Reader — so JSON composes with everything in §2, and you can decode straight off a network socket without buffering the whole payload. Third, the honest cost: reflection is the price. encoding/json inspects types at runtime, which is convenient and general but roughly an order of magnitude slower than hand-written or code-generated marshaling — a real, measurable bill you accept in exchange for not writing serialization by hand. Naming that cost is the lesson; Go's stdlib is taste, not magic, and the magic always has a price tag.

4 · The transferable rules

Read enough of the standard library and the same moves recur. They are the design rules you carry to your own code — and to any language:

The smaller the interface, the more useful it is
Rob Pike's proverb. One method (Read) has a universe of implementers and composes blindly; a ten-method interface has almost none and welds callers to it. Define interfaces by the least a function needs (question 1: what can I leave out).
Accept interfaces, return structs
A function takes io.Reader (so any source works) but returns a concrete *gzip.Reader (so the caller gets every method, not a narrowed view). The library does this everywhere — it is Lesson 05 made into muscle memory.
Compose with embedding, not hierarchy
io.ReadCloser = Reader + Closer, bufio.ReadWriter = Reader + Writer. Big contracts are assembled from small ones (Lesson 06), never inherited from a base class. The pieces stay independently useful.
Make the cost a choice
Buffering is a separate bufio wrapper, not built into io.Reader; reflection-based JSON is one option beside code generation. The library never bundles a cost you didn't ask for (question 5: what did it cost, who pays).

The meta-rule: when your API speaks the standard library's vocabulary — takes an io.Reader, returns something printable via Stringer, satisfies error — it plugs into everything Go programmers already know how to use, and a new reader onboards in minutes because the shapes are familiar. That interoperability is the team-up payoff: shared idiom is shared maintenance.

Checkpoint exercise

Try it
Write a small type countingWriter that wraps an io.Writer, forwards Write to it, and keeps a running total of bytes written. It should be ~8 lines: a struct holding an io.Writer and an int64, and a Write method that calls the inner writer and adds n to the total. Now use it: cw := &countingWriter{w: os.Stdout}; io.Copy(cw, someReader), then read cw.total. Confirm that io.Copy — code you can't edit — happily wrote through your brand-new type. Then ask the five questions of your design: which method did you leave out (everything but Write); does your type depend on a behavior or a concretion (it holds io.Writer, so any sink works); what did it cost (one extra method call per Write, nothing else). You have just extended the standard library without it knowing you exist.

Where this points next

Every abstraction in this lesson was built from interfaces — behavior shared across types Go never had to enumerate. But interfaces have a blind spot: they erase the concrete type, so a function over any loses compile-time type safety and a slice of "comparable things" or a generic Min can't be written cleanly with interfaces alone. For thirteen years Go simply lived with that gap, copy-pasting MinInt, MinFloat, MinString — because the complexity budget said the cure was worse than the disease. Lesson 14 tells the story of what finally changed that calculation: type parameters and constraints, generics as the textbook case of a feature that had to earn its place against everything the standard library already did well with interfaces.

Takeaway
The standard library is the Go bargain's worked example: it dissolves an N×M explosion of "copy X to Y" functions into a single io.Copy by naming the behavior — a one-method io.Reader, a one-method io.Writer — instead of the concrete types, and because Go satisfies interfaces structurally (Lesson 05) the universe of implementers is unbounded and wrappers like gzip and bufio compose blind, each one being the very interface it wraps. The transferable rules are the proverbs the library encodes: the smaller the interface the more useful it is, accept interfaces and return structs, assemble big contracts from small ones by embedding rather than inheritance (Lesson 06), and make every cost — buffering, reflection-based JSON — a wrapper you opt into rather than a tax baked in. Reading the stdlib is how you inherit the taste of engineers who paid the maintenance bill in public; speaking its vocabulary is how your code joins that shared idiom, which is the team-up payoff made concrete.

Interview prompts