Just added: Algorithms you can run and practice
Updated Jun 22, 2026 Edit on GitHub

The Standard Library as Design - Small Interfaces, Big Reach

Most languages ship a standard library that feels like a junk drawer - modules accreted over decades, each with its own opinions and quirks. Go's feels designed. Pick up almost any package and you find the same handful of small ideas reused everywhere, so learning one corner teaches you the next.

The mental model: Go's standard library is built on a few tiny interfaces that compose into enormous reach. Phases 9 and 10 showed Go interfaces are small and satisfied implicitly. This phase shows what that buys a whole ecosystem: a file, a network connection, an in-memory buffer, and a gzip compressor can all plug into each other, because every one speaks the same one-method language. Once you see the pattern, you'll stop reaching for third-party packages reflexively - the stdlib has probably already solved it, coherently.

io.Reader and io.Writer - two methods that run the I/O world

What it actually is. Nearly all Go input and output flows through two interfaces, each with exactly one method:

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

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

A Reader is "anything you can pull bytes from." A Writer is "anything you can push bytes to." That's the entire contract. Because satisfaction is implicit (Phase 9), a staggering number of unrelated types end up being readers and writers without saying so: an open file, a TCP connection, a bytes.Buffer, an HTTP request body, a gzip stream, even os.Stdout.

📝 Terminology. io.Reader / io.Writer are the two foundational interfaces in io. A type "is a reader" by having a Read([]byte) (int, error) method - no inheritance, no registration. Same for writers.

Why this is the whole game. Because every source is a Reader and every destination a Writer, any source can connect to any destination. No copyFileToSocket and separate copyBufferToFile - one io.Copy(dst Writer, src Reader) works for every combination, including ones nobody anticipated. The pieces compose like Lego: same studs.

One idea: anything on the left flows into anything on the right, since the middle only ever talks about Reader and Writer. A gzip writer is itself a writer wrapping another writer, so you can stack them.

A real example. One piece of plumbing, two very different destinations:

package main

import (
	"bytes"
	"io"
	"os"
	"strings"
)

func main() {
	src := strings.NewReader("hello, readers and writers\n") // a Reader over a string

	var buf bytes.Buffer        // an in-memory Writer
	io.Copy(&buf, src)          // pump everything from src into buf

	// Reset and copy the same kind of source straight to the terminal.
	src2 := strings.NewReader("...and now to stdout\n")
	io.Copy(os.Stdout, src2)    // os.Stdout is a Writer too

	os.Stdout.WriteString(buf.String())
}
$ go run main.go
...and now to stdout
hello, readers and writers

What just happened: strings.NewReader gave a Reader over a plain string. io.Copy doesn't know or care what's on either end - it pulled bytes from the reader and pushed them to the writer until EOF, once into a bytes.Buffer, once into os.Stdout, changing nothing about the copy logic. The buffer and the terminal share nothing except satisfying io.Writer - all io.Copy ever asked for.

💡 Key point. This is interface-driven design from Phase 10, at the scale of an entire standard library. Functions are written against the smallest interface they need (io.Copy needs only Read and Write), accepting the widest set of types - including types written years later. Wrapping is the superpower: a gzip.Writer compresses on its way to another writer, so gzip.NewWriter(file) gives "compress, then write to disk" by stacking two writers.

context - carrying cancellation across boundaries

You met context in Phase 12 for stopping work. It deserves a spot here because context.Context is itself a tiny interface the rest of the library threads through everything.

What it actually is. A Context carries three things across API boundaries: a cancellation signal (a Done() channel that closes when work should stop), an optional deadline, and request-scoped values. By convention it's the first parameter of any cancellable function: func Fetch(ctx context.Context, url string) (...).

Why this is a design lesson. Because net/http, database drivers, and most well-behaved libraries all accept a context.Context, cancellation composes the same way io does. A single timeout set at the top flows down through the HTTP handler, into the database query, into the outbound API call - every layer watches the same Done() channel, giving cancellation across the entire call tree for free.

package main

import (
	"context"
	"fmt"
	"time"
)

