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.
io.Reader, you are not copying syntax — you are inheriting the taste of people who paid the maintenance bill in public.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.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:
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 interface | One method | Why it composes |
|---|---|---|
io.Reader | Read([]byte) (int, error) | any byte source; wrappers (gzip, bufio, tar) are themselves readers |
io.Writer | Write([]byte) (int, error) | any byte sink; fmt.Fprintf, loggers, hashers, gzip writers all target it |
io.Closer | Close() error | composed with the others — io.ReadCloser is Reader+Closer (Lesson 06 embedding) |
fmt.Stringer | String() string | one 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:
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).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.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.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
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.
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
- Why is
io.Readera single method, and what does that buy? (§1–2 — one method maximizes the set of implementers and lets wrappers be readers themselves, so functions likeio.Copywork on any source and pipelines compose; the proverb is "the smaller the interface, the more useful it is.") - How can
io.Copymove bytes between a gzip stream and an HTTP socket it has never heard of? (§2 — it depends only on theReader/Writerbehavior; structural satisfaction (Lesson 05) means each concrete type is aReader/Writerwithout declaring it, and wrappers likegzip.Readerare themselves readers.) - What problem does the N×M framing describe, and how does naming a behavior fix it? (§1 — copying between N sources and M sinks needs N×M concrete functions; abstracting to one
io.Readerand oneio.Writercollapses the table to a singleio.Copy, and a new type adds zero functions.) - Why is buffering a separate
bufiowrapper instead of part ofio.Reader? (§2 — "make the cost a choice": an unbufferedReadis one syscall (~1 µs each), bufio turns thousands of small reads into one 64 KB read, but you only pay for the wrapper when you ask, keeping the core interface minimal.) - What is the honest cost of
encoding/json, and why accept it? (§3 — it uses runtime reflection over struct tags, roughly an order of magnitude slower than code-generated marshaling; you accept generality and not hand-writing serialization, and the streaming forms still compose viaio.Reader/Writer.) - How does package encapsulation (Lesson 12) show up in JSON output? (§3 —
encoding/jsononly sees exported, Capitalized fields, so unexported fields are invisible on the wire; "exported = the contract" extends literally to the serialized format.) - What does "accept interfaces, return structs" mean and where does the stdlib model it? (§4 — accept the narrowest behavior you need (
io.Reader) so any source works, but return a concrete type (*gzip.Reader) so the caller keeps every method;gzip.NewReaderand most constructors do exactly this.)