func slowWork(ctx context.Context) error {
	select {
	case <-time.After(2 * time.Second): // pretend this takes 2s
		return nil
	case <-ctx.Done(): // ...but the context gave up first
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel() // always release the context's resources

	if err := slowWork(ctx); err != nil {
		fmt.Println("stopped:", err)
	}
}
$ go run main.go
stopped: context deadline exceeded

What just happened: context.WithTimeout made a context that cancels itself after 100ms. slowWork raced two channels in a select: real work (2 seconds) versus Done(). The deadline fired first, so slowWork returned ctx.Err() - context deadline exceeded - instead of blocking two seconds. Pass that same ctx to an http.Request or SQL query and they'd abandon work at the same mark. ⚠️ Always defer cancel(); skipping it leaks the timer.

encoding/json - structs in, JSON out

JSON is how most services talk, and encoding/json is how Go speaks it - mapping structs to JSON using struct tags and the same exported/unexported visibility rule from Phase 9.

What it actually is. json.Marshal turns a Go value into JSON bytes; json.Unmarshal parses it back. Control how a field appears with a struct tag: json:"name". No schema file, no codegen - tags live right on the struct.

A real example.

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name  string `json:"name"`
	Email string `json:"email,omitempty"` // omit if empty
	Age   int    `json:"age"`
	token string // unexported - invisible to JSON
}

func main() {
	u := User{Name: "Ada", Age: 36, token: "secret"}

	data, _ := json.Marshal(u)
	fmt.Println(string(data))

	var back User
	json.Unmarshal([]byte(`{"name":"Grace","age":85}`), &back)
	fmt.Printf("%+v\n", back)
}
$ go run main.go
{"name":"Ada","age":36}
{Name:Grace Email: Age:85 token:}

What just happened: Marshal walked the struct's exported fields using each json: tag for the key - Name became "name". Email had omitempty and was empty, so it vanished entirely. The lowercase token field never appeared. Unmarshal parsed a JSON object into a fresh User (note &back needs a pointer to write into), filling Name and Age, leaving the rest at zero values.

⚠️ Gotcha - unexported fields are invisible to JSON. The same capitalization rule governing package visibility bites people wondering why their password field "disappeared." encoding/json lives in a different package and can only see exported (capitalized) fields. Watch omitempty too: it drops the field at the type's zero value, so a real, intentional 0, false, or "" also disappears. Reach for a pointer (*int) to distinguish "absent" from "zero."

net/http - a real web server in a few lines

The headline proof Go's standard library is production-grade: a genuine, deployable HTTP server with no framework at all - built on the interfaces you've already met. http.ResponseWriter is an io.Writer; a request body is an io.Reader; every handler takes a context.Context via the request.

What it actually is. Register handlers on an http.ServeMux (a router mapping URL paths to functions), then hand the mux to http.ListenAndServe. A handler receives an http.ResponseWriter and an *http.Request.

A real example - server and client in one file.

package main

import (
	"fmt"
	"io"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "hello from the stdlib") // w is an io.Writer!
	})

	go http.ListenAndServe(":8080", mux) // run the server in the background

	// Now act as a client against our own server.
	resp, err := http.Get("http://localhost:8080/hello")
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body) // resp.Body is an io.Reader!
	fmt.Print(string(body))
}
$ go run main.go
hello from the stdlib

What just happened: We built a router with http.NewServeMux, registered a handler on /hello, started the server with http.ListenAndServe. Inside, w is an http.ResponseWriter satisfying io.Writer, so fmt.Fprintln(w, ...) writes the response exactly like writing to a file. http.Get made a client request, and resp.Body came back as an io.Reader, drained by io.ReadAll. The same two interfaces from the start of this phase carry the entire request and response. (Always defer resp.Body.Close() to free the connection.)

💡 Key point. This server is not a toy. net/http handles connection management, HTTP/1.1 and HTTP/2, TLS, timeouts, and concurrency (each request in its own goroutine) - production services with no framework underneath. For a focused JSON-over-HTTP walkthrough see /guides/http-and-json-api-basics.

The lesson: reach for the standard library first

io, context, encoding/json, and net/http are the headliners, but the same coherence runs through the daily-driver packages you'll lean on constantly:

  • time - durations, deadlines, timers, formatting (the source of context's deadlines).
  • strings and bytes - mirror-image toolkits for text and raw bytes; strings.Builder and bytes.Buffer are both writers.
  • bufio - buffered wrappers around any reader/writer (you met bufio.Scanner in Phase 7); it wraps an io.Reader, naturally.
  • sort and the newer slices / maps generic helpers - sorting, searching, and transforming collections.
  • errors - errors.Is / errors.As / %w wrapping from Phase 7, the error half of the same design philosophy.

The meta-point: the standard library is coherent because it's built on a few small, composable interfaces, and that coherence is a reason to reach for it first. Before adding a dependency, check the stdlib - almost always there, battle-tested, zero supply-chain risk, and since everything speaks Reader, Writer, Context, and error, it slots together without glue code.

Recap

  1. io.Reader and io.Writer are two one-method interfaces that nearly all I/O flows through; because files, sockets, buffers, and gzip streams all satisfy them implicitly, any source can be piped to any destination with io.Copy and friends.
  2. context.Context threads a single cancellation/deadline signal down the entire call tree - net/http and database drivers all accept it, so one timeout composes across every layer. Always defer cancel().
  3. encoding/json maps structs to JSON via json:"..." struct tags; ⚠️ only exported (capitalized) fields are visible, and omitempty drops zero values too.
  4. net/http is a production-grade server in a few lines - handlers use http.ResponseWriter (an io.Writer) and read resp.Body (an io.Reader), reusing the very interfaces this phase opened with.
  5. The design lesson - time, strings, bufio, slices, and errors all reuse the same small-interface vocabulary. Reach for the standard library first: it's coherent, dependency-free, and composes without glue.

You now understand not just what's in Go's standard library, but why it fits together so well, which makes every new package faster to learn. Next: turning that composable code toward speed - profiling, allocation, and the optimizations that actually move the needle.

Quick check

Test yourself on the one idea that ties this phase together - small interfaces that compose:

[
  {
    "q": "Why can `io.Copy(dst, src)` work with a file, a network connection, and an in-memory buffer interchangeably?",
    "choices": [
      "Because each of those types satisfies the one-method io.Reader or io.Writer interface, and io.Copy only talks to those interfaces",
      "Because io.Copy has special-case code for every standard library type",
      "Because Go automatically converts all I/O types into files first",
      "Because io.Copy loads the entire source into memory before writing"
    ],
    "answer": 0,
    "explain": "io.Copy is written against io.Reader and io.Writer - the smallest interfaces it needs. Any type with the right one method satisfies them implicitly, so unrelated types like files, sockets, and buffers all plug in, including types written long after io.Copy."
  },
  {
    "q": "You marshal a struct to JSON but one field is missing from the output. The field is named `email` (lowercase). What's the most likely cause?",
    "choices": [
      "The field is unexported (lowercase), so encoding/json cannot see it",
      "JSON does not support string fields",
      "You must call json.Register on the field first",
      "Lowercase fields are always serialized as null"
    ],
    "answer": 0,
    "explain": "encoding/json lives in another package and can only access exported (capitalized) fields. A lowercase field is package-private and invisible to the marshaler. Capitalize it and use a json:\"email\" tag to control the key name."
  },
  {
    "q": "What does passing a `context.Context` with a timeout into `http.Get`, a database query, and an outbound API call give you?",
    "choices": [
      "A single cancellation signal that propagates through every layer, so one timeout stops the whole call tree",
      "Faster execution because context skips network round-trips",
      "Automatic retries of any operation that fails",
      "Encrypted communication between the layers"
    ],
    "answer": 0,
    "explain": "context.Context carries one cancellation/deadline signal across API boundaries. Because the standard library threads it consistently, a single timeout set at the top flows down and every layer watches the same Done() channel - cancellation composes for free."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. Why can `io.Copy(dst, src)` work with a file, a network connection, and an in-memory buffer interchangeably?

2. You marshal a struct to JSON but one field is missing from the output. The field is named `email` (lowercase). What's the most likely cause?

3. What does passing a `context.Context` with a timeout into `http.Get`, a database query, and an outbound API call give you?