taskd Beginner Ed.
Part 0: Start here / Chapter 1
~15 min read
8,500 words

How to read this book

This book builds one project, from an empty folder to a deployable, money-taking service: a task manager API called taskd. Every chapter changes the same codebase, in order. Nothing is hand-waved with “assume you have X” — if the code needs something, we build it or install it on the page where it first matters.

This is the beginner edition. The system it builds is not a beginner system: it is the real thing, with connection pools, graceful shutdown, metrics, rate limits, background workers, Stripe billing and a CI pipeline. We did not simplify the software. We simplified the explanation — every idea gets introduced from first principles, at the moment you need it, in plain words.

Why this exists

Most tutorials teach you to build a toy and leave you stranded the first time it meets a real user. Most production codebases are unreadable to a newcomer. This book refuses both: you get production code, explained one honest step at a time, so that at the end you can not only run it — you can defend every line of it in an interview.


Who this book is for

You, if:

  • you have never programmed, or you have written a bit of Python or JavaScript and never shipped anything;
  • you have never written Go;
  • you are willing to type things out, read error messages, and be temporarily confused.

You do not need to know Go, SQL, HTTP, Docker, or what a “connection pool” is. Four short warm-up chapters teach each of those before the code needs them:

If you have never… Read first
used a terminal, or installed Go/Docker Before you begin
wondered what actually happens when you open a website How the web actually works
written Go Go in one sitting
written SQL or used a database SQL and databases in one sitting

If you already know some of that, skim it. The rest of the book assumes you have read them.


What you’ll have at the end

A single Go program, about 4,000 lines, that:

  • serves a JSON API for tasks — create, read, update, delete, search, sort, paginate;
  • registers users, stores passwords safely, and authenticates requests with tokens;
  • sells three plans (Free, Pro, Business) through Stripe, and enforces what each plan can do;
  • caches hot reads, rate-limits abusers, sends email in the background;
  • reports its own health and speed to Prometheus, and logs in a format machines can search;
  • ships as a 15 MB Docker image with a pipeline that refuses to deploy broken code.

And a folder of notes in learnings/ that proves you understood it, written by you.


How each chapter is built

Every chapter follows the same shape. Once you have read two of them you will know exactly where to look for what you need.

Section What it gives you
The problem, in plain words Why this thing exists at all, before any code.
New words in this chapter Every piece of jargon, defined in one line.
The goal The one observable capability you’ll have at the end.
The thinking The options that existed, which we picked, and why. Most tutorials skip this. It is the most valuable part.
A picture of it A diagram of what you’re about to build.
The steps Numbered, copy-pasteable, in dependency order — each with a line-by-line decode.
Checkpoint The command to run and the exact output that proves it worked.
Common mistakes The error message you’ll actually see, what it means, and the fix.
Pitfalls The traps that only bite later, in production.
Check yourself A quiz, with answers and reasoning.
Practice Exercises on the real codebase, with full solutions.
FAQ The questions people are too embarrassed to ask.
Where we are What works now, what’s still fake, and what to write in your notes.

The original edition of this book had four of those sections. The other nine exist because a first-timer needs them, and because you learn a thing properly when you can predict it, break it, and explain it — not when you have merely copied it.


The two people this book learned from

The codebase has two influences, and it is worth naming them so you know which instincts to copy.

Alex Edwards (author of Let’s Go Further) gives us the structure: the cmd/api + internal folder layout, the application struct that carries shared dependencies, helper functions for JSON, an explicit errors.go, a hand-rolled validator package, stateful token authentication, optimistic concurrency with a version column, graceful shutdown done correctly, and a Makefile-driven workflow. If you have read his books, this codebase will feel like home. If you haven’t, buy them — this book is a complement, not a substitute.

Kailash Nadh (CTO of Zerodha, India’s largest stockbroker; author of listmonk, koanf and stuffbin) gives us the temperament: boring technology, brutally few dependencies, plain SQL you can read, a single static binary, configuration in one TOML file with environment-variable overrides (we use his koanf library for exactly that), and a deep suspicion of frameworks, ORMs, dependency-injection containers and microservices you don’t need. His teams run systems serving millions of users on a handful of ordinary servers. We build like that.

New word

ORM — “object-relational mapper”, a library that writes your database queries for you from code. Convenient on day one; hard to control when your data grows. We write our own SQL instead, and Chapter 7 shows how to make that type-safe.

Where the two philosophies disagree — Edwards wraps database access in model structs, Nadh calls SQL functions straight from handlers — the book stops and argues it out in front of you, then picks a side. That argument is the education.


What you need installed

Go 1.24 or newer, Docker with Compose, and curl. That is genuinely all — Postgres, DragonflyDB, the migration tool, the mail server and everything else arrive through Docker as we go. The next chapter installs each one and proves it works.

Note

The original edition also expected you to be comfortable with SQL. This edition teaches it instead — see SQL and databases in one sitting.

Versions pinned in this book (newer patch versions are fine):

Dependency Version Why it’s here
Go 1.24 the language
go-chi/chi v5 router — matches URLs to code
jackc/pgx v5 PostgreSQL driver + connection pool
sqlc v1.27+ turns SQL files into typed Go functions
golang-migrate v4 applies database schema changes in order
knadh/koanf v2 reads config from TOML + environment
redis/go-redis v9 client for DragonflyDB
stripe/stripe-go v78 billing
prometheus/client_golang v1.20 metrics
wneessen/go-mail v0.5+ sending email
go-chi/cors v1 browser cross-origin rules
PostgreSQL 17 the database
DragonflyDB latest cache and rate-limit counters
Mailpit latest a fake inbox for local development

Following along without getting lost

Every time a chapter creates a new function or dependency, it also shows the wiring — the exact change to main.go or routes.go that puts it to work. Those small blocks are the connective tissue; never skip them.

Three safety nets, for when you fall out of sync:

  1. Checkpoints. Every chapter ends with a command and the exact output it should print. If your output differs, stop there — do not continue and hope.
  2. Appendix A has the final file tree and Appendix E the complete, annotated main.go. Compare yours against them and you will find the missing wire in a minute.
  3. Appendix H is a troubleshooting index: error message on the left, cause and fix on the right, for every error this book can produce. Appendix F holds the complete final routes.go — the whole API on one screen — and the full OpenAPI spec.
Tip

Type the code; don’t paste it. Typing is slower, and that is the point — it forces you to read every character, and your fingers learn the shapes. Paste only long SQL and config blocks.


How to use the learnings/ folder

There is a learnings/ folder next to the book with one worksheet per chapter. The rhythm that works:

  1. Read the chapter once, all the way through, without typing anything. You are just meeting the ideas.
  2. Read it again, typing every code block and running every checkpoint.
  3. Close the book. Open learnings/chNN.md and fill it in from memory — in your own words. Looking something up is fine; copy-pasting the book’s sentences is not.
  4. Anything you couldn’t explain goes in the “still don’t understand” list. That list is the most valuable thing you own. Carry it forward; most items resolve themselves two chapters later.

If a worksheet takes you more than twenty minutes, you are copying rather than recalling.


A note on honesty

Everything in this book compiles and runs. You will still make typos, and versions drift. When something breaks, the debugging is the course — it is not an interruption of the learning, it is the learning. Read the error message. It is Go: the error is usually telling the truth.

You will also hit moments where the amount of code for something apparently simple feels absurd. That feeling is correct and temporary. The extra code is almost always one of three things: a timeout, an error path, or a lie you’re refusing to tell your future self. Chapter by chapter, you will start recognising them on sight.

Take it slowly. Two chapters an evening is a good pace. Nobody is timing you.

Before you begin

This is the warm-up. By the end of it you will have a terminal you are not afraid of, seven programs installed and proven to work, a folder for your notes, and a Go program you wrote and ran yourself. Nothing here teaches Go the language — Go in one sitting does that. This chapter is about the machine you will be operating for the next 27 chapters.

Time: about 25 minutes reading, 30–60 minutes installing.

Permission to skim: if you already live in a terminal and have Go 1.24+, Docker and an editor working, run the verification block in §5.7, read §8 (How to read this book’s code blocks), and go to Chapter 1 (Introduction).

Why this exists

Almost nobody quits a programming book at a hard concept. They quit at migrate: command not found, three chapters in, with no idea whether the problem is them, the book, or the computer. This chapter exists so that when something breaks later, you already know which of those three it is.


1. What a terminal is

The terminal is a window where you type a line, press Enter, a program runs, and text comes back. The graphical desktop is a convenience layer on top; the terminal is the layer the tools in this book actually speak.

New word

shell — the program inside the terminal window that reads what you type and starts other programs for you. macOS defaults to zsh, Ubuntu and WSL2 to bash. Everything here works in either.

Platform How to open one
macOS Cmd+Space, type Terminal, Enter
Ubuntu / Debian Ctrl+Alt+T
Windows install WSL2 (§2), then open the Ubuntu app

Where you are

When the shell is ready it prints a prompt (usually ending in $ or %) and waits.

The shell always has a current working directory — one folder it considers “here”. When you type go run ., the . means “this folder”. In the wrong folder you get an error that has nothing to do with your code. More beginner confusion comes from this than from any language feature.

Command What it does
pwd prints the folder you are in (print working directory)
ls lists what is in it
cd somewhere moves into a folder
mkdir name creates one
cat file prints a file’s contents

Two special names: .. is the folder above (cd .. goes up), ~ is your home folder.

Commands, arguments, flags

   mkdir      -p          ~/taskd-learnings
   ───┬──     ─┬─         ───────┬─────────
  the program  a flag        an argument
               (changes how     (what to
                it behaves)      act on)

Flags start with - or --. curl -i localhost:4000 runs curl with the -i flag on the argument localhost:4000. Each flag you meet gets explained where it first appears.

Four keys that save you

  • Tab completes a name you have started typing. Type cd task, press Tab, the shell finishes it. Treat this as a spell-checker: if Tab does not complete, the thing does not exist, and you have found your bug before running anything.
  • Up arrow brings back the previous command. You will run go run ./cmd/api several hundred times in this book. Type it once.
  • Ctrl-C stops the program currently running. Chapter 2 starts a server that never returns on its own; Ctrl-C is how you get your prompt back. Chapter 4 (A server that dies well) is entirely about what your program should do on receiving it.
  • Ctrl-D means “no more input”. You need it to leave psql in Chapter 5 (PostgreSQL and migrations).

Silence is success

Unix tools print nothing when they succeed. mkdir taskd prints nothing; cd taskd prints nothing. Unexpected text is the alarm, not the absence of it. Underneath, every command also returns an exit code — 0 for success, anything else for failure. You rarely look at it, but Chapter 26 (CI/CD: the robot that says no) is built entirely on it.

Run this now

cd ~
mkdir taskd-warmup
cd taskd-warmup
pwd
ls

pwd should print a path ending in taskd-warmup, and ls should print nothing at all, because the folder is empty. Here is a real transcript of the same three-command shape on another machine, with a folder created and listed from its parent:

/Volumes/ssd-main/Developer/book-golang/build/scratch/demo
taskd-demo

2. Which computer are you on?

Three supported setups, two of which are the same setup.

Your machine What you use
Mac (Apple Silicon or Intel) the macOS terminal, natively
Linux (Ubuntu/Debian) your terminal, natively
Windows WSL2 running Ubuntu — not PowerShell, not Command Prompt, not Git Bash
Important

Windows gets no native column on purpose. This book’s Makefile is GNU-make-only, its shell snippets use POSIX quoting, and every path uses forward slashes. WSL2 gives you a real Ubuntu inside Windows where every command in this book works character-for-character as printed. Translating the book to PowerShell costs more time than installing WSL2.

Open PowerShell as Administrator (right-click Start, “Terminal (Admin)”) and run wsl --install. Restart when asked. On next boot an Ubuntu window opens and asks you to invent a username and password — that is your Linux account, and the password is what sudo will ask for later. Then confirm the version with wsl --list --verbose: the VERSION column must say 2. If it says 1, run wsl --set-version Ubuntu 2.

From then on, “open a terminal” means “open the Ubuntu app”.

Warning

Keep your project inside Linux. Work in ~/taskd, never in /mnt/c/Users/.... Windows files are reachable from Linux, but Go builds crawl and Docker’s file permissions misbehave across that boundary.

Three more WSL2 details:

  1. Docker Desktop needs a toggle: Settings → Resources → WSL Integration → enable for Ubuntu. Without it, docker is not found inside your Ubuntu shell.
  2. localhost crosses the boundary. A server on port 4000 inside WSL2 opens at http://localhost:4000 in your Windows browser — which matters from Chapter 18 (Prometheus) and Chapter 21 (Background work and transactional email) onward.
  3. Turn off line-ending rewriting, once: git config --global core.autocrlf false. Windows line endings inside a Makefile produce the same baffling missing separator error as the tab mistake in §8.

3. What “installing” means

A program is a file. Installing it means putting that file somewhere your shell knows to look, so that typing its name is enough.

The shell does not search your disk. It looks in an ordered list of folders held in an environment variable called PATH.

New word

environment variable — a named piece of text the shell hands to every program it starts. PATH is the most important one. Chapter 3 (Configuration and logging, the Nadh way) makes your own program read environment variables for its settings; Chapter 25 (Docker) uses them as the container’s entire configuration interface.

echo $PATH | tr ':' '\n'

That prints the search list, one folder per line, in order. When a program is not on it, you get the most common installation error there is. Real output, first from zsh and then from bash:

zsh:1: command not found: flurble
bash: flurble: command not found

That message means exactly one thing: the shell looked through every folder in PATH and found nothing by that name. It does not mean the program is broken or that your flags are wrong. To ask where the shell would find something, run command -v go — it prints a full path, or prints nothing and fails.

Remember this

command not found is a PATH problem, never a code problem. Installing is nothing more mysterious than putting a file in a folder that PATH lists.


4. The tools, and why each is here

Tool Why this book needs it First used
Go 1.24+ the language; compiles your code into a program Chapter 2
Docker Desktop (with Compose) runs Postgres, DragonflyDB, Mailpit and Prometheus without installing any of them Chapter 5
curl sends HTTP requests from the terminal; how you test every endpoint you write Chapter 2
An editor (VS Code + Go extension) where you write the code Chapter 2
Git records history, so you can see what changed since the last thing that worked Chapter 2
make a file of named shortcuts (make run/api) Chapter 5
jq pretty-prints and filters JSON in the terminal Chapter 19
Note

The original edition listed four prerequisites: Go, Docker, curl, and SQL knowledge. It quietly also required git, make and jq. This edition installs all seven up front, and teaches the SQL in SQL and databases in one sitting.

Installed later, on the page that needs them — do not install these now: migrate (Chapter 5), sqlc (Chapter 7, sqlc: SQL in, type-safe Go out), and the stripe CLI (Chapter 16, Stripe II: webhooks).


5. Installing, one tool at a time

Each subsection ends with the command that proves it worked. Run it before moving on. If a check fails, §6 has the fix.

5.1 An editor: VS Code and the Go extension

Download from https://code.visualstudio.com. On WSL2, install VS Code on Windows and add the WSL extension, so the Windows editor can edit files living inside Linux.

Then press Ctrl+Shift+X (Cmd+Shift+X on Mac), search Go, and install the extension published by the Go Team at Google. The first time you open a .go file it offers to install helper tools. Say yes.

The important one is gopls, the Go language server. It reads your code as you type and gives you red underlines under errors before you run anything, Ctrl-click jump-to-definition into any function including the standard library’s, and formatting on save.

Turn that last one on now: Settings (Ctrl+,), search format on save, tick it.

Tip

Format-on-save runs gofmt, Go’s official formatter. Go has one legal layout and no style debates. This matters mechanically, not aesthetically: Chapter 26 (CI/CD) runs a check that fails your build if any file is not gofmt-clean. Tick the box now and that failure never happens to you.

Open the folder, not the file. VS Code’s Go support works on a folder containing a go.mod. Use File → Open Folder. Open a lone .go file and gopls has no module to reason about, so half its features go quiet.

Check with code --version. On the machine used here that printed 1.133.0 plus a commit hash and an architecture. Any version is fine.

5.2 Git

Git records snapshots of your project, so you can answer “what did I change since it worked?” — the most effective debugging tool you own.

Platform Command
macOS xcode-select --install (gives you Git, make and a compiler together)
Ubuntu / WSL2 sudo apt update && sudo apt install git
git --version

Real output here:

git version 2.39.5 (Apple Git-154)

Any 2.x is fine.

5.3 Go 1.24 or newer

Do not install Go from your Linux distribution’s package manager. apt install golang gives you a version that is often more than a year old, and this book needs 1.24+.

macOS. First find your chip:

uname -m

arm64 is Apple Silicon; x86_64 is an Intel Mac. Download the matching .pkg from https://go.dev/dl/darwin-arm64.pkg or darwin-amd64.pkg — and double-click it. It installs into /usr/local/go and puts /usr/local/go/bin on your PATH.

Common mistake

You’ll see: the installer finishes, then command not found: go in a terminal you already had open. It means: that window read PATH when it opened, before Go existed. Fix: close it and open a new one. This applies to every install in this chapter.

Ubuntu / WSL2. Unpack the Linux tarball into /usr/local, replacing anything already there. Substitute the current version from go.dev/dl:

curl -LO https://go.dev/dl/go1.24.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc

(On macOS the startup file is ~/.zshrc, not ~/.bashrc.)

go version

Real output here:

go version go1.25.6 darwin/arm64

Read it left to right: the toolchain version (go1.25.6), then the operating system and processor architecture it was built for (darwin is macOS’s internal name). Yours must say 1.24 or higher; on Ubuntu the suffix reads linux/amd64 or linux/arm64.

The one extra step everyone skips

Two tools later in this book — migrate in Chapter 5 and sqlc in Chapter 7 — are installed by Go itself, with go install. That command puts the finished program in $(go env GOPATH)/bin, usually ~/go/bin. That folder is not on PATH by default.

So you install migrate successfully, run make db/migrations/up, and get migrate: command not found at the exact moment you can least diagnose it. Fix it before it can happen:

echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc
source ~/.zshrc

Use ~/.bashrc on Ubuntu and WSL2. Then prove it:

echo $PATH | tr ':' '\n' | grep go

You should get a line ending in /go/bin. Here that printed /Users/shine/go/bin.

5.4 Docker Desktop, with Compose

New word

container — a running, isolated copy of a packaged program, with its own filesystem and its own view of the network. image — the frozen template a container is started from. Chapter 5 runs PostgreSQL on your machine without installing PostgreSQL; Chapter 25 packages your program as an image.

Download Docker Desktop from https://www.docker.com/products/docker-desktop/.

  • macOS: pick the build matching your uname -m answer. The wrong one is a large download that fails at launch.
  • Windows: install it on Windows, not inside Ubuntu, then enable WSL integration (§2).
  • Ubuntu, native: Docker Desktop for Linux works, or install Docker Engine plus the Compose plugin from Docker’s repository. Afterwards run sudo usermod -aG docker $USER and log out and back in, or every docker command will demand sudo.

Docker Desktop must be running. It is an application, not only a command. Look for the whale icon in your menu bar or system tray; without it, every docker command fails no matter how correctly it is installed.

New word

daemon — a program running continuously in the background, waiting to be asked to do things. The docker command you type is a thin client that sends requests to the Docker daemon. “Docker isn’t running” always means the daemon.

docker compose version

Real output here:

Docker Compose version v5.1.0

Note the shape: docker compose, two words with a space. Any tutorial telling you to run docker-compose with a hyphen is written for a retired version. This book only ever writes docker compose.

5.5 curl

New word

curl — a program that speaks HTTP from the command line. It is a browser with no window: it opens a connection, sends a request, prints exactly what comes back. Every endpoint you build is tested with it, and How the web actually works takes its output apart line by line.

macOS ships with it. On Ubuntu and WSL2: sudo apt install curl. Check with curl --version. The first line here was:

curl 8.7.1 (x86_64-apple-darwin24.0) libcurl/8.7.1 (SecureTransport) LibreSSL/3.3.6 zlib/1.2.12 nghttp2/1.63.0

Only the first two words matter. Any 7.x or 8.x is fine.

5.6 make and jq

make reads a file called Makefile — a list of named shortcuts, so make run/api runs whatever long command that name was defined as. Chapter 5 writes the first one; about forty later commands go through it. jq filters and pretty-prints JSON; Chapter 19 (Logging that pays rent) uses it to prove why machine-readable logs are worth the trouble.

Platform Command
macOS xcode-select --install for make; brew install jq for jq
Ubuntu / WSL2 sudo apt install make jq

Real first lines from make --version and jq --version here:

GNU Make 3.81
jq-1.6-159-apple-gcff5336-dirty

The word GNU is what matters for make. Any jq 1.6 or newer is fine.

5.7 The verification ritual

Run all of it at once. Come back to this block whenever you suspect your machine rather than your code.

go version
git --version
docker compose version
curl --version
make --version
jq --version
code --version

A passing run, from the machine this chapter was written on, trimmed to each command’s first line:

go version go1.25.6 darwin/arm64
git version 2.39.5 (Apple Git-154)
Docker Compose version v5.1.0
curl 8.7.1 (x86_64-apple-darwin24.0) libcurl/8.7.1 ...
GNU Make 3.81
jq-1.6-159-apple-gcff5336-dirty
1.133.0
Checkpoint

Seven commands, seven version strings, no command not found. Go must be 1.24 or higher. If any line failed, §6 has it.


6. When an install fails

You see It means Fix
command not found: go Go is not on PATH Open a new terminal window. Still failing: check /usr/local/go/bin appears in echo $PATH | tr ':' '\n'
go version go1.21.x ... Too old; this book needs 1.24+ sudo apt remove golang-go, then install from go.dev/dl per §5.3
migrate: command not found, right after a successful go install $(go env GOPATH)/bin is not on PATH The extra step in §5.3 — the highest-frequency first-time blocker in this book
A message containing connect, daemon and docker.sock Docker Desktop is not running Start it and wait for the whale icon to settle. On native Linux, sudo systemctl start docker
permission denied while trying to connect to the Docker daemon (Linux) Your user is not in the docker group sudo usermod -aG docker $USER, then log out and back in
docker-compose: command not found You are following a v1-era tutorial This book always writes docker compose, with a space
Docker Desktop installs, then will not launch on a Mac Wrong chip build uname -m: arm64 needs the Apple Silicon build, x86_64 the Intel one
docker not found inside Ubuntu on Windows WSL integration is off for that distro Docker Desktop → Settings → Resources → WSL Integration → enable Ubuntu
wsl --install fails, or VERSION shows 1 Virtualization is off in BIOS, or the distro is on WSL 1 wsl --set-version Ubuntu 2; failing that, enable virtualization in BIOS/UEFI
Makefile:2: *** missing separator. Stop. A recipe line is indented with spaces, not one Tab Retype the indent as a single Tab (§8)
make: *** No rule to make target '.envrc'. Stop. The Makefile says include .envrc but the file does not exist yet Create .envrc first — Chapter 5 says what goes in it

The last two rows are genuine transcripts from GNU Make 3.81. Newer versions quote the filename slightly differently; match on the words missing separator and No rule to make target.


7. Your first 60 seconds of Go

Before Chapter 1 says a word about architecture, run something.

Note

This is a throwaway module for practice, deliberately not the taskd project — that gets created properly in Chapter 2 (The skeleton: a server that answers). Keeping practice code out of the real repository means your first git status there is clean.

cd ~
mkdir go-practice
cd go-practice
go mod init taskd-practice

Real output from that last command:

go: creating new go.mod: module taskd-practice
New word

module — a named unit of Go code with its own dependency list, described by a file called go.mod. The name doubles as the import prefix, which is why Chapter 2 names the real one github.com/yourname/taskd: code inside it is imported as github.com/yourname/taskd/internal/data.

Now one file (File → Open Folder → go-practice, then a new file hello.go):

// ~/go-practice/hello.go — new file (scratch practice, not part of taskd)
package main

import "fmt"

func main() {
	fmt.Println("hello from Go")
}

Four things are happening, and every Go file in this book has all four:

  1. package main — this file belongs to the package main, the one Go treats as a runnable program rather than a library.
  2. import "fmt" — pull in the standard library’s formatting package. Import something you don’t use and Go refuses to compile, deliberately.
  3. func main() — where the program starts. Exactly one per program.
  4. A tab-indented body. Format-on-save handles that for you.
go run .

Real output:

hello from Go
Checkpoint

hello from Go on your screen means the compiler, the module system and your PATH are working together. You have compiled and executed a program.

go run . means “compile everything in this folder and run it now, discarding the result”. The book’s daily command is go run ./cmd/api, the same idea pointed at a subfolder. Chapter 25 (Docker) switches to go build, which keeps the compiled file instead of throwing it away.

New word

compile — translate the source you wrote into instructions your processor can execute. binary — the resulting file. Go produces one self-contained binary with no runtime to install beside it, which is why Chapter 25 can ship a 15 MB image.


8. How to read this book’s code blocks

1. The path comment is an instruction. Every Go, SQL, YAML and Makefile block opens with a comment naming its file and what to do with it:

// cmd/api/main.go — replaces the whole file

That line is not part of the program. It says: create or open cmd/api/main.go, relative to your project root, and replace its contents. Other variants are — new file and — add this function.

2. In a shell block, # lines are output, not commands. The original book prints both together:

go run ./cmd/api
# time=2026-... level=INFO msg="starting server" addr=:4000 env=development

You type line one; line two is what the computer says back. This edition mostly splits them into two blocks so you can copy commands safely, but you will meet the combined form.

3. Never type a leading $. Some books use it to mark the prompt. It is not part of the command.

4. ... means code was left out. A block containing ... or /* params as before */ is a fragment, not a file. Typing ... into a Go file is a syntax error. Where the original elided something you genuinely need, this edition prints the complete version.

5. Placeholders are yours to substitute. Put your own name in github.com/yourname/taskd, consistently, everywhere. $TOKEN and sk_test_... are values you will have generated by the time you need them.

6. Tabs in makefile blocks are load-bearing. GNU make requires every recipe line to begin with one real Tab. Spaces produce this, which names a real line and explains nothing:

Makefile:2: *** missing separator.  Stop.

That transcript is genuine — it is what a four-space indent produces. If you copy a Makefile out of a PDF, retype the indentation.


9. The edit-run-read-the-error loop

   ┌──────────┐      ┌──────────┐      ┌────────────────┐
   │   edit   │ ───▶ │   run    │ ───▶ │ read the error │
   └──────────┘      └──────────┘      └────────────────┘
        ▲                                       │
        └───────────────────────────────────────┘

Beginners treat the third box as failure and stop. It is the compiler answering a question you asked. Here are the three shapes of answer you will get in the first ten chapters, each produced for real.

Shape 1: a compile error that names your mistake

Replace hello.go with this — a miniature of Chapter 2’s main.go, with one deliberate typo.

// ~/go-practice/hello.go — scratch practice; contains a deliberate typo
package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	logger.Inf("starting server", "addr", ":4000")
}

Run go run .. Real output:

# taskd-practice
./hello.go:10:9: logger.Inf undefined (type *slog.Logger has no field or method Inf)

Every Go compile error has that shape:

Piece Meaning
# taskd-practice which package failed — your module name
./hello.go which file
:10 which line
:9 which column — character 9 on that line, the I of Inf
logger.Inf undefined the claim: no such thing exists
(type *slog.Logger has no field or method Inf) the evidence: Go looked at logger, saw it is a *slog.Logger, searched that type, found no Inf

File, line, column, type, name. Fix Inf to Info, save, run again:

time=2026-08-16T00:28:40.215+05:30 level=INFO msg="starting server" addr=:4000

That is real slog output, and you will read hundreds of lines like it. Note the shape: key=value pairs, not a sentence.

Common mistake

You’ll see: later, logger.Error(err) refusing to compile. It means: slog’s methods take a message string followed by alternating key and value arguments, never an error on its own. Fix: logger.Error(err.Error()), or logger.Error("could not connect", "err", err). This catches everybody once; Chapter 2 writes the first one and about thirty later call sites follow.

Shape 2: a syntax error pointing at the wrong line

Now break the structure. Take a working six-line file and delete the final }:

// ~/go-practice/hello.go — scratch practice; final } deliberately deleted
package main

import "fmt"

func main() {
	fmt.Println("hello from Go")

Real output:

# taskd-practice
./hello.go:7:1: syntax error: unexpected EOF, expected }

The file has six lines. The error is at line 7, which does not exist. EOF means end of file: the compiler read to the very end still waiting for a } that never came, and reported where it ran out of file, not where you erred.

For syntax errors, the line number is where the compiler noticed. Start there and read upward, looking for an unclosed brace, bracket or quote. Click a { and gopls highlights its partner.

A related variant, when code follows the missing brace, reads syntax error: unexpected name helper, expected ( and points at a perfectly correct function two lines below the real problem. Same rule, same cure.

Shape 3: not a Go error at all

Fix the file. Now, without starting any server:

curl -i localhost:4000/v1/healthcheck

Real output:

curl: (7) Failed to connect to localhost port 4000 after 1 ms: Couldn't connect to server

Nothing about Go. Nothing about your code. curl knocked on port 4000 and nobody answered, because no program is listening there. (The wording after curl: (7) varies between curl versions; the (7) and the word “connect” are the signal.)

Nine times in ten, the cause is that the server is not running, or that it is running in a terminal tab you forgot about. Both are found by looking at your other terminal windows before touching the code.

Remember this

Read the error before changing anything. Compile errors name the file, line, column and type. Syntax errors name where the compiler gave up, which may be below the mistake. Connection errors are not code errors at all.

When something breaks, the debugging is the course. Read the error. It’s Go — the error is usually telling the truth.


10. Your learnings/ folder

Every chapter ends with three to five takeaways under For your notes. They exist to be written down by hand, in your own words, because restating an idea is what tells you whether you have it.

mkdir -p ~/taskd-learnings
cd ~/taskd-learnings
git init
Why this exists

Keep it outside taskd for two concrete reasons. Chapter 25’s .dockerignore excludes *.md, so notes inside the project are dead weight in the build. And Chapter 26’s pipeline starts caring about a clean working tree — half-finished notes should not make git status noisy at the moment you are learning to read it.

Note

From here on, every chapter calls this folder learnings/ and names files like learnings/ch08.md. That means this folder, wherever you chose to put it — the book uses the short name because your path is yours. If you accepted ~/taskd-learnings above, then learnings/ch08.md means ~/taskd-learnings/ch08.md. A pre-filled worksheet for every chapter ships with this book in its own learnings/ folder; copy them in if you would rather fill in blanks than face an empty file.

Use the same four headings every time, so 27 files stay searchable:

# ch08 — CRUD done properly

## What now works that didn't before

## The decision, and why (one paragraph)

## The error I hit and what it meant

## The sentence worth memorising

Because they are identical, you can grep the set: grep -A3 "error I hit" ~/taskd-learnings/*.md gives you every mistake you have made and what it turned out to be. Start now, with ~/taskd-learnings/fm02.md.


Common mistakes (and the quick fix)

Install failures are in §6. These are the other four that catch people in their first hour.

Common mistake

You’ll see: go: cannot find main module; see 'go help modules' It means: you ran a go command in a folder with no go.mod above it — almost always the wrong directory. Fix: pwd to see where you are, ls to look for go.mod, cd to the right place.

Common mistake

You’ll see: ./hello.go:5:2: "os" imported and not used and ./hello.go:9:2: declared and not used: name It means: Go refuses to compile code containing an unused import or unused local variable. Both lines above are real output from one run of a nine-line file. Fix: delete the unused line, or use the thing. It feels pedantic on day one and prevents a whole class of stale-code bug forever.

Common mistake

You’ll see: listen tcp :4000: bind: address already in use It means: something is already listening on port 4000 — usually a go run you started earlier and never stopped. Fix: Ctrl-C in the tab still running it. To hunt it down, lsof -i :4000 prints the process and kill <PID> ends it. You will meet this from Chapter 2 onward.

Common mistake

You’ll see: nothing — the terminal sits there with no prompt after you start the server. It means: the program is running and will not return until stopped. Correct behaviour, not a hang. Fix: open a second terminal tab for your curl commands and leave the first one serving.


Check yourself

  1. go run . gives go: cannot find main module. Most likely cause, and the first command you run to check?
  2. command not found: sqlc appears right after go install ...sqlc... reported no errors. Was the install broken?
  3. A compile error says ./cmd/api/main.go:47:12. What are the 47 and the 12?
  4. Your file is 30 lines and the error is ./main.go:31:1: syntax error: unexpected EOF, expected }. Where do you look?
  5. curl -i localhost:4000/v1/healthcheck prints curl: (7) Failed to connect.... Name the two most likely causes.
  6. Why does this book insist on WSL2 for Windows readers rather than PowerShell?
  7. make says *** missing separator. Stop. after you copy a Makefile out of a PDF. Which single character is wrong?
  8. docker compose version succeeds. Does that mean Docker can run a database for you?
Answers
  1. You are in the wrong folder — no go.mod here or in any parent. Run pwd, then ls to look for go.mod. Directory confusion causes more “it doesn’t work” than any language feature.
  2. No. go install writes to $(go env GOPATH)/bin, which is not on PATH by default. The program exists; the shell does not know where to look. Add the export from §5.3, open a new terminal.
  3. Line 47, column 12 — the 12th character on that line. The column tells you which identifier on a long line the compiler objects to.
  4. Upward from line 30, looking for an unclosed {. Line 31 does not exist; EOF means the compiler ran out of file while still waiting to be closed.
  5. No server is running, or it is running in another tab on a different port. Neither is a Go error — nothing about your code is implicated.
  6. The Makefile is GNU-make-only, the shell snippets use POSIX quoting, and all paths use forward slashes. Under WSL2 every command works exactly as printed; under PowerShell you translate on every page.
  7. The indent. GNU make requires one real Tab at the start of each recipe line; spaces produce exactly that error.
  8. No. That proves the client program is installed. Running a database also needs the daemon — Docker Desktop actually open. A working client with a stopped daemon is the commonest Docker confusion in Chapter 5.

FAQ

Can I use Vim, Neovim, GoLand or Zed instead of VS Code?

Yes. Nothing here depends on VS Code. What matters is that your editor runs gofmt on save and shows compile errors as you type, because Chapter 26’s pipeline fails builds over formatting. Most editors get both from gopls. VS Code is recommended as the shortest path to a working setup, not as the better tool.

Do I really need Docker? Can’t I install PostgreSQL directly?

You can, and then you own its upgrades, configuration, startup and eventual removal. Chapter 5 brings up PostgreSQL 17 in one command; Chapter 13 adds DragonflyDB to the same file, Chapter 21 a mail server, Chapter 18 Prometheus. By the end you run four services you never installed. That is what the one-time cost of Docker buys.

This is a lot of setup before writing any code. Is that normal?

Yes, and this is the smallest version of it. A working professional does it once per machine and forgets about it for a year. It feels heavy because you are doing all of it in one sitting while also learning what each piece is for. It does not recur.

Why can’t I use apt install golang? It’s one command.

Distributions freeze package versions for their release cycle, and this book needs Go 1.24+. apt often hands you something a year or more old, and the failure surfaces later as a confusing compile error rather than as “wrong version”. The tarball takes two extra minutes and removes the whole category of problem.

My version numbers are higher than the ones printed here. Is that a problem?

No — higher is fine for everything in the list. These transcripts were produced on Go 1.25, newer than the 1.24 the book pins, and the book’s code is unaffected. Where a version difference genuinely matters, the chapter that cares says so.


Before you start Chapter 1

Tick every line. Each is a command you have actually run, not a thing you believe is true.

  • [ ] I can open a terminal, and pwd tells me where I am.
  • [ ] I know Tab completes names, Up arrow repeats commands, and Ctrl-C stops a running program.
  • [ ] (Windows only) I am typing in an Ubuntu WSL2 window, my project will live under ~, and git config --global core.autocrlf false has been run.
  • [ ] go version prints 1.24 or higher.
  • [ ] echo $PATH | tr ':' '\n' | grep go shows a line ending in /go/bin.
  • [ ] git --version prints a 2.x version.
  • [ ] docker compose version prints v2 or newer, and Docker Desktop is running.
  • [ ] curl --version and jq --version both print a version.
  • [ ] make --version says GNU Make.
  • [ ] My editor is installed, the Go extension is installed, and format-on-save is on.
  • [ ] go run . in ~/go-practice printed hello from Go.
  • [ ] I have deliberately broken that program, read the compile error, and fixed it.
  • [ ] ~/taskd-learnings exists and is a Git repository.

All thirteen ticked means your machine will not be the reason anything fails for the next 27 chapters. Go to Chapter 1 (Introduction: what we’re building and why this shape).


Where we are

You have a terminal you can navigate, seven working tools, a scratch Go module, and a notes repository. You have compiled and run a Go program, broken it on purpose, read the compiler’s answer, and fixed it. Nothing in taskd exists yet — Chapter 2 creates the first folder.

Still ahead in the warm-up: How the web actually works makes curl -i output fully legible, Go in one sitting teaches the language, and SQL and databases in one sitting teaches the database. Those three are reference chapters — read once, return to often. This one you should never need again.

For your notes

Copy these into ~/taskd-learnings/fm02.md, in your own words.

  1. command not found is a PATH problem, never a code problem. The shell searched every folder in PATH and found nothing by that name. Either it is not installed, or it is not where the shell looks.
  2. go install puts binaries in $(go env GOPATH)/bin, which is not on PATH by default. Fixing this before Chapter 5 saves the single most common wasted half-hour in this book.
  3. A Go compile error gives you file, line, column, type and name. Read all five before changing anything. For syntax errors, the line is where the compiler gave up — the mistake is usually above it.
  4. Silence means success. Unix tools print nothing when they work; unexpected text is the signal.
  5. Format-on-save is not cosmetic. Chapter 26’s pipeline fails builds that are not gofmt-clean. Tick the box on day one and the problem never exists.

How the web actually works

By the end of Chapter 2 you will type this and see a reply:

curl -i localhost:4000/v1/healthcheck

Every character in that line, and every line of the reply, is a decision somebody made about how two programs talk. This warm-up explains all of them. When you finish it, that command and its output will contain nothing mysterious — no line you skim past, no number you take on faith.

What you’ll be able to do by the end

  • Explain what a server is, in terms of a process and a port, without hand-waving.
  • Read a raw HTTP request and response byte by byte, and say what each part is for.
  • Predict which status code taskd returns for a given mistake, and why.
  • Read curl -i output line by line, and know which flag produced which part of it.
  • Say what Authorization: Bearer ..., Content-Type, and Idempotency-Key do, and which chapter of this book builds each one.

Time: ~45 minutes reading, ~15 minutes typing. Nothing here requires Go, and nothing here requires taskd to exist yet.

You need before starting: a terminal, curl, and nc. All three are set up in Before you begin. Prove them with curl --version and nc -h.

Tip

If you already run servers for a living, skim sections 1 to 5, read section 7 (taskd’s exact status codes) and section 13 (the end-to-end diagram), and move on. Nothing is hidden in the parts you skip — they are here for people meeting this for the first time.


1. Two computers, one conversation

Two programs want to talk. One of them speaks first and asks for something; the other listens and answers. That is the whole idea, and it has two names.

New word

client — the program that starts the conversation and asks for something. server — the program that waits, receives the question, and sends an answer.

The word server misleads beginners constantly, so let us kill the confusion now: a server is a program, not a machine. People say “the server is down” about a computer in a rack, but in this book “server” always means the running program. Your laptop can run six servers at once. taskd is a server. So is PostgreSQL. So is the thing that hands your browser this page.

New word

process — one running copy of a program, as the operating system sees it. When you type go run ./cmd/api you create a process; when you press Ctrl-C you end it.

What does a server program actually do? Almost nothing, most of the time. It asks the operating system for a port (section 2), and then it blocks — sits there doing nothing, using no CPU — until a connection arrives. When one does, it wakes up, reads the request, works out an answer, writes it back, and goes back to waiting. taskd will do this a few thousand times a second and be bored the whole time.

In Chapter 2 (The skeleton) you write the line that does the waiting:

err := srv.ListenAndServe()

ListenAndServe does not return while the server is healthy. It is the “sit there and wait” instruction. The only reason your program ever gets past that line is that something went wrong, which is why the very next line logs an error and exits.

Run this. Nothing is listening on port 4000 yet, so ask for something and watch the failure:

curl -i http://localhost:4000/v1/healthcheck

You should see:

curl: (7) Failed to connect to localhost port 4000 after 0 ms: Couldn't connect to server
Note

On Linux and WSL2 the same failure usually reads curl: (7) Failed to connect to localhost port 4000 after 0 ms: Connection refused. Same meaning, different wording from the operating system. That (7) is curl’s exit code for “couldn’t connect”.

That message is not a bug. It is the correct answer to “talk to the program on port 4000” when there is no program on port 4000. You will see it perhaps thirty times while working through this book, and every single time it means the same thing: the server isn’t running. Not broken — absent.


2. Addresses, ports, localhost, and DNS

To reach a server you need two numbers.

New word

IP address — the number that identifies one machine on a network, written like 93.184.216.34 (IPv4) or 2606:2800:220:1:248:1893:25c8:1946 (IPv6). It answers which computer. port — a number from 1 to 65535 that identifies one program on that machine. It answers which program on it.

Think of it like

The IP address is the street address of an apartment building. The port is the apartment number. Delivering to the building is not enough; the courier needs the flat.

One special address matters more than all the others while you are learning: 127.0.0.1, the machine you are sitting at. It has a name, localhost, and traffic to it never touches a network card, a router, or the internet. It goes out of your program and straight back into your own machine. Every curl localhost:4000 in this book is your laptop talking to itself.

Here is the port map for this whole book. You will meet all six.

Port Who listens there First appears
4000 taskd itself Chapter 2 (The skeleton)
5432 PostgreSQL, the database Chapter 5 (PostgreSQL and migrations)
6379 DragonflyDB, the cache Chapter 13 (Caching)
1025 Mailpit’s fake SMTP server Chapter 21 (Background work and email)
8025 Mailpit’s web inbox, viewed in a browser Chapter 21 (Background work and email)
9090 Prometheus, scraping metrics Chapter 18 (Prometheus)

Only one program may hold a given port at a time. That rule is the cause of a failure you will meet within a week:

listen tcp :4000: bind: address already in use

It means an older copy of taskd is still running in a terminal tab you forgot about. Find it, Ctrl-C it, start again.

Where DNS comes in

Humans do not remember 104.20.23.154. So there is a directory service that turns names into addresses.

New word

DNS (Domain Name System) — the internet’s phone book. Give it a hostname like api.yourdomain.com and it gives back an IP address. Your machine asks it before every connection to a named host, and caches the answer for a while.

localhost is the exception that proves the rule: it is resolved from a local file (/etc/hosts), never from the network, which is why curl localhost:4000 works with your Wi-Fi off. Chapters 1 through 26 never need real DNS. Chapter 27 (Going live) does, because that is where api.yourdomain.com has to point at a real machine.

Run this. Prove localhost resolves to your own machine:

ping -c 1 localhost

The first two lines you should see:

PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.077 ms

The address in the parentheses is the point. Your time in milliseconds will differ; it is a round trip that never left the building.


3. TCP: a reliable pipe between two programs

An IP address and a port get you to the right program. Something still has to carry the bytes, in order, without losing any. That job belongs to TCP.

New word

TCP (Transmission Control Protocol) — the agreement two programs use to open a connection and stream bytes over it reliably: everything arrives, in the order it was sent, or you get an error. Networks lose and reorder packets constantly; TCP hides that.

Opening a connection takes a short exchange (the “three-way handshake” — three small messages before any of your data moves). That handshake costs a round trip, which is exactly why Chapter 6 (Connecting with pgx) keeps a pool of already-open database connections instead of opening a fresh one per query. Reusing a connection means skipping the handshake.

Two properties of TCP shape everything that follows:

  1. It is a stream of bytes, not messages. TCP delivers POST /v1/tasks HTTP/1.1... as a flow of characters. It has no idea where your request ends. Something on top has to say “this request is 20 bytes of body long, stop reading there” — that something is HTTP.
  2. A connection is a resource that stays open. Each one costs the server a small amount of memory and a file descriptor. That is why Chapter 2 sets IdleTimeout: time.Minute — a connection nobody is using gets closed rather than accumulating until the server runs out.

Run this. Become a program that holds a port. In one terminal:

nc -l 4000

nc (netcat) now owns port 4000 and waits, exactly like a server. In a second terminal:

curl -i --max-time 3 http://localhost:4000/v1/healthcheck

Look at terminal one. It printed the request curl sent. Nothing answers, so curl gives up after three seconds — but the connection was real, and you just watched a request arrive. Press Ctrl-C in terminal one to release the port.


4. HTTP: text, over that pipe — the request

TCP gives you a pipe. HTTP is the language two programs agree to speak through it, and its best-kept secret is that it is plain text you could type by hand.

New word

HTTP (HyperText Transfer Protocol) — the request-and-response format the web runs on. A client sends one text block asking for something; the server sends one text block back. Both have the same four-part shape.

Here is a real request. This is not an illustration: it is what nc captured when curl sent a POST /v1/tasks on my machine, printed exactly as it arrived.

POST /v1/tasks HTTP/1.1
Host: localhost:4000
User-Agent: curl/8.7.1
Accept: */*
Authorization: Bearer FKF6PMGT2WQ3ZKNJ5X7YQ4RVDA
Content-Type: application/json
Content-Length: 20

{"title":"buy milk"}

Four parts, always in this order:

  1. The request linePOST /v1/tasks HTTP/1.1. Three fields separated by spaces: the method (what kind of operation), the path (which resource), the protocol version.
  2. Headers — one per line, Name: value. Facts about the request rather than the request itself.
  3. A blank line — the marker that says “headers finished, body begins”. Not decoration. Without it the server cannot tell where headers stop.
  4. The body — the actual payload, here 20 bytes of JSON. GET requests usually have none.

The bytes underneath

Every line above ends with two invisible characters, not one: a carriage return (\r, hex 0d) and a line feed (\n, hex 0a). Here is the same request as raw bytes, from xxd:

00000000: 504f 5354 202f 7631 2f74 6173 6b73 2048  POST /v1/tasks H
00000010: 5454 502f 312e 310d 0a48 6f73 743a 206c  TTP/1.1..Host: l
00000020: 6f63 616c 686f 7374 3a34 3030 300d 0a55  ocalhost:4000..U
...
000000a0: 6a73 6f6e 0d0a 436f 6e74 656e 742d 4c65  json..Content-Le
000000b0: 6e67 7468 3a20 3230 0d0a 0d0a 7b22 7469  ngth: 20....{"ti
000000c0: 746c 6522 3a22 6275 7920 6d69 6c6b 227d  tle":"buy milk"}

Find 0d0a 0d0a near the end. That is the blank line: two line-endings back to back, headers over, body starting at {. And Content-Length: 20 is how the server knows to read exactly 20 more bytes and then stop — TCP would happily let it wait forever otherwise.

Remember this

An HTTP request is: one request line, some headers, a blank line, an optional body. Everything else in this chapter is detail hanging off that skeleton.

Run this. Capture your own raw request. In terminal one:

nc -l 4000 > raw-request.txt

In terminal two:

curl -s --max-time 3 -X POST http://localhost:4000/v1/tasks \
  -H "Authorization: Bearer FKF6PMGT2WQ3ZKNJ5X7YQ4RVDA" \
  -H "Content-Type: application/json" \
  -d '{"title":"buy milk"}'

Then cat raw-request.txt, and if you want the bytes, xxd raw-request.txt.


5. The response

The reply has the same shape with one difference: instead of a request line it opens with a status line.

This is a real response, captured from the Chapter 2 healthcheck handler:

HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 15 Aug 2026 19:00:14 GMT
Content-Length: 68
Connection: close

{"status":"available","environment":"development","version":"0.1.0"}
  • HTTP/1.1 200 OK — protocol version, a three-digit status code, and a human-readable label. Programs read the number; the words are for people.
  • Content-Type: application/json — “interpret this body as JSON”. Chapter 2’s handler sets this by hand; Chapter 8’s writeJSON helper sets it for every response in the book.
  • Date and Content-Length — added by Go’s HTTP server without you asking.
  • Blank line, then the body.

Order is not negotiable, and it will bite you in Chapter 8: headers, then the status line, then the body. Once the status has been sent, header changes are silently ignored. That is why writeJSON sets every header first and calls w.WriteHeader(status) second.

Run this. Be the server yourself, by hand. In terminal one:

nc -l 4002

In terminal two:

curl -i --max-time 20 http://localhost:4002/v1/healthcheck

Terminal one prints curl’s request and waits. Now type this into terminal one — the blank line matters — and press Enter, then Ctrl-D:

HTTP/1.1 200 OK
Content-Type: application/json

{"status":"available"}

Terminal two prints it as a real HTTP response. You have written a web server with your fingers.

Note

Typed by hand, your line endings are \n rather than \r\n. curl accepts it. Real servers send \r\n, and Go’s server always does.


6. Methods, and the two words that matter

The method is the verb: what kind of operation this is. Six of them appear in this book.

Method Means Safe? Idempotent? taskd example
GET read something yes yes GET /v1/tasks/7
POST create something new no no POST /v1/tasks
PUT replace / set to this state no yes PUT /v1/users/activated
PATCH change part of something no no PATCH /v1/tasks/7
DELETE remove something no yes DELETE /v1/tasks/7
OPTIONS “what am I allowed to do here?” yes yes sent by browsers, Chapter 23
New word

safe — the request does not change anything on the server. A crawler can fire a million GETs and break nothing. idempotent — doing it twice has the same effect as doing it once. Deleting task 7 twice leaves the same world as deleting it once.

These two words are not academic. POST is the only method here that is neither, and that single fact is why Chapter 23 (Hardening the edge) exists. Picture it: a client posts a new task, the network stalls, the client gives up and retries. Did the first one land? Nobody knows, and now there might be two tasks. The fix is the client sending an Idempotency-Key header so the server can recognise the retry and replay its old answer instead of creating a second task.

OPTIONS is the one you never write. Browsers send it on their own before certain cross-site requests, asking permission first. Chapter 23 handles it by mounting CORS before authentication, because a browser’s OPTIONS carries no token and would otherwise be rejected with a 401.

Run this. curl uses GET by default, POST when you pass -d, and whatever you name with -X. Confirm with the capture trick from section 4 — start nc -l 4000 and try:

curl -s --max-time 3 -X DELETE http://localhost:4000/v1/tasks/7

The first word nc prints is DELETE.


7. Status codes

The three-digit number in the status line. Its first digit is the family, and knowing the five families is most of the job.

Family Meaning Rough translation
1xx informational “still working” — you will not meet these
2xx success “done”
3xx redirection “it lives somewhere else” — taskd never sends one
4xx client error you got it wrong”
5xx server error we got it wrong”

The 4xx/5xx split is the one that matters for a career. A 4xx is a bill sent to the caller: fix your request. A 5xx is an apology and a page for whoever is on call. Getting this backwards — returning 500 when a user sent bad JSON — turns your error dashboard into noise.

Here is every code taskd returns, and where it is built:

Code Name taskd returns it when Chapter
200 OK a read succeeded, or an update finished 8 (CRUD done properly)
201 Created a POST made something new (with a Location header) 8
202 Accepted we took the job but haven’t finished it (email is queued) 21 (Background work and email)
204 No Content it worked and there is nothing to say (account deleted) 22 (Password reset)
400 Bad Request the body isn’t valid JSON, or has an unknown field 8
401 Unauthorized no token, or a token we don’t recognise 11 (Stateful tokens)
402 Payment Required your plan doesn’t include this 17 (Entitlements)
403 Forbidden you are known, but your account isn’t activated 21
404 Not Found no such route, or no such task of yours 8, 12 (Ownership)
405 Method Not Allowed the path exists but not with that verb chi handles it
409 Conflict somebody edited this row since you read it 8, 23 (Hardening)
422 Unprocessable Entity valid JSON, invalid data (empty title) 8
429 Too Many Requests you’re over your rate limit 14 (Rate limiting)
500 Internal Server Error our bug, our fault 8
503 Service Unavailable the process is up but the database isn’t 6 (Connecting with pgx)

Two of those will look wrong to you, and both are deliberate:

  • 402 Payment Required was reserved decades ago and left unused. Chapter 17 repurposes it as a sales signal: hitting your plan’s task limit returns 402 with a code and an upgrade URL, which is a far better client experience than a generic 403.
  • 404 for other people’s tasks. Task 7 exists, but it belongs to another user. The honest answer is 403 Forbidden — and Chapter 12 returns 404 instead, on purpose. A 403 confirms the task exists, which lets an attacker map your database by counting responses. 404 tells them nothing.
New word

400 vs 422 — 400 means “I could not read this” (broken JSON, a field I don’t know). 422 means “I read it fine, and it’s wrong” (title empty, priority not one of the four allowed values). taskd keeps this distinction rigorously; most APIs do not.

Run this. Real transcripts from a server speaking taskd’s exact response bytes. -s silences curl’s progress meter; -i prints the headers. A wrong method on a real path:

HTTP/1.1 405 Method Not Allowed
Allow: GET
Date: Sat, 15 Aug 2026 19:00:07 GMT
Content-Length: 0

A validation failure, once Chapter 8 is done:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Content-Length: 39

{"error":{"title":"must be provided"}}

Note the Allow: GET header on the 405. The router adds it without being asked, telling the client which methods would have worked.


8. Headers

A header is a Name: value line carrying a fact about the message. Names are case-insensitive (Content-Type and content-type are the same header), and there can be many of them.

Three headers deserve real understanding now.

Content-Type answers “how should I interpret these bytes?”. A body is bytes; bytes alone do not say whether they are JSON, an image, or a form. taskd sends Content-Type: application/json on every response and expects it on every request body. Chapter 16 (Stripe II) shows what happens when you forget that a body is bytes first and JSON second: Stripe’s signature is computed over the raw bytes, so decoding before verifying destroys the proof.

Authorization answers “who are you?”. taskd uses the Bearer scheme:

Authorization: Bearer FKF6PMGT2WQ3ZKNJ5X7YQ4RVDA
New word

bearer token — a secret string that proves identity by possession alone. Whoever bears it is treated as the user, exactly like a cinema ticket. This is why it travels only over HTTPS in production (section 12) and why Chapter 11 stores only a hash of it, never the token itself.

That 26-character string is not decorative. Chapter 11 generates 16 random bytes and encodes them in base32, which produces exactly 26 characters — and the authentication middleware rejects anything of a different length before it touches the database.

Idempotency-Key answers “is this a retry?”. The client invents a unique string per real attempt and repeats it on retries; the server remembers the response and replays it. Chapter 23 implements this on the two POSTs that hurt to duplicate: creating a task and starting a checkout.

Everything else taskd touches, so none of it ambushes you later:

Header Direction What it does Chapter
Host in which hostname the client asked for always present
Content-Length both how many bytes the body is always present
Location out URL of the thing just created, on a 201 8
WWW-Authenticate: Bearer out “authenticate like this”, on a 401 11
Retry-After out seconds to wait before retrying, on 429 and 409 14, 23
Vary: Authorization out “responses differ per user — caches, do not share them” 11
X-Cache: HIT / MISS out did this answer come from the cache 13
X-Request-ID both one id joining every log line for one request 19
X-Forwarded-For in the real client IP, when a proxy is in front 14, 27
X-Content-Type-Options: nosniff out “never guess a body’s type” 23
X-Frame-Options: DENY out “never render me inside someone’s page” 23
Referrer-Policy out limits what gets leaked when a link is followed 23
Idempotency-Replayed: true out honest admission that this is a replayed answer 23
Stripe-Signature in proof a webhook really came from Stripe 16
Connection: close out “I’m hanging up after this” (set after a panic) 4

Names beginning X- are conventional non-standard headers. There is nothing magic about the prefix; it is a habit that means “this one is ours”.

Run this. Ask any public site for headers only — -I sends a HEAD request, which asks for the response headers without the body:

curl -I https://example.com

You should see a status line and roughly ten headers. The exact set varies by site and by day; what matters is that you recognise the shape.


9. The body, and what JSON is

The body is whatever bytes follow the blank line. taskd’s bodies are always JSON.

New word

JSON (JavaScript Object Notation) — a text format for structured data. It has six kinds of value and nothing else: string ("hi", always double quotes), number (7, 1.5), boolean (true / false), null, array ([1, 2, 3]), and object ({"key": value}).

Rules that catch everyone once:

  • Keys are always quoted strings. {title: "x"} is not JSON; {"title": "x"} is.
  • Double quotes only. 'x' is not a JSON string.
  • No trailing comma after the last element.
  • No comments.

Here is a real response body from a POST /v1/tasks, reformatted for reading:

{
  "task": {
    "id": 7,
    "created_at": "2026-03-14T09:12:41Z",
    "updated_at": "2026-03-14T09:12:41Z",
    "title": "buy milk",
    "notes": "",
    "status": "open",
    "priority": "none",
    "due_at": null,
    "version": 1,
    "user_id": 3
  }
}

Three things to notice, each of which becomes a decision later in the book:

  1. The envelope. The task is wrapped in {"task": ...} rather than sent bare. Chapter 8 argues this out: one map literal buys you a place to add siblings — Chapter 9 adds pagination metadata alongside — without breaking clients that already parse task.
  2. "due_at": null. JSON has a value that means “nothing here”. Chapter 8 leans on the difference between a key set to null and a key absent altogether, because that is how a PATCH distinguishes “clear the due date” from “leave the due date alone”.
  3. "version": 1. Not decoration. Chapter 8’s optimistic locking sends this number back on an update; if it no longer matches, somebody else edited the row first and you get a 409.

How JSON becomes Go

JSON is text. Go values are typed structures. Something must translate, and in Go that something is driven by struct tags — small strings attached to struct fields naming their JSON key. You will meet exactly this in Chapter 8, and it is worth seeing once now:

// internal/db/models.go — this is taskd's real Task type (Chapter 7 generates it)
type Task struct {
	ID        int64      `json:"id"`
	CreatedAt time.Time  `json:"created_at"`
	Title     string     `json:"title"`
	DueAt     *time.Time `json:"due_at"`
	Version   int32      `json:"version"`
}

The tag json:"created_at" says: when turning this into JSON, call the field created_at; when reading JSON, fill it from created_at. The Go field is CreatedAt because Go’s capitalisation rule makes uppercase names visible outside their package, and the JSON world prefers snake_case. Go in one sitting covers structs and tags properly; this is here so the words are not new when you meet them.

Run this. Check whether a piece of JSON is valid before you blame the server:

echo '{"title":"buy milk"}' | jq .

jq reprints it, indented. Now break it on purpose:

echo '{title:"buy milk"}' | jq .

jq reports a parse error. This is exactly the mistake that earns you a 400 from taskd.


10. What an API is, and what REST does and doesn’t mean

New word

API (Application Programming Interface) — an interface built for programs rather than people. Same information a website would show you, delivered as data instead of a page, so other software can use it.

taskd has no web pages. It has endpoints: a method plus a path that together name one operation. GET /v1/tasks/7, POST /v1/tasks, DELETE /v1/tasks/7.

New word

endpoint — one method-and-path combination the server answers, and by extension the code behind it. resource — a thing the API is about, named by a path. /v1/tasks/7 names one task.

Three conventions are visible in every taskd path:

  • Nouns, not verbs. The path names a thing (/v1/tasks); the method says what to do to it. Not /v1/createTask.
  • Plural collections, singular members. /v1/tasks is the collection; /v1/tasks/7 is one member of it.
  • A version prefix. Everything lives under /v1 so that a breaking /v2 can exist one day without stranding old clients. Chapter 2 puts this in place on the first day, which costs nothing then and is nearly impossible to retrofit later.

REST is the name for this style. Honestly stated: in industry, “REST API” means “a JSON API over HTTP with sensible paths and methods”. The original definition is stricter and demands things almost nobody implements. This book uses the loose meaning and does not pretend otherwise. What actually matters is the discipline underneath it: predictable paths, methods that mean what they say, status codes that are true, and the same response shape everywhere.

One more distinction, because it prevents a whole class of confusion later: the JSON you get is a representation, not the row. {"id":7,"title":"buy milk",...} is a rendering of a database row, chosen by us. Chapter 11 shows the point sharply — the Token type has a field that is deliberately never rendered, so the plaintext token can exist in Go and never appear in JSON.

Run this. Read taskd’s whole API surface in one sitting once you reach it — the file is cmd/api/routes.go, and Chapter 24 (Documenting the API) turns it into a browsable page at http://localhost:4000/docs.


11. What curl does

Think of it like

curl is a browser with no window. It resolves a name, opens a TCP connection, writes an HTTP request, prints what comes back. That is the entire program.

The flags this book uses:

Flag What it does
-i print the response headers as well as the body
-s silent: suppress the progress meter (which goes to stderr, not stdout)
-v verbose: show the connection steps and the request curl sent
-d '<data>' send this as the request body — and switch the method to POST
-X <METHOD> force the method (-X PATCH, -X DELETE)
-H "Name: value" add a request header; repeatable
-I send HEAD: response headers only, no body
--max-time <n> give up after n seconds

Two behaviours surprise people. -d implies POST, so curl -d '{"title":"x"}' localhost:4000/v1/tasks is a POST without you saying so — but it sends Content-Type: application/x-www-form-urlencoded unless you pass -H "Content-Type: application/json". taskd’s readJSON will still parse it, and a stricter server would not. Add the header; it is what the book does.

Here is curl -i output split into its parts. Real transcript, from a server writing taskd’s Chapter 8 bytes:

HTTP/1.1 201 Created                                  <- status line
Content-Type: application/json                        <- headers begin
Location: /v1/tasks/7
Referrer-Policy: strict-origin-when-cross-origin
Vary: Authorization
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Date: Sat, 15 Aug 2026 19:01:12 GMT
Content-Length: 192
                                                      <- the blank line
{"task":{"id":7,"created_at":"2026-03-14T09:12:41Z",...

Everything above the blank line came from -i. Without it you get the last line only.

-v shows more: lines starting * are curl narrating, > is what it sent, < is what came back. A real trace, trimmed:

* Host localhost:4000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:4000...
* Connected to localhost (::1) port 4000
> GET /v1/healthcheck HTTP/1.1
> Host: localhost:4000
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Sat, 15 Aug 2026 19:01:16 GMT
< Content-Length: 68
<
{"status":"available","environment":"development","version":"0.1.0"}

Sections 1 through 5 of this chapter are all visible in those fifteen lines: name resolved, connection opened, request text written, response text read.

Run this. Point verbose curl at anything, including nothing:

curl -v --max-time 3 http://localhost:4000/v1/healthcheck

With no server up, you get the resolve lines and then the connection failure — which is itself useful: it proves the name resolved fine and the connection is what failed.


12. What sits in front: proxies, load balancers, and HTTPS

taskd listens on port 4000 and speaks plain HTTP. In production it does not face the internet directly. Something sits in front.

New word

reverse proxy — a server that accepts requests from the outside and forwards them to your real server, then passes the answer back. Clients only ever see the proxy. load balancer — a reverse proxy that spreads requests across several copies of your server.

Chapter 27 (Going live) puts Caddy in that position, with a config the size of a haiku:

api.yourdomain.com {
    reverse_proxy localhost:4000
}

Caddy takes the public traffic on ports 443 and 80, and forwards each request to taskd on 4000. That buys three things at once: HTTPS certificates obtained and renewed automatically, one place to add or remove app instances, and a firewall rule that stops anyone reaching port 4000 directly.

It also creates the trap Chapter 14 warns about. Once Caddy forwards every request, taskd sees Caddy’s address as the client for all of them — one “IP” absorbing the entire rate limit. The fix is the X-Forwarded-For header, which the proxy adds to record the original client, trusted only because Caddy is the sole route to the port.

HTTPS, at a level you can hold

New word

TLS (Transport Layer Security) — a layer that wraps a TCP connection so that the bytes are encrypted and the server’s identity is verified. HTTPS is HTTP inside TLS. Nothing about the request or response format changes; the pipe is now private.

Three things it gives you, in plain terms:

  1. Nobody in between can read the bytes. On plain HTTP, the coffee shop’s Wi-Fi can read your Authorization: Bearer ... header and become you. This is the whole reason bearer tokens require HTTPS.
  2. Nobody in between can change the bytes. Tampering is detected.
  3. You are talking to who you think. The server presents a certificate signed by an authority your machine already trusts, vouching that it really is api.yourdomain.com.

The handshake that sets this up costs an extra round trip or two before any HTTP flows, which is another reason connections are reused rather than reopened.

Run this (needs internet). Watch a TLS handshake happen:

curl -sv -o /dev/null https://example.com

Among the * lines you should see a TLS handshake, Client hello, a Certificate step, an SSL connection using TLSv1.3 line, and a Server certificate: block naming a subject and an expiry date. The exact ciphers and dates depend on the site and the day. That block is your machine checking it is talking to the real example.com.


13. One request, end to end

Everything in this chapter, in one picture. This traces a single POST /v1/tasks — chosen because it is the one request that exercises every idea at once: name resolution, TCP, HTTP, authentication, middleware, SQL, JSON, and a status code with a header attached.

  you type:
    curl -i -H "Authorization: Bearer FKF6PMGT2WQ3ZKNJ5X7YQ4RVDA" \
            -H "Content-Type: application/json" \
            -d '{"title":"buy milk"}' \
            localhost:4000/v1/tasks

  ┌─────────────┐
  │    curl     │  1. resolve the name:  "localhost" ──▶ 127.0.0.1
  │  (client)   │  2. open a TCP connection to 127.0.0.1 : 4000
  └──────┬──────┘  3. write HTTP text down it:
         │              POST /v1/tasks HTTP/1.1
         │              Host: localhost:4000
         │              Authorization: Bearer FKF6PMGT2WQ3ZKNJ5X7YQ4RVDA
         │              Content-Type: application/json
         │              Content-Length: 20
         │              (blank line)
         │              {"title":"buy milk"}
         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │  the taskd process, waiting on port 4000                     │
  │                                                              │
  │  4. http.Server accepts, parses, starts ONE goroutine  ch. 2 │
  │                                                              │
  │  5. middleware rings, outermost first:                       │
  │       secureHeaders ─────────────────────────────────  ch.23 │
  │        └▶ cors ──────────────────────────────────────  ch.23 │
  │            └▶ recoverPanic ──────────────────────────  ch. 4 │
  │                └▶ metrics ───────────────────────────  ch.18 │
  │                    └▶ logRequest ────────────────────  ch.19 │
  │                        └▶ authenticate ──────────────  ch.11 │
  │                            └▶ requireAuthenticated ──  ch.11 │
  │                                └▶ requireActivated ──  ch.21 │
  │                                    └▶ rateLimitUser ─  ch.14 │
  │                                        └▶ idempotent   ch.23 │
  │                                                              │
  │  6. chi matches POST /v1/tasks ──▶ createTaskHandler   ch. 8 │
  │  7. handler: readJSON ▶ validate ▶ quota gate ▶ INSERT  ch.17 │
  │  8. Postgres returns the new row                       ch. 5 │
  │  9. writeJSON: headers, then status line, then body    ch. 8 │
  └──────────────────────────┬───────────────────────────────────┘
                             │ 10. bytes travel back down
                             ▼     the same TCP connection
  ┌──────────────────────────────────────────────────────────────┐
  │  HTTP/1.1 201 Created                                        │
  │  Content-Type: application/json                              │
  │  Location: /v1/tasks/7                                       │
  │  (blank line)                                                │
  │  {"task":{"id":7,"title":"buy milk",...}}                    │
  └──────────────────────────────────────────────────────────────┘

  11. curl prints it and exits. The connection stays open but idle
      for up to IdleTimeout (1 minute), ready to be reused.

Walking the numbers:

  1. Resolve. localhost becomes 127.0.0.1 from a local file. In production this is a real DNS lookup (Chapter 27).
  2. Connect. A TCP handshake opens a reliable pipe to port 4000. If nothing is listening, you get the Failed to connect error from section 1 and steps 3 onward never happen.
  3. Write. curl formats the method, path, headers, blank line, and body as text and sends it.
  4. Accept. Go’s http.Server reads the text, turns it into an *http.Request, and runs the rest of this list in its own goroutine — a lightweight thread, so a slow request blocks nobody else. Chapter 2 sets this up; Go in one sitting explains goroutines.
  5. Middleware. Ten layers wrap the handler like rings of an onion, each one able to answer early. authenticate hashes the bearer token and finds the user. rateLimitUser can end the request right here with a 429. Chapter 4 (A server that dies well) builds the first ring.
  6. Route. chi compares method and path against the registered routes and picks the handler. Wrong method here is what produced the 405 in section 7.
  7. Handle. Decode the JSON (400 on failure), validate it (422 on failure), check the plan’s quota (402 on failure), then insert a row.
  8. Database. Postgres returns the created row, including the id and version it assigned.
  9. Respond. writeJSON sets Location, sets Content-Type, writes status 201, writes body. In that order, because after the status nothing else can be set.
  10. Return. The response text goes back down the same connection that carried the request.
  11. Idle. curl exits. The connection lingers, unused, until IdleTimeout closes it — which is why the next request is faster: no new handshake.

Every chapter of this book adds a ring, a step, or a failure mode to this one picture.


Come back here when

When you are in Re-read section
Chapter 2 (The skeleton) — “curl opened a TCP connection to port 4000” 1, 3, 13
Chapter 6 (pgx) — connection pools and why handshakes cost 3
Chapter 8 (CRUD done properly) — status codes, envelopes, JSON 5, 7, 9
Chapter 9 (Listing at scale) — query strings ?page=2&sort=-created_at 4, 10
Chapter 11 (Stateful tokens) — Authorization, 401, Vary 8
Chapter 14 (Rate limiting) — 429, Retry-After, X-Forwarded-For 7, 8, 12
Chapter 16 (Stripe II) — raw bytes vs parsed JSON 9
Chapter 23 (Hardening the edge) — CORS, idempotency keys, security headers 6, 8
Chapter 27 (Going live) — Caddy, TLS, DNS 2, 12

Common mistakes

Common mistake

You’ll see: curl: (7) Failed to connect to localhost port 4000 after 0 ms: Couldn't connect to server (or ... Connection refused on Linux and WSL2). It means: no program is listening on that port. Usually the server isn’t running, or it crashed on startup, or you’re curling the wrong port. Fix: look at the terminal where you started the server. Read its last line.

Common mistake

You’ll see: listen tcp :4000: bind: address already in use It means: another process already holds port 4000 — nearly always an old go run in a forgotten tab. Fix: find that tab and press Ctrl-C. Failing that, lsof -i :4000 lists the process holding it.

Common mistake

You’ll see: {"error":"json: unknown field \"titel\""} with a 400 Bad Request. It means: the JSON parsed fine, but you sent a key the server doesn’t recognise — a typo, almost always. Chapter 8 rejects unknown fields deliberately, so a typo fails loudly instead of being silently dropped. Fix: correct the key.

Common mistake

You’ll see: {"error":"you must be authenticated to access this resource"} with 401. It means: you sent no Authorization header at all. Fix: add -H "Authorization: Bearer $TOKEN". If instead you see invalid or missing authentication token, the header was present but malformed — check for a missing Bearer prefix, or a token that is not 26 characters.

Common mistake

You’ll see: your shell mangles the JSON, or reports dquote> and hangs. It means: quoting. Single quotes around the JSON, double quotes inside it: -d '{"title":"buy milk"}'. The other way round, the shell eats the quotes and curl sends something that is not JSON. Fix: press Ctrl-C, retype with the quotes that way round.

Common mistake

You’ll see: a two-line progress table above your output with percentages and speeds. It means: nothing is wrong. That is curl’s progress meter, written to stderr. Fix: add -s if it bothers you. Book transcripts omit it.


Check yourself

  1. A server program is running but no request has arrived for ten minutes. What is it doing, and roughly how much CPU is it using?
  2. You run curl localhost:4000/v1/healthcheck and get Failed to connect. Name two different causes, and the one place you would look first.
  3. Which of GET, POST, PUT, DELETE are idempotent? Why does the answer matter to Chapter 23?
  4. A client sends {"title":""} to POST /v1/tasks with a valid token. Which status code comes back, and why is it not 400?
  5. In an HTTP message, what separates the headers from the body, and what happens if it is missing?
  6. Why does taskd return 404 rather than 403 when you ask for a task belonging to another user?
  7. You see HTTP/1.1 200 OK but the body is {"error":"..."}. What has the developer done wrong?
  8. What does Content-Type: application/json actually change about the bytes in the body?
Answers
  1. Blocked, using essentially zero CPU. ListenAndServe hands control to the operating system and asks to be woken when a connection arrives. Waiting is free; a server idling is not a server working.

  2. Most likely: the server isn’t running (or crashed at startup), or it’s listening on a different port than the one you typed. Look first at the terminal where you started the server — the reason is nearly always printed there, for example bind: address already in use.

  3. GET, PUT and DELETE are idempotent; POST is not. It matters because Chapter 23 only needs idempotency keys for the non-idempotent case: a retried POST could create a second task, whereas a retried DELETE of task 7 leaves the same world either way.

  4. 422 Unprocessable Entity. The JSON was well-formed and readable, so the client did not fail at the parsing step — the data itself is invalid. 400 is reserved for bodies the server could not read at all.

  5. A blank line — two line-endings in a row, \r\n\r\n in bytes. Without it the server keeps treating body content as headers and the request is malformed. You saw the 0d0a 0d0a in the hex dump in section 4.

  6. Because 403 confirms the task exists. An attacker could then walk /v1/tasks/1, /2, /3 and learn how many tasks your system holds and which ids are real, purely from the difference between 403 and 404. Chapter 12 chooses to leak nothing.

  7. Lied about the outcome. A 2xx tells every client, cache and monitoring system that the request succeeded. Errors must carry 4xx or 5xx, or nobody downstream can act on them — including your own error-rate alert in Chapter 18.

  8. Nothing at all about the bytes. It changes how the receiver interprets them. The same bytes labelled text/plain would be shown as text rather than parsed as JSON. This bytes-versus-interpretation distinction is exactly what Chapter 16’s Stripe signature verification depends on.


FAQ

Can I skip this and start Chapter 2? You can, and you will get output. But Chapter 2’s closing paragraph — the end-to-end trace that is the mental model for the entire book — is written in this vocabulary. Reading it without this chapter is reading a summary of a film you have not seen.

Why is HTTP plain text? Isn’t that wasteful? Yes, and it was still the right call. Text is inspectable: you can read it with nc, type it by hand, and debug it with your eyes, which is why you could do both of those things in this chapter. HTTP/2 and HTTP/3 are binary and faster, and Go’s server speaks HTTP/1.1 to curl here. The shape is identical either way; only the encoding on the wire changes.

Do I need to memorise all fifteen status codes? No. Memorise the five families and the rule that 4xx blames the caller while 5xx blames you. The table in section 7 is a reference to return to; you will absorb the specific codes as each chapter builds the case for one.

Everyone says “REST API”. Is what we’re building actually REST? By the loose industry meaning, yes. By the original academic definition, no — and almost nothing you have ever used is either. This book uses the term the way working developers use it and does not pretend the strict version is being implemented.

Why does the book run everything on localhost instead of a real server? Because it removes an entire category of problem from your first twenty-six chapters. No DNS, no certificates, no firewalls, no deploy step between writing a line and seeing it work. Chapter 27 adds the real hostname and the certificate, and by then the only new thing is the outside world.

Is it safe to send an Authorization header over plain HTTP on localhost? On localhost, yes — the bytes never leave your machine. Over a network, no. A bearer token is a password in a header; anything that can read the connection becomes that user. That is section 12’s entire point, and it is why Chapter 27 puts Caddy and TLS in front before the service faces the internet.


Where we are

You can now read an HTTP request and response by eye, name what each part does, predict which status code a given mistake earns, and explain what curl -i is showing you. Nothing in the rest of the book will use a networking word this chapter has not defined.

What is still missing, and where it arrives: the Go language (Go in one sitting), SQL and databases (SQL and databases in one sitting), and the actual server that answers these requests (Chapter 2, The skeleton).

For your notes — copy these into learnings/fm03.md in your own words:

  1. A server is a process that holds a port and waits. “Connection refused” means absent, not broken — check the terminal where you started it.
  2. An HTTP message is a first line, headers, a blank line, and a body. The blank line is the only thing separating headers from body, and it is two line-endings back to back.
  3. 4xx blames the caller, 5xx blames the server. 400 means “I couldn’t read it”; 422 means “I read it and it’s wrong”. Getting this backwards makes your error dashboard useless.
  4. Safe means “changes nothing”; idempotent means “twice equals once”. POST is neither, which is the entire reason idempotency keys exist in Chapter 23.
  5. Content-Type does not change the bytes, only how they are interpreted. A body is bytes first and JSON second — a distinction that becomes load-bearing when Chapter 16 verifies a Stripe signature.

Go in one sitting

This is the Go you need to read and write this book’s code — no more, and no less. It is not a tour of the language. Every idea below appears in taskd, most of them within the first eight chapters, and each one is introduced with the same shape you will meet again later, so that when Chapter 8 (CRUD done properly) puts a pointer inside a struct tag inside an anonymous struct, you recognise all three pieces.

Time: about ninety minutes to read, about two hours if you type and run everything. You should type and run everything.

If you already write Go: skim the section headings, read section 4 (Errors are values), section 9 (Functions as values, closures, and interfaces), and the Common mistakes table, then go to Chapter 1. Nothing else here will surprise you.

If you have never programmed at all: read Before you begin first — it installs Go and teaches you the terminal. Then come back. Do not try to memorise this chapter. You are building recognition, not recall. When Chapter 4 (A server that dies well) shows you a channel, you want the reaction “I have seen that arrow before”, not “I know exactly what that does”. The book re-explains every idea at the moment it does real work.

Why this exists

The original edition of this book opened with six paragraphs of Go and assumed the rest. That works if you already write Go for a living. If you don’t, Chapter 2 becomes forty minutes of copying shapes you can’t debug. This chapter exists so that every character you type later is a character you could have written yourself.


Set up a scratch module first

Everything in this chapter runs in a throwaway project. Keep it outside taskd — you will create that in Chapter 2 (The skeleton), and you don’t want practice files in it.

mkdir -p ~/go-practice
cd ~/go-practice
go mod init example.com/go-practice

You should see:

go: creating new go.mod: module example.com/go-practice

Every code block in this chapter names the file to put it in, like this:

// go-practice/hello/main.go — scratch, delete the whole folder when you're done

Create that folder and that file, paste, run. Delete ~/go-practice when you finish the chapter.


The six ideas that repeat on every page

Before the detail, here is the map. These six sentences are the whole book’s Go, compressed. Read them now; they will mean more after each section, and you can come back to this list as a summary.

  1. Structs are labelled boxes; methods are functions attached to them. type config struct { port int } defines a box with a typed slot. A method is a function with a receiver before its name: in func (app *application) serve() error, the (app *application) part means “this function belongs to application values, and inside it, app refers to the one it was called on.” When you later read app.logger.Info(...) inside a handler, that is the whole trick — the handler is a method, so it carries its dependencies with it. → sections 5 and 6

  2. Pointers: & takes an address, * means “pointer to”. app := &application{...} creates one struct and hands back its address; everything holding that pointer shares the same struct rather than copies. That is why the whole app agrees about one logger and one database pool. You rarely need more pointer theory than this: &thing to share it, *Type in a signature to receive a shared one. → section 6

  3. Errors are ordinary values, and if err != nil is the rhythm of Go. Functions that can fail return an error as their last result. You check it immediately, every time: handle it, wrap it with context (fmt.Errorf("loading config: %w", err)), or return it upward. There are no exceptions to catch; the visible chain of if err != nil blocks is the error-handling story, and its repetitiveness is a feature — failure paths are impossible to miss in review. → section 4

  4. defer schedules cleanup for when the function exits. defer pool.Close() runs when the surrounding function returns — no matter which return path is taken, even a panic. It keeps acquisition and release on adjacent lines, which is why you will see it after every resource we open. → section 10

  5. Interfaces describe behaviour; anything with the right methods satisfies them. http.Handler is “anything with a ServeHTTP(w, r) method.” Our router satisfies it, every middleware takes one and returns one, and no type ever declares “I implement Handler” — having the method is the declaration. This is why middleware compose so freely. → section 9

  6. Goroutines and channels: cheap concurrency, typed pipes. go someFunc() runs a function concurrently; ch := make(chan error) makes a pipe; ch <- v sends into it and <-ch blocks until something arrives. The standard library already runs every HTTP request in its own goroutine — which is why shared things need care (Chapter 14’s mutex) and why graceful shutdown (Chapter 4) is a small conversation between two goroutines over a channel. → section 11

Each of these gets re-explained in place the first time it does real work. This chapter means none of them will ambush you.


1. A program, a package, a module

Go is a compiled language. You write text; a program called the compiler turns it into a single executable file of machine instructions — a binary — and that binary is what runs. Nothing has to be installed on the server for it to work. That property is the reason Chapter 25 (Docker) can ship taskd as a 15 MB image with no operating system inside it.

New word

package — a folder of Go files that share a name and are imported together. It is Go’s unit of code organisation, like a chapter of a book: self-contained, referred to by name.

module — one Go project, identified by a name that looks like a web address. That name is also the prefix used to import the project’s own code.

A program starts at the function main in the package main.

// go-practice/hello/main.go — scratch
package main

import "fmt"

func main() {
	fmt.Println("taskd practice: hello")
}
cd ~/go-practice
go run ./hello
taskd practice: hello

Line by line:

  • package main — this file belongs to the package called main. That specific name is what tells Go “this package builds an executable”. taskd’s cmd/api/*.go files all start with package main; internal/validator/validator.go starts with package validator, which builds a library instead.
  • import "fmt" — bring in the standard library’s formatting package. You must import what you use, and you must not import what you don’t (see Common mistakes).
  • func main() — the entry point. Exactly one per program.
  • fmt.Println — the package name, a dot, the function.

go run, go build, go install

Three commands, three different outcomes.

Command What it does Where you meet it
go run ./hello Compiles into a temporary file, runs it, throws the binary away Every chapter: go run ./cmd/api
go build -o bin/api ./cmd/api Compiles and keeps the binary at the path you name Chapter 25 (Docker), the Makefile
go install <url>@latest Downloads someone else’s program, compiles it, drops the binary into $(go env GOPATH)/bin Chapter 5 installs migrate, Chapter 7 installs sqlc
go build -o bin/hello ./hello
ls -l bin/hello
./bin/hello

The binary is around 2.3 MB for a program that prints one line — Go bakes its runtime and garbage collector into every binary. That fixed cost stops mattering the moment your program does anything real.

Warning

go install writes into $(go env GOPATH)/bin, which is not on your PATH by default. If you skipped that step in Before you begin, Chapter 5 will greet you with migrate: command not found. Fix it now, not then.

Imports, modules, and internal/

go.mod names your module. In taskd it says module github.com/yourname/taskd, and that name is the prefix for importing the project’s own packages:

import "github.com/yourname/taskd/internal/validator"

Third-party code arrives with go get, which downloads it and records the exact version in go.mod:

go get github.com/go-chi/chi/v5@latest
go: added github.com/go-chi/chi/v5 v5.3.1

One folder name is special. Anything under a directory called internal/ cannot be imported from outside your own module — the compiler enforces it, it is not a naming convention. Try it from another project and you get:

use of internal package example.com/moda/internal/data not allowed

That is why taskd puts its database code, validator and cache wrapper in internal/: they are this program’s private parts, and the toolchain guarantees nobody else builds against them.

Capital letters are the access control

Go has no public or private keyword. A name starting with a capital letter is visible to other packages; a lowercase name is private to its own package. That rule is load-bearing in Chapter 10 (Users and passwords), where the Password struct keeps plaintext lowercase so it is structurally impossible for another package — or the JSON encoder — to read it.

Tip

Run gofmt -w . in your project, or turn on format-on-save in your editor, from day one. Go has one official formatting style and no arguments about it. Chapter 26 (CI/CD) runs a check that fails your build over a misplaced space, and if you have been formatting all along you will never notice.


2. Values: variables, types, zero values

Every value in Go has a type, fixed when the variable is created, and the compiler checks every use of it. There are two ways to create a variable.

// go-practice/values/main.go — scratch
package main

import "fmt"

const version = "0.1.0" // never changes while the program runs

type config struct {
	port int
	env  string
}

func main() {
	var cfg config // every field gets its zero value
	fmt.Printf("zero config: %+v\n", cfg)

	cfg.port = 4000
	cfg.env = "development"
	addr := fmt.Sprintf(":%d", cfg.port) // := declares and assigns
	fmt.Println(version, addr, cfg.env)
}
go run ./values
zero config: {port:0 env:}
0.1.0 :4000 development
  • var cfg config — declare a variable of type config without giving it a value. It is not garbage and it is not an error: it holds the zero value of every field.
  • addr := fmt.Sprintf(...) — declare and assign in one step, with the type inferred from the right-hand side. := only works inside a function.
  • const version = "0.1.0" — a constant, fixed at compile time. Chapter 2 declares exactly this line; Chapter 25 turns it into a var so the build can stamp the real Git commit into it.
New word

zero value — the value a Go variable has before you set it. 0 for numbers, "" for strings, false for booleans, nil for pointers, slices, maps and errors. There is no “undefined” in Go and no uninitialised memory.

Zero values are a design tool, not an accident. Chapter 9 (Listing at scale) returns Metadata{} — every field zero — to mean “there are no results”, and lets the JSON encoder omit them all. Chapter 13 (Caching with DragonflyDB) treats a nil cache as a legal state meaning “running degraded, no cache today”.

The types this book uses

// go-practice/types/main.go — scratch
package main

import "fmt"

type Tier string // a named type over string: still a string, new identity

const TierPro Tier = "pro"

func main() {
	var pageSize int = 20
	var limit int32 = int32(pageSize) // conversion is always explicit
	var enabled bool                  // zero value: false
	var name string                   // zero value: ""
	var hash []byte                   // zero value: nil

	fmt.Println(limit, enabled, len(name), hash == nil)
	fmt.Printf("%q has %d bytes and %d runes\n", "héllo", len("héllo"), len([]rune("héllo")))
	fmt.Println(TierPro, string(TierPro) == "pro")
}
20 false 0 true
"héllo" has 6 bytes and 5 runes
pro true
Type What it holds Where taskd uses it
string Text, stored as UTF-8 bytes Everywhere
int A whole number, 64-bit on modern machines cfg.port, page numbers
int32 / int64 Whole numbers of a guaranteed size Database columns: f.Limit() returns int32, task IDs are int64
bool true or false cfg.limiter.enabled, user.Activated
float64 A number with a decimal point Chapter 18’s Prometheus histogram buckets
[]byte A list of raw bytes Password hashes, token hashes, JSON before it is written
time.Duration A length of time 5 * time.Second, cfg.db.maxIdleTime

Three things worth memorising:

  1. Go never converts number types for you. int32(pageSize) is a conversion you write by hand. Leave it out and the compiler stops you — see Common mistakes.
  2. A string is bytes, not characters. len("héllo") is 6, because é takes two bytes in UTF-8. []rune(s) converts to a list of characters if you need to count those instead. This matters in Chapter 10: the password rule is “at least 8 bytes long”, and it says bytes on purpose, because bcrypt has a 72-byte ceiling.
  3. A named type is a new type, not an alias. type Tier string gives you a Tier that the compiler will not accept where a plain string is wanted, and vice versa, without an explicit conversion. Chapter 15 (Stripe I) uses it so a plan tier can never be confused with an arbitrary string; Chapter 19 uses type contextKey string so no other package can collide with its context keys.

3. Functions, and returning two things

A function names its parameters with their types, and its results after the parameter list.

// go-practice/funcs/main.go — scratch
package main

import (
	"fmt"
	"strconv"
)

// itoa is the real two-line helper from taskd's cmd/api/helpers.go.
func itoa(i int64) string { return strconv.FormatInt(i, 10) }

// Two results: the value, and whether it was usable. This is the shape
// of almost every function in taskd.
func readIDParam(s string) (int64, error) {
	id, err := strconv.ParseInt(s, 10, 64)
	if err != nil || id < 1 {
		return 0, fmt.Errorf("invalid id parameter: %q", s)
	}
	return id, nil
}

func main() {
	id, err := readIDParam("42")
	fmt.Println(id, err, "/v1/tasks/"+itoa(id))

	_, err = readIDParam("abc")
	fmt.Println(err)
}
42 <nil> /v1/tasks/42
invalid id parameter: "abc"
  • func readIDParam(s string) (int64, error) — one parameter named s of type string, two results. When a function returns more than one value the results go in parentheses.
  • The last result is an error by convention whenever a function can fail. This is not a rule the compiler enforces; it is a convention the entire language follows, and taskd follows it about 150 times.
  • _, err = readIDParam("abc") — the underscore is the blank identifier: “I must accept this value and I am deliberately ignoring it.” You need it because Go refuses to compile a program with an unused variable.
  • <nil> is how fmt prints a nil error. No error happened.

You will meet readIDParam and itoa again as real code in Chapter 8 (CRUD done properly), where itoa builds the Location: /v1/tasks/17 header on a freshly created task.

The format verbs

fmt.Printf and fmt.Sprintf take a template with % placeholders. The book uses six of them.

// go-practice/fmtdemo/main.go — scratch
package main

import "fmt"

func main() {
	fmt.Printf("%d\n", 4000)                     // an integer
	fmt.Printf("%s\n", "development")            // a string, bare
	fmt.Printf("%q\n", "development")            // a string, quoted
	fmt.Printf("%v\n", []string{"open", "done"}) // any value, default form
	fmt.Printf("%+v\n", struct{ Page int }{3})   // structs, with field names
	fmt.Printf("%T\n", int64(7))                 // the type, not the value
	fmt.Printf("100%% cached\n")                 // a literal percent sign
}
4000
development
"development"
[open done]
{Page:3}
int64
100% cached

%q is the one to remember: Chapter 2’s first handler hand-writes JSON with fmt.Fprintf(w, "{\"status\":\"available\",\"environment\":%q}", app.config.env) precisely because %q produces a correctly quoted JSON string. There is a seventh verb, %w, which belongs to errors and is the subject of the next section.


4. Errors are values

This is the section to read twice. Go has no exceptions. A function that can fail returns the failure as an ordinary value, and you deal with it on the next line or you have written a bug.

if err != nil {
	return err
}

That block appears about 150 times in taskd. Its repetitiveness is deliberate: every place the program can fail is visible on the page, in the normal flow of reading, rather than hidden in a jump to a handler somewhere else.

Wrapping with %w

An error from deep in the standard library says open config.toml: no such file or directory. That is true but contextless. fmt.Errorf with the %w verb adds your context while keeping the original error inside, so later code can still ask what it really was.

// go-practice/errs/main.go — scratch
package main

import (
	"errors"
	"fmt"
	"os"
)

func loadConfig(path string) error {
	// os.Open fails; we add context and keep the original with %w.
	_, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("loading %s: %w", path, err)
	}
	return nil
}

func main() {
	err := loadConfig("config.toml")
	fmt.Println(err)

	// errors.Is walks the %w chain looking for one specific value.
	fmt.Println("is not-exist?", errors.Is(err, os.ErrNotExist))

	// errors.As walks it looking for one specific TYPE, and fills the var.
	var pathErr *os.PathError
	if errors.As(err, &pathErr) {
		fmt.Println("failed operation:", pathErr.Op, "on", pathErr.Path)
	}
}
loading config.toml: open config.toml: no such file or directory
is not-exist? true
failed operation: open on config.toml

Chapter 3 (Configuration and logging) contains that exact fmt.Errorf("loading %s: %w", path, err) line.

Remember this

%w wraps, %v only prints. Use %v and the original error is flattened into text — errors.Is and errors.As can no longer find anything, and every caller above you loses the ability to react to the specific failure.

Sentinel errors and errors.Is

A sentinel error is one specific error value, declared once, compared by identity. The standard library and every serious Go package ship them: http.ErrServerClosed, pgx.ErrNoRows, io.EOF, bcrypt.ErrMismatchedHashAndPassword.

// go-practice/sentinel/main.go — scratch
package main

import (
	"errors"
	"fmt"
)

// A sentinel: one specific error value, declared once, compared by identity.
// pgx.ErrNoRows and http.ErrServerClosed are exactly this.
var ErrNoRows = errors.New("no rows in result set")

func getTask(id int64) error {
	if id != 1 {
		return fmt.Errorf("querying task %d: %w", id, ErrNoRows)
	}
	return nil
}

func main() {
	err := getTask(7)
	switch { // a tagless switch: the cases are conditions, not values
	case errors.Is(err, ErrNoRows):
		fmt.Println("404 the requested resource could not be found")
	case err != nil:
		fmt.Println("500 the server encountered a problem")
	default:
		fmt.Println("200 OK")
	}
}
404 the requested resource could not be found

That switch is not an analogy. It is the literal shape of showTaskHandler in Chapter 8: a missing row is a 404 the client caused, anything else is a 500 we caused, and telling them apart is the whole job.

errors.As is the same question about types rather than values, and it takes a pointer to a variable which it fills in on success. Chapter 8’s readJSON uses it four times to work out which way a JSON body was malformed — syntax error, wrong type, body too large — and turn each one into a message a client can act on.

Common mistake

You’ll see: cannot use err (variable of interface type error) as string value in argument to logger.Error It means: slog’s Error takes a message string first, then key/value pairs. It does not take an error. Fix: app.logger.Error("cannot connect to database", "error", err). Standardise on that shape and your production JSON logs always have an error key you can search on. Chapter 3 calls this out as a pitfall for the same reason.


5. Structs, and the methods that hang off them

New word

struct — a named group of related values kept together, each with its own name and type. A labelled box with compartments.

// go-practice/structs/main.go — scratch
package main

import (
	"encoding/json"
	"fmt"
	"time"
)

// The real shape from taskd's internal/data/tokens.go, trimmed.
type Token struct {
	Plaintext string    `json:"token"`
	Hash      []byte    `json:"-"` // "-" means: never appear in JSON
	UserID    int64     `json:"-"`
	Expiry    time.Time `json:"expiry"`
}

func main() {
	t := Token{Plaintext: "FKF6...", Hash: []byte{1, 2}, UserID: 7,
		Expiry: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}

	js, _ := json.Marshal(t)
	fmt.Println(string(js))
	fmt.Printf("%+v\n", struct{ Title, Notes string }{"buy milk", ""})
}
{"token":"FKF6...","expiry":"2026-01-02T03:04:05Z"}
{Title:buy milk Notes:}

Three things are happening.

Struct literals. Token{Plaintext: "FKF6...", ...} builds one, naming the fields. Always name them — the positional form exists but breaks silently the day someone adds a field.

Struct tags. The backtick string after a field’s type is a note for libraries. json:"token" says “call this field token in JSON”; json:"-" says “never put this field in JSON at all”. Those tags on Token are a security mechanism, not a formatting one: in Chapter 11 (Stateful tokens), the plaintext token goes to the client exactly once, and the hash we store is made structurally incapable of appearing in any response.

Anonymous structs. struct{ Title, Notes string }{"buy milk", ""} declares a struct type and makes one, in a single expression, with no name. Chapter 8 uses this for every request body: the shape is written where it is used and nowhere else, so there is no chance of a leftover type drifting away from the endpoint it describes.

Note

Go also supports struct embedding — putting a type inside another type without a field name, so its methods get promoted. taskd never uses it. Do not confuse it with sqlc.embed in Chapter 9 (Listing at scale): that is an instruction to a SQL code generator, not a Go language feature, and the two have nothing to do with each other beyond the word.

Methods and receivers

A method is a function attached to a type. The (f Filters) part before the name is the receiver.

// go-practice/methods/main.go — scratch
package main

import "fmt"

type Filters struct{ Page, PageSize int }

// Value receiver: gets a COPY. Fine — it only reads.
func (f Filters) Offset() int32 { return int32((f.Page - 1) * f.PageSize) }

// Pointer receiver: gets the address. Required — it writes.
func (f *Filters) NextPage() { f.Page++ }

func main() {
	f := Filters{Page: 3, PageSize: 20}
	fmt.Println("offset:", f.Offset())

	f.NextPage() // Go rewrites this as (&f).NextPage()
	fmt.Println("page now:", f.Page, "offset:", f.Offset())
}
offset: 40
page now: 4 offset: 60

Offset is real code — it is how Chapter 9 turns “page 3, 20 per page” into OFFSET 40 for PostgreSQL.

The difference between the two receivers is the single most common source of quiet bugs for people new to Go:

  • Value receiver (f Filters) — the method gets a copy. Changes it makes are thrown away when the method returns. Use it when the method only reads.
  • Pointer receiver (f *Filters) — the method gets the address. Changes it makes are visible to the caller. Use it when the method writes, or when the struct is large enough that copying it is wasteful.

Get it wrong and nothing complains:

// go-practice/valuereceiver/main.go — scratch
package main

import "fmt"

type Filters struct{ Page int }

// The bug: a VALUE receiver mutating its copy. Compiles. Does nothing.
func (f Filters) NextPageBroken() { f.Page++ }

func main() {
	f := Filters{Page: 1}
	f.NextPageBroken()
	fmt.Println("page:", f.Page) // still 1
}
page: 1

Why every handler in this book is a method on *application

This is the pattern the next twenty-five chapters hang off, so it is worth ten minutes now.

A handler needs a logger, a database pool, a cache, a mailer and the config. There are three ways to give it those, and Chapter 2 argues them out: package-level global variables (any code can secretly change them, tests can’t swap them), a dependency-injection framework (machinery for a problem Go does not have), or one struct holding every shared dependency, with every handler written as a method on it.

// go-practice/appstruct/main.go — scratch
package main

import (
	"log/slog"
	"os"
)

// The dependency container, in miniature. Every handler in taskd is a
// method on *application — that is how they all reach the logger.
type application struct {
	logger *slog.Logger
	env    string
}

func (app *application) healthcheck() {
	app.logger.Info("healthcheck", "status", "available", "env", app.env)
}

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
	app := &application{logger: logger, env: "development"} // & = address of
	app.healthcheck()
}
time=2026-08-16T00:34:19.235+05:30 level=INFO msg=healthcheck status=available env=development

(Your timestamp will differ — that is your own clock.)

app.logger inside the method is not magic. app is the receiver; logger is a field on it. Every handler you write from Chapter 2 onward starts func (app *application) for exactly this reason, and it costs one line per handler to buy testability forever: Chapter 20 (Testing what matters) builds a different application with a test database and a logger pointed at io.Discard, and the handlers cannot tell the difference.

Remember this

The receiver is *application, with a star, everywhere in this book. One application exists; every method shares it. If it were (app application) each handler would get a private copy of the database pool, which is either a bug or a disaster depending on the day.


6. Pointers

New word

pointer — the address of a value rather than a copy of it. &x takes the address of x; *T in a type means “address of a T”; *p reads the value at the address p holds. A street address versus a photocopy of the house.

// go-practice/pointers/main.go — scratch
package main

import "fmt"

type Task struct{ Title, Notes string }

func main() {
	a := Task{Title: "original"}
	b := a  // a COPY
	p := &a // a POINTER to the same struct
	b.Title = "copy edited"
	p.Title = "pointer edited" // same as (*p).Title
	fmt.Println(a.Title, "|", b.Title, "|", p.Title)

	var missing *Task // zero value of any pointer is nil
	fmt.Println("missing == nil:", missing == nil)
}
pointer edited | copy edited | pointer edited
missing == nil: true

Editing through p changed a. Editing b did not, because b := a copied the struct. That is the entire idea.

nil is the zero value of a pointer: “this points at nothing”. Reading a field through a nil pointer crashes the program at runtime — see Common mistakes.

Pointer-as-optional, and the PATCH overlay

Here is the second job pointers do in this book, and it is the one that surprises people. A pointer can be nil, and a plain string cannot. So *string can express something string cannot: “the client did not send this field at all”, as distinct from “the client sent an empty string”.

// go-practice/overlay/main.go — scratch
package main

import (
	"encoding/json"
	"fmt"
)

type Task struct{ Title, Notes string }

func main() {
	// Every field is a POINTER, so nil means "the client didn't send it".
	var input struct {
		Title *string `json:"title"`
		Notes *string `json:"notes"`
	}
	json.Unmarshal([]byte(`{"title":"renamed"}`), &input)

	task := Task{Title: "old title", Notes: "keep me"}
	if input.Title != nil {
		task.Title = *input.Title // * reads the value the pointer points at
	}
	if input.Notes != nil {
		task.Notes = *input.Notes
	}
	fmt.Printf("%+v\n", task)
}
{Title:renamed Notes:keep me}

That is Chapter 8’s updateTaskHandler, complete. A PATCH request that mentions only title leaves notes alone, because input.Notes is nil. Without pointers, an absent notes and an "notes": "" would both arrive as "" and the handler would silently wipe the field.

Chapter 8 also documents the honest limitation of this trick: a client cannot set a value back to null, because absent and explicit-null both decode to nil.


7. Slices and maps

Two collection types, and you need both from Chapter 8 onward.

Slices

New word

slice — Go’s growable list of values of one type, written []T. A shopping list you can keep adding lines to.

// go-practice/slices/main.go — scratch
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	statuses := []string{"open", "doing", "done"} // slice literal
	fmt.Println(len(statuses), statuses[0], statuses)

	// make(type, length, capacity): 0 items now, room for 3 without regrowing.
	tasks := make([]string, 0, len(statuses))
	for i, s := range statuses { // range gives index, value
		tasks = append(tasks, fmt.Sprintf("%d:%s", i, s))
	}
	fmt.Println(tasks)

	var nilSlice []string
	empty := []string{}
	a, _ := json.Marshal(nilSlice)
	b, _ := json.Marshal(empty)
	fmt.Println("nil ->", string(a), " empty ->", string(b))
}
3 open [open doing done]
[0:open 1:doing 2:done]
nil -> null  empty -> []
  • len(s) is the count. Indexes start at 0.
  • append(s, v) returns a new slice with v on the end. You must assign the result back — append(s, v) on its own line does nothing useful.
  • make([]T, 0, n) creates a slice with zero items but room for n, so append never has to reallocate. Chapter 9 writes make([]db.Task, 0, len(rows)) for exactly this reason.
  • for i, s := range statuses walks the slice, giving index and value. Use _ for either if you do not need it.

The last two lines are the reason Chapter 9 is fussy about make([]db.Task, 0, ...) rather than var tasks []db.Task: a nil slice encodes as JSON null, and an empty slice encodes as []. A client parsing "tasks": null has to write a special case; a client parsing "tasks": [] does not. One character of Go, a real difference to every consumer of your API.

Maps

New word

map — a lookup table from keys to values, written map[K]V. A phone book: name in, number out.

// go-practice/maps/main.go — scratch
package main

import "fmt"

func main() {
	errs := make(map[string]string) // the validator's Errors map
	errs["title"] = "must be provided"

	// AddError's rule: keep only the FIRST message per field.
	if _, exists := errs["title"]; !exists { // the comma-ok idiom
		errs["title"] = "must not be more than 500 bytes long"
	}
	fmt.Println(errs, len(errs) == 0)

	// Reading a missing key is legal: you get the zero value, not a crash.
	fmt.Printf("missing key -> %q\n", errs["notes"])

	delete(errs, "title")
	fmt.Println("after delete:", errs, "valid:", len(errs) == 0)
}
map[title:must be provided] false
missing key -> ""
after delete: map[] valid: true

That is internal/validator/validator.go from Chapter 8, almost line for line: Errors is a map[string]string from field name to human message, AddError keeps only the first message per field using the comma-ok check, and Valid() is len(v.Errors) == 0.

The comma-ok idiomvalue, ok := m[key] — is how you tell “the key is missing” from “the key is present with the zero value”. You will see the same two-result shape again for type assertions in section 9 and channel receives in section 11.

Common mistake

You’ll see: panic: assignment to entry in nil map It means: you declared a map with var m map[string]string and never created it. The zero value of a map is nil, and reading a nil map is fine but writing to one crashes. Fix: m := make(map[string]string), or a literal m := map[string]string{}.

Bytes, arrays, and the [:] you will meet in Chapter 11

// go-practice/bytes/main.go — scratch
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
)

func main() {
	plaintext := "SIU4EQPHHUB6D5RQ3TPCJTPWLE" // a 26-char taskd token

	// Sum256 returns [32]byte — a fixed-size ARRAY, not a slice.
	hash := sha256.Sum256([]byte(plaintext))

	// hash[:] reslices the array into a []byte, which is what the
	// database column and every function here actually want.
	fmt.Println(len(plaintext), "chars ->", len(hash[:]), "bytes")
	fmt.Println(hex.EncodeToString(hash[:]))
}
26 chars -> 32 bytes
16482678a497a115496d3e25860738ccd6bdd5f299d813ddf28a51b2c14d2db0

An array has a length fixed in its type — [32]byte is a different type from [16]byte. Go’s crypto functions return arrays; almost everything else wants slices; x[:] is the conversion. []byte("some string") goes the other way, from text to bytes. Chapter 11 writes t.Hash = hash[:] on the line after sha256.Sum256, and now you know why.


8. Loops, if, switch, and asking what type something is

Go has exactly one loop keyword, for, in four shapes:

for i := 0; i < 10; i++ { }      // the counting loop
for i, v := range collection { } // over a slice, map, string or channel
for condition { }                // "while"
for { }                          // forever, until you return or break

Chapter 14 (Rate limiting) uses the infinite form for its cleanup goroutine; Chapter 21 uses for range ticker.C to do something once an hour.

// go-practice/switches/main.go — scratch
package main

import "fmt"

func plan(tier string) int {
	switch tier { // expression switch: no fallthrough, no break needed
	case "pro":
		return 10_000
	case "business":
		return -1 // -1 means unlimited
	default:
		return 100
	}
}

func main() {
	for _, t := range []string{"free", "pro", "business"} {
		fmt.Println(t, plan(t))
	}
	// if with an init statement: n exists only inside the if/else.
	if n := plan("pro"); n == -1 {
		fmt.Println("unlimited")
	} else {
		fmt.Println("cap:", n)
	}
}
free 100
pro 10000
business -1
cap: 10000

Two Go-specific details:

  • switch does not fall through. No break needed; each case ends by itself.
  • if can carry an init statement. if n := plan("pro"); n == -1 creates n, then tests it, and n exists only inside that if/else. taskd uses this constantly: if err := app.readJSON(w, r, &input); err != nil {.

You have already seen the tagless switch in section 4 — switch { with conditions as cases. Read it as a tidier if / else if / else. It is the house style for error triage.

Type switches and type assertions

any is a value of any type at all — Go’s escape hatch when the type genuinely varies. taskd uses it in type envelope map[string]any, the wrapper every response body wears. To get a real type back out you assert.

// go-practice/typeswitch/main.go — scratch
package main

import "fmt"

// envelope is taskd's response wrapper: keys to anything at all.
type envelope map[string]any

func describe(v any) string {
	switch x := v.(type) { // type switch: which concrete type is inside?
	case string:
		return "string of " + fmt.Sprint(len(x)) + " chars"
	case map[string]string:
		return fmt.Sprint(len(x), " field errors")
	default:
		return fmt.Sprintf("something else (%T)", x)
	}
}

func main() {
	e := envelope{"error": "rate limit exceeded"}
	fmt.Println(describe(e["error"]))
	fmt.Println(describe(map[string]string{"title": "must be provided"}))
	fmt.Println(describe(402))

	// The comma-ok assertion: ok is false instead of panicking.
	s, ok := e["error"].(int)
	fmt.Println(s, ok)
}
string of 19 chars
1 field errors
something else (int)
0 false

v.(string) on its own would panic if v were not a string. v, ok := v.(string) gives you a boolean instead. Chapter 19 (Logging that pays rent) uses the comma-ok form to pull a request ID out of a context, and Chapter 11 uses the panicking form on purpose — a handler that cannot find its user was wired to the wrong route, which is a programmer error that should be loud.


9. Functions as values, closures, and interfaces

This section contains the hardest single idea in the book. It is worth slowing down for; Chapter 4 (A server that dies well) leans on it, and so does every middleware from Chapter 11 to Chapter 23.

Functions are values

A function can be stored in a variable, passed to another function, and returned from one.

// go-practice/closure/main.go — scratch
package main

import (
	"fmt"
	"strings"
)

func main() {
	// A function value: assign it, pass it, call it later.
	transform := func(s string) string {
		s = strings.TrimPrefix(s, "TASKD_")
		return strings.ReplaceAll(strings.ToLower(s), "__", ".")
	}
	fmt.Println(transform("TASKD_DB__DSN"))
	fmt.Println(transform("TASKD_STRIPE__SECRET_KEY"))

	// A closure: this function CAPTURES prefix from the surrounding scope.
	prefix := "u:42:"
	key := func(name string) string { return prefix + name }
	fmt.Println(key("tasks"), key("ver"))
}
db.dsn
stripe.secret_key
u:42:tasks u:42:ver

transform is Chapter 3’s real config loader function, the one that turns the environment variable TASKD_DB__DSN into the config key db.dsn. It is handed to koanf as a value, and koanf calls it once per environment variable.

New word

closure — a function written inside another function that remembers (“captures”) the variables around it, even after the outer function has returned. A photograph that keeps the scene after everyone has left the room.

Capture is a real hazard, not trivia. Chapter 21 (Background work and transactional email) starts background goroutines with closures and is careful to capture user and token rather than the *http.Request — because the request is gone by the time the goroutine runs.

Interfaces

New word

interface — a list of methods. Any type that has those methods automatically qualifies, with nothing to declare. “Anything that can be plugged into a socket” — the plug shape is the qualification.

This is the part that feels strange coming from other languages: there is no implements keyword. A type satisfies an interface by having the methods, full stop. The compiler works it out.

// go-practice/iface/main.go — scratch
package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

// io.Writer is an interface the standard library declares:
//
//	type Writer interface { Write(p []byte) (n int, err error) }
//
// Anything with that method satisfies it. Nobody says "implements".
func greet(w io.Writer, name string) { fmt.Fprintf(w, "hello %s\n", name) }

func main() {
	greet(os.Stdout, "stdout") // a file satisfies io.Writer
	var b strings.Builder      // so does a string builder
	greet(&b, "builder")
	fmt.Print(b.String())
	fmt.Println(io.Discard != nil) // and so does the bin
}
hello stdout
hello builder
true

Three unrelated types — a file, a string builder, a discard sink — all work, because all three have a Write method. greet never learns which is which.

http.Handler, the interface this whole book is built on

http.Handler is one method:

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

“Anything that can answer an HTTP request.” Your router satisfies it. Every middleware takes one and returns one. And because writing a whole type for every handler would be tedious, the standard library ships an adapter: http.HandlerFunc is a function type with a ServeHTTP method that calls the function. So http.HandlerFunc(myFunc) converts a plain function into a Handler.

// go-practice/handler/main.go — scratch
package main

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

// http.Handler is: type Handler interface { ServeHTTP(ResponseWriter, *Request) }
// http.HandlerFunc converts a plain function into something with that method.
func healthcheck(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	fmt.Fprintf(w, `{"status":"available","version":%q}`, "0.1.0")
}

func main() {
	var h http.Handler = http.HandlerFunc(healthcheck)

	rec := httptest.NewRecorder() // a fake ResponseWriter that records
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/healthcheck", nil))

	fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
	fmt.Println(rec.Body.String())
}
200 application/json
{"status":"available","version":"0.1.0"}

That is Chapter 2’s first handler, running, without a server or a browser. httptest.NewRecorder is a fake ResponseWriter that keeps what was written — the same trick Chapter 20 uses to test the real thing.

Middleware: the pattern that runs the codebase

Put the last two ideas together — functions are values, and http.Handler is an interface — and you get middleware:

A middleware is a function that takes a handler and returns a new handler, which does something before and/or after calling the original.

// go-practice/middleware/main.go — scratch
package main

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

// A middleware: takes a handler, returns a handler that wraps it.
func announce(label string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Println("->", label)
		next.ServeHTTP(w, r) // call through
		fmt.Println("<-", label)
	})
}

func main() {
	var h http.Handler = http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) { fmt.Println("   handler") })

	h = announce("logRequest", h)   // registered second -> inner
	h = announce("recoverPanic", h) // registered last  -> outermost

	h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}
-> recoverPanic
-> logRequest
   handler
<- logRequest
<- recoverPanic

That output is the onion diagram, proven by running it:

request ──▶ recoverPanic ──▶ logRequest ──▶ router ──▶ your handler
response ◀── recoverPanic ◀── logRequest ◀── router ◀──┘

Everything cross-cutting in taskd is this shape: panic recovery and request logging (Chapter 4), authentication (Chapter 11), rate limits (Chapter 14), metrics (Chapter 18), idempotency keys (Chapter 23). Understand these fifteen lines and you have understood a third of the codebase in advance.

Remember this

The middleware registered last wraps the ones registered before it, so it runs first on the way in and last on the way out. That is why panic recovery must be outermost: a panic thrown inside the logging middleware would escape a recovery placed inside it.


10. defer, panic, recover

defer schedules a function call for when the surrounding function returns — by any route, including a panic.

// go-practice/deferdemo/main.go — scratch
package main

import "fmt"

func withLoop() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("deferred", i) // NOT at the end of each iteration
	}
	fmt.Println("loop finished")
}

func main() {
	fmt.Println("open pool")
	defer fmt.Println("close pool") // runs last, whatever happens
	withLoop()
	fmt.Println("main body done")
}
open pool
loop finished
deferred 3
deferred 2
deferred 1
main body done
close pool

Two facts, both visible in that output:

  1. defer is per-function, not per-block. The three deferred calls inside the loop did not run at the end of each iteration; they ran when withLoop returned. Chapter 4 names this as the misconception everyone has once.
  2. Deferred calls run in reverse order, last scheduled first. That is what you want for cleanup: you release in the opposite order you acquired.

taskd uses defer for pool.Close(), cancel() on every context with a timeout, ticker.Stop(), mu.Unlock(), wg.Done(), and the Prometheus in-flight gauge decrement. In each case the acquire and the release sit on adjacent lines, so you can see at a glance that they match.

panic and recover

New word

panic — Go’s “this should be impossible” crash. It unwinds the current goroutine and, if nothing catches it, kills the whole program.

recover — catches a panic inside a deferred function and turns it back into an ordinary value.

Panics are rare in normal Go. You do not use them for expected failures — that is what errors are for. But they happen: a nil pointer dereference, an index out of range, a write to a nil map, or a deliberate panic() for a condition that means the programmer wired something wrong.

Unhandled, one panic in one handler kills the entire server process, taking every other user’s in-flight request with it. So taskd’s outermost middleware catches them.

// go-practice/recoverdemo/main.go — scratch
package main

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

func recoverPanic(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil { // stops the unwinding
				w.Header().Set("Connection", "close")
				http.Error(w, fmt.Sprintf("500: %s", err), 500)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

func main() {
	h := recoverPanic(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) { panic("missing user value") }))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks", nil))
	fmt.Println(rec.Code, rec.Header().Get("Connection"), rec.Body.String())
}
500 close 500: missing user value

A handler panicked; the client got a clean 500 and the process survived. That is Chapter 4’s recoverPanic, with its real logger swapped for http.Error. recover() only works inside a deferred function — which is exactly why the defer func() { ... }() shape exists.


Checkpoint

You can start Chapter 1 after this point.

Sections 1–10 cover every Go construct in Chapters 1, 2 and 3 of the book, and most of Chapter 4. If you are itching to build something, go now. Chapter 4 will send you back for section 11 (goroutines and channels), Chapter 6 for section 12 (context), and Chapter 8 for section 13 (JSON). Each of those chapters re-teaches its own material anyway; the sections below are here so the ideas are not brand new when it does.

Prove you are ready: cd ~/go-practice && go run ./middleware should print the five-line onion.


11. Concurrency: goroutines, channels, mutex, WaitGroup

New word

goroutine — a piece of work running at the same time as the rest of the program, extremely cheap to start. An extra pair of hands you can hire for a second and dismiss.

channel — a typed pipe for passing values between goroutines. Receiving waits until something arrives. A pneumatic tube between two desks.

You are already concurrent

Here is the fact that makes this section non-optional: Go’s HTTP server runs every request in its own goroutine, whether you asked for it or not. You will never write go in a handler, and your handlers still run simultaneously.

// go-practice/pergoroutine/main.go — scratch
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync"
)

func main() {
	// Every handler signals "I'm here", then waits for the other four.
	// If the server served requests one at a time, this would hang forever.
	var arrived sync.WaitGroup
	arrived.Add(5)
	srv := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			arrived.Done()
			arrived.Wait()
			fmt.Fprintln(w, "served")
		}))
	defer srv.Close()

	var clients sync.WaitGroup
	for i := 0; i < 5; i++ {
		clients.Add(1)
		go func() { defer clients.Done(); srv.Client().Get(srv.URL) }()
	}
	clients.Wait()
	fmt.Println("five handlers ran at the same time; nobody wrote `go`")
}
five handlers ran at the same time; nobody wrote `go`

If the server had processed those requests one after another, the first handler would have waited forever for four colleagues who never arrived, and the program would hang. It printed instead. Five handlers were alive at once.

That is the whole justification for the next three sub-sections, and for Chapter 14’s mutex.

Channels

// go-practice/chans/main.go — scratch
package main

import (
	"fmt"
	"time"
)

func main() {
	shutdownError := make(chan error) // an unbuffered typed pipe

	go func() { // a goroutine: runs alongside main, does not block it
		time.Sleep(50 * time.Millisecond)
		fmt.Println("goroutine: draining requests")
		shutdownError <- nil // send; blocks until someone receives
	}()

	fmt.Println("main: serving traffic")
	err := <-shutdownError // receive; blocks until something arrives
	fmt.Println("main: drain result =", err)
}
main: serving traffic
goroutine: draining requests
main: drain result = <nil>

Read the arrows as direction of travel: ch <- v puts v into the pipe, <-ch takes something out. An unbuffered channel is a handshake — the sender waits for a receiver and vice versa. make(chan os.Signal, 1) in Chapter 4 is a buffered channel with room for one value, so the operating system can drop a signal in without waiting for anyone.

This example is Chapter 4’s graceful shutdown in miniature. The real version: the main goroutine sits in srv.ListenAndServe() serving traffic, a second goroutine sleeps until the operating system delivers Ctrl-C or Docker’s stop signal, and the result of the shutdown travels back to main over shutdownError. The channel exists because the answer to “did every in-flight request finish in time?” is computed in one goroutine and needed in the other.

Data races, and the mutex that fixes them

Two goroutines writing the same map at the same time is not a subtle bug. It is a crash.

// go-practice/race/main.go — scratch, this one crashes on purpose
package main

import (
	"fmt"
	"sync"
)

func main() {
	clients := make(map[string]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1) // count one more worker BEFORE starting it
		go func() {
			defer wg.Done() // count it off however this function exits
			clients["1.2.3.4"]++
		}()
	}
	wg.Wait() // block until the counter is back to zero
	fmt.Println("count:", clients["1.2.3.4"])
}
fatal error: concurrent map writes

Run it again with Go’s race detector and you get a diagnosis instead of a corpse:

go run -race ./race
==================
WARNING: DATA RACE
Read at 0x00c0001240f0 by goroutine 10:
  runtime.mapassign_fast64ptr()

The fix is a mutex — a lock only one goroutine can hold at a time.

// go-practice/mutex/main.go — scratch
package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		mu      sync.Mutex
		clients = make(map[string]int)
		wg      sync.WaitGroup
	)
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()         // one goroutine past this line at a time
			defer mu.Unlock() // released however this function exits
			clients["1.2.3.4"]++
		}()
	}
	wg.Wait()
	fmt.Println("count:", clients["1.2.3.4"])
}
count: 100

Both sync types are in that one program, and both are in taskd:

  • sync.Mutex — Chapter 14 (Rate limiting) keeps a map[string]*client of per-IP rate limiters, written by every request goroutine. The mutex is what stops it crashing. Its code comment says: “the lock on the door: Lock() admits one goroutine; everyone else queues at Unlock().”
  • sync.WaitGroup — a counter. Add(1) before starting work, Done() when it finishes, Wait() blocks until the counter is zero. Chapter 21 puts one on the application struct so that Chapter 4’s shutdown can wait for background emails to finish sending before the process exits.
Warning

go test -race is not optional on concurrent code. It catches races that only show up under production load, on a Tuesday, in a stack trace that makes no sense. Chapter 20 wires it into make audit.

select, briefly

select waits on several channels at once and takes whichever is ready first.

// go-practice/selectdemo/main.go — scratch
package main

import (
	"fmt"
	"time"
)

func main() {
	done := make(chan string)
	go func() { time.Sleep(300 * time.Millisecond); done <- "query finished" }()

	select { // wait on several channels; the first one ready wins
	case msg := <-done:
		fmt.Println(msg)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("timed out first")
	}
}
timed out first
Note

taskd never writes a select — not once in the whole codebase. It is here because you will meet it in other people’s Go within a week, and because the next section is easier to explain with it. One paragraph is the right amount of attention to give it for this book.


12. context: deadlines and cancellation

Why this exists

A user hits your API, the request triggers a database query, and the user closes their laptop. The query keeps running, holding a connection from a pool of ten, until it finishes answering a question nobody will read. Do that a few hundred times and your database has no connections left for people who are still listening. context.Context is the mechanism that lets a cancelled request cancel everything it started.

A context.Context is a value carried down through function calls that answers one question: “should I still be doing this?” Every function that talks to the network takes one as its first parameter.

// go-practice/ctx/main.go — scratch
package main

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

func slowQuery(ctx context.Context) error {
	select {
	case <-time.After(2 * time.Second):
		return nil // the query finished
	case <-ctx.Done():
		return ctx.Err() // the deadline (or the client) gave up
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel() // ALWAYS: releases the timer even on the happy path
	start := time.Now()
	fmt.Println(slowQuery(ctx), "after", time.Since(start).Round(50*time.Millisecond))
}
context deadline exceeded after 200ms
  • context.Background() — the empty root context. Use it when nothing above you has one: in main, or in a background worker.
  • context.WithTimeout(parent, d) — a child context that cancels itself after d. It returns the context and a cancel function you must always call, normally with defer cancel(). Chapter 6 (Connecting with pgx/v5) writes exactly this to give the database five seconds to answer at boot or fail the startup.
  • r.Context() — inside a handler, the request’s own context. It is cancelled the moment the client disconnects. taskd passes it into every database call, roughly sixty times, which is why a hung-up client stops costing you a connection.

Values in a context

A context can also carry request-scoped values.

// go-practice/ctxvalue/main.go — scratch
package main

import (
	"context"
	"fmt"
)

// A private key type, so no other package can collide with ours.
type contextKey string

const requestIDKey = contextKey("request_id")

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "9f2c1ab30e77")

	// Coming back out, the value is an `any` — assert it back to a string.
	id, ok := ctx.Value(requestIDKey).(string)
	fmt.Println(id, ok)

	missing, ok := context.Background().Value(requestIDKey).(string)
	fmt.Printf("%q %v\n", missing, ok)
}
9f2c1ab30e77 true
"" false

That is cmd/api/context.go, near enough exactly. The named key type is not decoration: if the key were the plain string "user", any package in the program could overwrite it by accident. A private named type makes collision impossible.

Chapter 11 stores the authenticated user this way, so that middleware can identify a user once and every handler downstream can read it. Chapter 19 stores a request ID the same way, so every log line from one request shares an identifier you can grep for.

Careful

Never use r.Context() in background work. The request context dies the instant the handler returns, so a goroutine that outlives the handler will find its context already cancelled — sometimes after succeeding, which is the worst kind of bug to chase. Chapter 21 builds a fresh context for background work for exactly this reason.


13. JSON, one generic, and tests

encoding/json

JSON is text; a Go struct is not. encoding/json converts in both directions, and struct tags say how the field names map.

// go-practice/jsondemo/main.go — scratch
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

func main() {
	var input struct {
		Title    string `json:"title"`
		Priority string `json:"priority"`
	}

	body := strings.NewReader(`{"title":"buy milk","priority":"high"}`)
	dec := json.NewDecoder(body)
	dec.DisallowUnknownFields() // a typo'd key becomes an error, not silence
	fmt.Println(dec.Decode(&input), input)

	bad := json.NewDecoder(strings.NewReader(`{"titel":"typo"}`))
	bad.DisallowUnknownFields()
	fmt.Println(bad.Decode(&input))

	js, _ := json.Marshal(map[string]any{"task": input})
	fmt.Println(string(js))
}
<nil> {buy milk high}
json: unknown field "titel"
{"task":{"title":"buy milk","priority":"high"}}

Four things you will use in Chapter 8:

  • json.Marshal(v) turns a Go value into []byte of JSON. writeJSON marshals to memory first, so that a half-failed encode can never reach a client who has already been told 200 OK.
  • json.NewDecoder(r).Decode(&v) reads JSON from a stream — a request body — into a Go value. Note the &: the decoder needs the address so it can fill your variable in.
  • DisallowUnknownFields() turns a client’s typo into an error. Without it, {"titel": "..."} decodes cleanly, saves nothing, returns 200, and the client files the bug against you.
  • Only exported (capitalised) fields are encoded or decoded. A lowercase field is invisible to encoding/json — which is how Chapter 10 guarantees a plaintext password can never leak into a response.

The one generic in this book

// go-practice/generics/main.go — scratch
package main

import (
	"fmt"
	"slices"
)

// The book's only generic. [T comparable] = "T is any type you can
// compare with ==". permitted ...T is variadic: zero or more Ts.
func PermittedValue[T comparable](value T, permitted ...T) bool {
	return slices.Contains(permitted, value)
}

var Statuses = []string{"open", "doing", "done"}

func main() {
	fmt.Println(PermittedValue("doing", Statuses...)) // ... spreads the slice
	fmt.Println(PermittedValue("urgent", Statuses...))
	fmt.Println(PermittedValue(402, 200, 201, 404)) // same function, ints
}
true
false
false

PermittedValue is real, unmodified, from internal/validator/validator.go. The square brackets declare a type parameter: one function that works for strings today and integers tomorrow. The ...T is a variadic parameter — any number of arguments — and at the call site Statuses... spreads a slice into them.

This is the only generic function in taskd. Go’s generics can do considerably more; the book does not need it to.

Tests

Chapter 20 (Testing what matters) needs three facts, and here they are with a test you can run.

A test lives in a file ending _test.go, in the same package, in a function called TestSomething taking *testing.T. The Go house style is table-driven: one slice of cases, one loop.

// go-practice/pagination/metadata.go — scratch
package pagination

type Metadata struct {
	CurrentPage, PageSize, FirstPage, LastPage int
	TotalRecords                               int64
}

func CalculateMetadata(total int64, page, pageSize int) Metadata {
	if total == 0 {
		return Metadata{} // the zero value: every field 0
	}
	return Metadata{
		CurrentPage: page, PageSize: pageSize, FirstPage: 1,
		LastPage:     int((total + int64(pageSize) - 1) / int64(pageSize)),
		TotalRecords: total,
	}
}
// go-practice/pagination/metadata_test.go — scratch
package pagination

import "testing"

func TestCalculateMetadata(t *testing.T) {
	tests := []struct { // the table: one anonymous struct per case
		name           string
		total          int64
		page, pageSize int
		wantLast       int
	}{
		{"exact fit", 100, 1, 20, 5},
		{"partial last page", 101, 1, 20, 6},
		{"empty", 0, 1, 20, 0},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) { // a named sub-test per row
			got := CalculateMetadata(tt.total, tt.page, tt.pageSize)
			if got.LastPage != tt.wantLast {
				t.Errorf("LastPage = %d, want %d", got.LastPage, tt.wantLast)
			}
		})
	}
}
go test ./pagination/ -v
=== RUN   TestCalculateMetadata
=== RUN   TestCalculateMetadata/exact_fit
=== RUN   TestCalculateMetadata/partial_last_page
=== RUN   TestCalculateMetadata/empty
--- PASS: TestCalculateMetadata (0.00s)
    --- PASS: TestCalculateMetadata/exact_fit (0.00s)
    --- PASS: TestCalculateMetadata/partial_last_page (0.00s)
    --- PASS: TestCalculateMetadata/empty (0.00s)
PASS
ok  	example.com/go-practice/pagination	0.003s

That is Chapter 20’s first test, verbatim, running against a copy of Chapter 9’s real function. Break a case on purpose — change wantLast for “partial last page” from 6 to 5 — and you get:

--- FAIL: TestCalculateMetadata (0.00s)
    --- FAIL: TestCalculateMetadata/partial_last_page (0.00s)
        metadata_test.go:20: LastPage = 6, want 5
FAIL

The sub-test name tells you which row failed. That is why t.Run is worth the extra line.

The rest of the testing vocabulary, in one table:

Call What it does First used
t.Errorf(...) Record a failure, keep going Chapter 20
t.Fatal(err) Record a failure and stop this test now Chapter 20’s fixtures
t.Helper() “Report failures at my caller’s line, not mine” — put it first in every helper Chapter 20’s assertStatus, doJSON
t.Skip(...) Skip this test (Chapter 20 skips integration tests when no test database is configured) Chapter 20
t.Cleanup(fn) Run fn when the test finishes, however it finishes Chapter 20’s pool teardown
go test ./... Run every test in the module Chapter 20, and Chapter 26’s CI
go test -race ./... The same, with the data-race detector on Chapter 20

Common mistakes

Every error message below is real output from a real Go toolchain. Read the shape: file, line, column, then the complaint. Go’s compiler errors are unusually honest — the hard part is only believing them.

You’ll see It means Fix
"fmt" imported and not used You imported a package and then didn’t use it. Go treats unused imports as an error, not a warning. Delete the import, or use it. An editor with the Go extension does this for you on save.
declared and not used: port You created a variable and never read it. Same policy. Use it, delete it, or assign to _.
logger.Inf undefined (type *slog.Logger has no field or method Inf) A typo in a method name. The message names the exact type it looked at. logger.Info.
cannot use err (variable of interface type error) as string value in argument to logger.Error slog wants a message string, then key/value pairs. Never an error on its own. logger.Error("connecting to database", "error", err)
cannot use page (variable of type int) as int32 value in argument to offset Go never converts number types for you. offset(int32(page))
assignment mismatch: 1 variable but strconv.ParseInt returns 2 values You forgot the error. Almost every standard-library function returns two things. id, err := strconv.ParseInt(...)
syntax error: non-declaration statement outside function body You used := at the top level of a file. It only works inside a function. var version = "0.1.0"
syntax error: unexpected EOF, expected } A missing closing brace. The line number is where Go noticed, not where you erred — usually the end of the file. Run gofmt -w .; the indentation will show you where the block stopped closing.
panic: assignment to entry in nil map You declared a map but never created it. Runtime crash, not a compile error. m := make(map[string]string)
panic: runtime error: invalid memory address or nil pointer dereference You read a field through a pointer that is nil. Check if p != nil before dereferencing, or work out why it was never assigned.
name task not exported by package inner You tried to use a lowercase name from another package. Capitalisation is the access control. Capitalise it in the package that defines it — if it should be public.
use of internal package .../internal/data not allowed Something outside your module tried to import your internal/. The compiler enforces this. Move the package out of internal/, or don’t import it.
fatal error: concurrent map writes Two goroutines wrote the same map at the same time. Not recoverable. A sync.Mutex around every read and write. Then go test -race.
Tip

When a Go error makes no sense, run gofmt -w . first. Half the confusing messages are a brace or a bracket in the wrong place, and the formatter’s re-indentation points straight at it.


Check yourself

Answer before you look. Every question is answerable from this chapter alone.

  1. var cfg config — what is in cfg.port immediately afterwards, and why is that not a bug?
  2. You write func (f Filters) NextPage() { f.Page++ }, call f.NextPage(), and f.Page doesn’t change. What’s wrong?
  3. In Chapter 8’s PATCH handler, why is the input struct’s Title field a *string rather than a string?
  4. What is the difference between fmt.Errorf("loading: %w", err) and fmt.Errorf("loading: %v", err), and which one breaks errors.Is?
  5. This middleware registration order appears in routes.go: r.Use(app.recoverPanic) then r.Use(app.logRequest). Which one sees the request first, and why does that ordering matter for panics?
  6. Chapter 9 writes tasks := make([]db.Task, 0, len(rows)) instead of var tasks []db.Task. A user with no tasks calls GET /v1/tasks. What does the client receive in each case?
  7. You add a sync.Mutex around a map in a handler, but the program still crashes with fatal error: concurrent map writes sometimes. Name two plausible causes.
  8. Why does taskd pass r.Context() into every database call, and what specifically goes wrong in a background goroutine that does the same?
Answers
  1. 0 — the zero value for int. Go has no uninitialised memory: every declared variable starts at its type’s zero value (0, "", false, nil). It is not a bug because it is defined behaviour, and the book uses it deliberately — return Metadata{} in Chapter 9 means “no results” and encodes as an empty JSON object.

  2. The receiver is a value, (f Filters), so the method mutates a copy that is discarded when it returns. It compiles and does nothing. Change it to a pointer receiver, (f *Filters). This is the quietest bug in the language for newcomers, because there is no error at all.

  3. Because a *string can be nil, and a string cannot. nil means “the client didn’t send this field”, which is different from "" meaning “the client sent an empty string”. Without the pointer, a PATCH that mentions only notes would decode title as "" and wipe it.

  4. %w wraps the original error, keeping it retrievable inside the new one. %v flattens it into text. errors.Is and errors.As walk the wrap chain, so %v breaks both — the caller can still print the message but can no longer react to which failure it was.

  5. recoverPanic sees the request first, because middleware registered earlier wraps everything registered after it. That is required: recovery must be outermost so that a panic thrown inside logRequest (or any other middleware) is still caught. Put recovery inside the logger and a panic in the logger escapes and kills the process.

  6. With make([]db.Task, 0, ...) the client gets "tasks": []. With var tasks []db.Task the slice is nil and the client gets "tasks": null. Every client then needs a special case for null, so the empty slice is the kinder API. The capacity argument (len(rows)) is a separate, smaller win: append never has to reallocate.

  7. Two good answers: (a) some code path reads or writes the map without taking the lock — a mutex only helps if every access takes it; (b) you locked a copy of the mutex, for example by holding it in a struct that is passed by value rather than by pointer. A third: you unlock too early, before the map access finishes. go test -race finds all three.

  8. r.Context() is cancelled the instant the client disconnects, so an abandoned request stops occupying a database connection — which matters because the pool is small and shared. In a background goroutine it is exactly wrong: the request context dies when the handler returns, so the “background” work is cancelled immediately — sometimes after it has already succeeded in a test, which makes it look intermittent. Background work builds its own context with context.Background().


FAQ

Do I have to remember all of this before Chapter 1?

No, and trying to will slow you down. You need sections 1–6 solidly: packages, values, functions, errors, structs and methods, pointers. Everything else you need to recognise, not reproduce. The book re-teaches each idea at the moment it does real work, which is when it will actually stick.

Why does Go make me handle an error after literally every call? It’s so much typing.

Because the alternative hides the failure paths. In a language with exceptions you can read a whole function and not know which of its twelve lines can fail or where the failure ends up. In Go the failure paths are the visible half of the code. The cost is real — this book has roughly 150 if err != nil blocks — and the benefit is that no reviewer, including future you, can overlook one. Chapter 8 turns the repetition into something better by funnelling every error through one errorResponse helper.

Isn’t this a strangely small amount of Go? Where are the design patterns?

That is deliberate. taskd is about 4,000 lines of production code and it uses one generic, zero select statements, no iota, no struct embedding, no reflection, no worker pools, no custom error types and no dependency-injection framework. Go rewards boring. The interesting decisions in this book are about databases, money, caches and failure — not about the language.

I already know Python or JavaScript. What will trip me up?

Four things, in order of how often they bite. Types are checked and never converted for youint to int32 is a conversion you write. Value versus pointer — assigning a struct copies it, which has no equivalent in either language. Unused variables and imports are compile errors, not warnings. And capitalisation is the access modifier: there is no private keyword, only a lowercase first letter.

Can I use an AI assistant to write this book’s Go for me?

You can, and you will learn nothing worth having. The specific skill this book builds is reading production code and being able to say why each line is there — including the timeouts, the error paths and the things that look redundant until the day they aren’t. Type the code. Let the assistant explain an error message if you’re stuck; do not let it write the file.

What if I want the whole language, not this subset?

Read A Tour of Go on go.dev after Chapter 8, when the shapes are familiar and the tour feels like revision rather than a firehose. Effective Go and Go by Example are the two other things worth your time. Nothing in them contradicts this chapter.


Where we are

You now have the Go this book uses, in the shapes this book uses it. Concretely, you can:

  • read a Go file top to bottom and say what each declaration is: package, import, type, const, var, func, method;
  • explain why func (app *application) createTaskHandler(...) starts the way it does;
  • tell a value receiver from a pointer receiver and predict which one silently does nothing;
  • read if err != nil { return fmt.Errorf("...: %w", err) } and know what the caller can still do with that error;
  • recognise a middleware on sight, and say which one runs first;
  • run a table-driven test and read its failure output.

Come back here when:

Chapter Section to reread
2 (The skeleton) 1 packages and modules, 5 structs and methods, 9 interfaces
3 (Configuration and logging) 4 errors and %w, 9 closures
4 (A server that dies well) 9 interfaces and middleware, 10 defer/panic/recover, 11 goroutines and channels
6 (Connecting with pgx/v5) 12 context and defer cancel()
8 (CRUD done properly) 4 errors.Is/errors.As, 5 struct tags, 6 pointer-as-optional, 13 JSON
9 (Listing at scale) 7 slices, nil-versus-empty
11 (Stateful tokens) 7 hash[:], 8 type assertions, 12 context values
13 (Caching with DragonflyDB) 8 type assertions, 12 context
14 (Rate limiting) 11 mutex and data races
18 (Prometheus) 9 interfaces, 11 channels (chan<- parameters)
20 (Testing what matters) 13 tests, t.Helper, -race
21 (Background work and email) 9 closures and capture, 11 sync.WaitGroup, 12 context

Delete your scratch module when you are done:

rm -rf ~/go-practice
Checkpoint

Before Chapter 1, confirm the toolchain is real: go version prints go version go1.24 or newer, and go run ./hello from a fresh scratch module prints your line. If either fails, go back to Before you begin.


For your notes

Copy these into learnings/fm04.md, in your own words:

  1. Errors are values, and %w is the difference between an error you can print and an error you can react to. errors.Is compares to a known value; errors.As asks about a type. Both walk the chain that %w builds and %v destroys.

  2. The receiver is the dependency-injection mechanism. func (app *application) handler(...) means the handler carries the logger, config, database pool, cache and mailer with it. One struct, one line per handler, no framework — and testable because Chapter 20 can build a different application.

  3. A middleware is a function that takes a handler and returns a handler. Registered first means outermost, which means it runs first on the way in and last on the way out. Panic recovery goes outermost or it does not work.

  4. Every HTTP request already runs in its own goroutine. You never wrote go, and your handlers still run simultaneously — which is why shared state needs a mutex and why go test -race is part of the build.

  5. nil is a legal, useful value. A nil pointer means “absent” in a PATCH body; a nil slice encodes as JSON null where an empty one encodes as []; a nil cache means “running degraded”. Knowing which nils are deliberate is most of reading this codebase.

SQL and databases in one sitting

The original edition of this book asked you to arrive with “comfort with basic SQL”. This warm-up supplies that comfort, and nothing beyond it.

Everything here is taught with taskd’s own tables — the real tasks, users, tokens and subscriptions you will build in Chapters 5 through 22. There are no employees tables, no departments, no invented examples. By the time you finish you will have typed most of Appendix C’s final schema by hand, which means Chapter 5 (PostgreSQL and migrations) will feel like recognition rather than instruction.

Time: about 50 minutes reading, 30 minutes typing.

What you’ll be able to do by the end

  • read every .sql file in this book and say what it does, line by line;
  • start a throwaway PostgreSQL, connect to it, and inspect its tables;
  • write INSERT, SELECT, UPDATE and DELETE with RETURNING, filters, ordering and paging;
  • explain what a primary key, a foreign key, NULL, an index, a transaction and a connection pool are, and why taskd has each one;
  • recognise the five constraint errors Postgres will throw at you and fix them.
Tip

If you already write SQL, skim the headings, read Transactions (this book never opens one, and that is deliberate), Indexes, and the schema diagram at the end. Twelve minutes.

You need before starting: Docker running. Check with docker ps — a table header, even an empty one, means the daemon is alive. If it prints Cannot connect to the Docker daemon, go back to Before you begin.


1. What a database is, and why not a file

Your program has data that must outlive it: tasks, users, tokens. The obvious first idea is a file — write the tasks out as JSON when the program stops, read them back when it starts.

That works for exactly one user on one machine doing one thing at a time. taskd is a web server, and web servers do many things at once. Chapter 4 (The lifecycle) puts every HTTP request in its own goroutine, so at 3pm on a Tuesday you might have forty requests inside your program simultaneously. Now the file idea breaks in four places:

The problem What a file does What a database does
Two requests write at once One overwrites the other’s changes, silently Serialises the writes; nobody loses
The power dies mid-write Half a file; unreadable The change either happened or it didn’t
“Find the open tasks for user 42” Read all 50,000, filter in Go Answers from an index in under a millisecond
“This must never be empty” Hope every code path remembers The rule is stored with the data and cannot be bypassed

A database is a program whose entire job is holding data safely while many clients read and write it concurrently. You do not open its files. You connect to it over the network and ask it questions in a language it understands. For us, that program is PostgreSQL and that language is SQL.

New word

SQL — “Structured Query Language”. You describe what you want (“the open tasks belonging to user 42, newest first, twenty of them”); the database decides how to get it. You almost never tell it to loop over anything.

New word

relational database — one that stores data as tables and lets you state relationships between them (“every task belongs to a user”) as rules the database enforces. The word “relational” is about those enforced links, not about tables being related in a vague sense.

Run this

Start a throwaway Postgres. It is deliberately not the one Chapter 5 builds — that one arrives with Docker Compose and a Makefile, and this one gets deleted at the end of this warm-up.

docker run -d --name sql-primer \
  -e POSTGRES_USER=taskd \
  -e POSTGRES_PASSWORD=pa55word \
  -e POSTGRES_DB=taskd \
  postgres:17-alpine

It prints a long hexadecimal string — the container’s ID. Wait five seconds, then check:

docker exec sql-primer pg_isready -U taskd -d taskd
/var/run/postgresql:5432 - accepting connections

Note what is missing from the docker run: a -p flag. We are not publishing a port to your laptop, because we will talk to this database from inside its own container. That also means it cannot collide with the Postgres Chapter 5 starts on port 5432.


2. Getting a prompt: psql

psql is the official command-line client for Postgres. You type SQL, it prints tables. It is the tool the book’s make db/psql target runs for you from Chapter 5 onward.

docker exec -it sql-primer psql -U taskd -d taskd

-U taskd is the user to log in as, -d taskd is the database to open. You get a banner with the server version, Type "help" for help., and then a prompt that names the database you are in:

taskd=#

Four things to know and then we can start:

You type What happens
SELECT 1; SQL. The semicolon is what sends it. Without one, psql waits for more
\dt psql’s own command (backslash, not SQL): describe tables
\d tasks describe one table: its columns, types, defaults, constraints, indexes
\q quit
Common mistake

You’ll see: the prompt changed from taskd=# to taskd-# and nothing you type does anything. It means: you forgot the semicolon; psql thinks your statement is unfinished. Fix: type ; and press Enter.

Run this

At the taskd=# prompt:

SELECT 1;

You should get a one-row, one-column table. Then \dt — which reports Did not find any relations., because the database is empty. We will fix that next.

Tip

Everything from here on is typed at the taskd=# prompt unless the block says bash. Leave that psql session open for the rest of this warm-up.


3. Tables, rows, columns — and taskd’s first table

A table is a grid with a fixed set of named columns and any number of rows. Each column has a type, and the database refuses values of the wrong type. A row is one thing: one task, one user, one token.

Here is taskd’s first table, exactly as Chapter 5 (PostgreSQL and migrations) creates it. Type it in. The comments are the book’s own.

-- migrations/000001_create_tasks.up.sql — the real thing, type it now
CREATE TABLE tasks (
    -- IDENTITY: Postgres assigns 1, 2, 3... itself; GENERATED ALWAYS means
    -- clients can't insert their own ids (a whole bug class, deleted).
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- timestamptz = timestamp WITH time zone. now() stamps creation for free.
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now(),

    title       text        NOT NULL,
    notes       text        NOT NULL DEFAULT '',   -- empty string, never NULL

    -- The CHECK constraint makes invalid states unstorable: the database
    -- itself rejects status='banana', no matter which code path tries.
    status      text        NOT NULL DEFAULT 'open'
                CHECK (status IN ('open','done','archived')),
    priority    text        NOT NULL DEFAULT 'none'
                CHECK (priority IN ('none','low','medium','high')),

    due_at      timestamptz,                       -- the ONE honest NULL

    -- Optimistic-locking counter; ch. 8 explains the dance it powers.
    version     integer     NOT NULL DEFAULT 1
);

Postgres answers CREATE TABLE. That is the whole confirmation — SQL statements report what they did, not that they succeeded at doing it.

Run this

\dt
       List of relations
 Schema | Name  | Type  | Owner 
--------+-------+-------+-------
 public | tasks | table | taskd
(1 row)
\d tasks
                                    Table "public.tasks"
   Column   |           Type           | Collation | Nullable |           Default            
------------+--------------------------+-----------+----------+------------------------------
 id         | bigint                   |           | not null | generated always as identity
 created_at | timestamp with time zone |           | not null | now()
 updated_at | timestamp with time zone |           | not null | now()
 title      | text                     |           | not null | 
 notes      | text                     |           | not null | ''::text
 status     | text                     |           | not null | 'open'::text
 priority   | text                     |           | not null | 'none'::text
 due_at     | timestamp with time zone |           |          | 
 version    | integer                  |           | not null | 1
Indexes:
    "tasks_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "tasks_priority_check" CHECK (priority = ANY (ARRAY['none'::text, 'low'::text, 'medium'::text, 'high'::text]))
    "tasks_status_check" CHECK (status = ANY (ARRAY['open'::text, 'done'::text, 'archived'::text]))

\d tasks is the single most useful command in this warm-up. Note that Postgres has rewritten your CHECK (status IN (...)) into status = ANY (ARRAY[...]), and expanded timestamptz into its formal name. What you get back is the database’s own understanding of your table, which is not always the words you typed.


4. The column types this book uses

Postgres ships dozens of types. taskd uses seven. Here they are, with where each first appears.

Type Holds Where taskd uses it
bigint a whole number up to about 9.2 quintillion every id; user_id
integer a whole number up to about 2.1 billion version counters
text a string of any length title, notes, status, priority, tier
citext text that compares case-insensitively users.email, users.pending_email (Ch. 10)
bytea raw bytes, not characters users.password_hash, tokens.hash (Ch. 10, 11)
boolean true or false users.activated (Ch. 10)
timestamptz an instant in time, with time zone every created_at, expiry, due_at

Four notes that will save you later:

bigint GENERATED ALWAYS AS IDENTITY is how taskd makes ids. Postgres hands out 1, 2, 3… GENERATED ALWAYS means a client cannot supply its own id — that whole class of bug is deleted at the schema level. You will see bigserial in other people’s SQL; it is the older spelling of the same idea, and this book never uses it.

Always timestamptz, never timestamp. Plain timestamp stores a wall-clock reading with no time zone attached, which will eventually corrupt someone’s Tuesday. Chapter 5 lists this as a pitfall; obey it everywhere.

citext is a Postgres extension, not a built-in — an optional add-on you switch on with CREATE EXTENSION IF NOT EXISTS citext;. Chapter 10 (Users and passwords) uses it so that Bob@example.com and bob@example.com are the same email to the database itself, not just to whichever Go function remembered to lowercase.

bytea is bytes, not text. A bcrypt hash and a SHA-256 hash are byte sequences that are not valid text in any encoding. Storing them in a text column corrupts them. You will meet this again in Chapter 11 (Stateful tokens), where tokens.hash is 32 raw bytes.

Note

taskd uses no jsonb, no uuid, no ENUM and no numeric. The ENUM omission is a decision, not an oversight: Chapter 5 argues that enums “are cute until you need to remove a value; check constraints alter in one statement”.

Run this

SELECT
    pg_typeof(1::bigint)      AS a,
    pg_typeof('x'::text)      AS b,
    pg_typeof(now())          AS c;

pg_typeof reports the type of a value, and :: is Postgres’ cast operator (“treat this as a bigint”). You will see bigint, text and timestamp with time zone.


5. NULL is not zero, and not “”

NULL means there is no value here — not zero, not empty string, not false. In taskd’s tasks table exactly one column is nullable: due_at. A task with due_at = NULL has no due date. A task with notes = '' has notes, and they are empty. Those are different claims and the schema keeps them different.

Chapter 5 states the rule as NOT NULL everywhere possible”, and the reason is Go, not purity: every nullable column becomes a pointer or a pgtype wrapper in sqlc’s generated code (Chapter 7) and an if statement in every consumer. notes defaults to '' precisely so that no Go code ever has to ask whether notes exist.

The part that catches everybody: NULL is not equal to anything, including NULL. Comparing with = gives you neither true nor false — it gives you NULL, which is not true, so the row is not returned.

Run this

We need rows first. Insert them (we will unpack INSERT properly in section 8):

INSERT INTO tasks (title, notes, status, priority, due_at) VALUES
  ('write the SQL primer', '',             'open',     'high',   '2026-09-01 09:00:00+00'),
  ('buy milk',             'semi-skimmed', 'open',     'low',    NULL),
  ('file the tax return',  '',             'open',     'high',   '2026-09-30 00:00:00+00'),
  ('renew passport',       '',             'done',     'medium', NULL),
  ('water the plants',     '',             'open',     'none',   NULL),
  ('cancel the gym',       '',             'archived', 'low',    NULL);

Now the trap and its fix:

SELECT id, title FROM tasks WHERE due_at = NULL;
SELECT id, title FROM tasks WHERE due_at IS NULL;
 id | title 
----+-------
(0 rows)

 id |      title       
----+------------------
  2 | buy milk
  4 | renew passport
  5 | water the plants
  6 | cancel the gym
(4 rows)

The first query is not wrong syntax — it is silently wrong, which is worse. Always IS NULL and IS NOT NULL. You will meet IS NOT NULL doing real work in Chapter 22 (Password reset and the account lifecycle), where ConfirmPendingEmail updates a user only WHERE id = $1 AND pending_email IS NOT NULL.

Remember this

NULL means unknown. Unknown is not equal to unknown. Use IS NULL, never = NULL.


6. Primary keys

A primary key is the column whose value identifies a row uniquely and forever. Postgres enforces two things about it: no two rows may share one, and it may never be NULL. It also builds an index on it automatically — that "tasks_pkey" PRIMARY KEY, btree (id) line you saw in \d tasks.

taskd’s choice is argued out loud in Chapter 5: bigint GENERATED ALWAYS AS IDENTITY, not UUID. Integers are half the size, index-friendly, and human-typeable in a debugger. The usual counter-argument — sequential ids let outsiders guess other people’s ids — is real, and taskd answers it with authorization rather than obfuscation: from Chapter 12 (Ownership: making it multi-tenant) onward every single query is scoped to the requesting user, so guessing an id gets you a 404.

Not every primary key is a number. tokens.hash is a bytea primary key — the hash is the identity of the token. And subscriptions.user_id is a primary key that is also a foreign key, which is how Chapter 15 (Stripe I) says “one subscription per user” in the schema instead of in a comment.

Run this

INSERT INTO tasks (id, title) VALUES (99, 'I choose my own id');
ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.

That refusal is the point of GENERATED ALWAYS. Ignore the HINT; taskd never wants that.


7. Constraints: the rules that live with the data

A constraint is a rule the database enforces on every write, from every code path, forever. This is the difference between validation in your application (which the next endpoint someone adds might skip) and validation in the schema (which nothing can skip).

taskd uses four kinds:

Constraint Says Example in taskd
NOT NULL this column always has a value title text NOT NULL
DEFAULT if you don’t supply one, use this created_at ... DEFAULT now()
CHECK the value must satisfy this test CHECK (status IN ('open','done','archived'))
UNIQUE no two rows may share this value email citext NOT NULL UNIQUE

DEFAULT is applied at insert time, to rows being inserted. It does not reach back and change existing rows — which is exactly why Chapter 21 (Background work and transactional email) can run ALTER TABLE users ALTER COLUMN activated SET DEFAULT false and leave every existing account activated. Old users are grandfathered; new ones must confirm their email.

Run this

INSERT INTO tasks (title, status) VALUES ('learn SQL', 'banana');
ERROR:  new row for relation "tasks" violates check constraint "tasks_status_check"
DETAIL:  Failing row contains (7, 2026-08-15 19:03:45.319321+00, 2026-08-15 19:03:45.319321+00, learn SQL, , banana, none, null, 1).
INSERT INTO tasks (notes) VALUES ('no title here');
ERROR:  null value in column "title" of relation "tasks" violates not-null constraint
DETAIL:  Failing row contains (8, 2026-08-15 19:03:45.393303+00, 2026-08-15 19:03:45.393303+00, null, no title here, open, none, null, 1).

Read the shape of those errors: which constraint, on which table, and the entire failing row so you can see what you actually sent. Note the ids 7 and 8 in the failing rows — the identity counter moved even though the inserts failed. Gaps in id sequences are normal and mean nothing.

Why this exists

Chapter 10 turns a constraint violation into a polite HTTP 422. When two people register the same email at the same instant, checking “does this email exist?” first and then inserting leaves a window where both checks pass. The UNIQUE constraint has no window: exactly one insert wins and the other gets error code 23505, which the handler translates. Let the database be the arbiter.


8. Putting rows in: INSERT and RETURNING

INSERT INTO tasks (title, notes, priority, due_at)
VALUES ('write the SQL primer', '', 'high', '2026-09-01 09:00:00+00')
RETURNING *;

Read it as three parts: which table and which columns you are filling; the values, in the same order; and what you want handed back.

Columns you leave out get their DEFAULT (or NULL if there is no default and the column allows it). That is why the insert above never mentions id, created_at, updated_at, status or version — the schema fills all five.

RETURNING is the piece to internalise. In most databases, inserting and then finding out what id you got takes a second query. Postgres lets the insert hand the finished row straight back, in the same round trip, from the same snapshot. taskd uses it everywhere: Chapter 7’s very first query is INSERT INTO tasks ... RETURNING *, and Chapter 8’s UpdateTask ends with RETURNING * too.

RETURNING * means every column. RETURNING id, created_at, name, email, activated, version means those six — and Chapter 10 uses exactly that list on CreateUser so that the handler physically cannot leak password_hash, even by accident.

Run this

psql’s \x on switches to one-field-per-line display, which is easier to read for a wide row.

\x on
INSERT INTO tasks (title, notes, priority, due_at)
VALUES ('another one', 'with RETURNING', 'medium', NULL)
RETURNING *;
\x off
Expanded display is on.
-[ RECORD 1 ]----------------------------
id         | 1
created_at | 2026-08-15 19:03:45.20259+00
updated_at | 2026-08-15 19:03:45.20259+00
title      | write the SQL primer
notes      | 
status     | open
priority   | high
due_at     | 2026-09-01 09:00:00+00
version    | 1

INSERT 0 1
Expanded display is off.

Your id, created_at and updated_at will differ from the ones above — the transcript is from one real run, and both the clock and the id counter have moved since. INSERT 0 1 is Postgres’ summary line: one row inserted.

Note

There is a fifth verb this book uses once: INSERT ... ON CONFLICT, sometimes called an upsert. Chapter 15 writes INSERT INTO subscriptions ... ON CONFLICT (user_id) DO UPDATE SET ... (“insert, or if a row with this user_id already exists, update it instead”) and INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING (“insert, or shrug”). You do not need it today. You need to not be surprised by it in Chapter 15.


9. Getting rows out: SELECT, WHERE, ORDER BY, LIMIT

SELECT has a fixed skeleton, and taskd’s biggest query — the one in Chapter 9 — is this skeleton with the blanks filled in:

  SELECT   which columns
  FROM     which table
  WHERE    which rows          (filter)
  ORDER BY in what order       (sort)
  LIMIT    how many            (page size)
  OFFSET   skipping how many   (page number)

WHERE takes a condition. The operators taskd uses: =, <> (not equal), >, <, IS NULL, IS NOT NULL, AND, OR, and ILIKE (case-insensitive pattern match, where % means “any run of characters”). Chapter 9’s search filter is title ILIKE '%' || search || '%', and || is string concatenation, so a search for tax becomes the pattern %tax%.

ORDER BY takes columns and a direction, ASC (default) or DESC. Rows containing NULL sort last only if you say NULLS LAST — worth knowing because Chapter 9 sorts by due_at, and tasks without a due date should not lead the list.

LIMIT and OFFSET are how pagination works: page 3 with 20 per page is LIMIT 20 OFFSET 40. Chapter 9 computes exactly that in Go: func (f Filters) Offset() int32 { return int32((f.Page - 1) * f.PageSize) }.

One rule that looks like decoration and is not: always end ORDER BY with a tiebreaker on a unique column. Chapter 9’s query ends ..., id ASC. Without it, rows that tie on the main sort key can come back in a different order on each query, and a row can appear on page 1 and page 2. Users report that as “sometimes an item shows up twice”, and you chase it for a week.

Run this

SELECT id, title, priority FROM tasks WHERE status = 'open';
 id |        title         | priority 
----+----------------------+----------
  1 | write the SQL primer | high
  2 | buy milk             | low
  3 | file the tax return  | high
  5 | water the plants     | none
(4 rows)
SELECT id, title FROM tasks
WHERE status = 'open' AND title ILIKE '%TAX%';
 id |        title        
----+---------------------
  3 | file the tax return
(1 row)

Note the capitals in '%TAX%' matching lowercase tax — that is the I in ILIKE.

SELECT id, title, due_at FROM tasks ORDER BY due_at ASC NULLS LAST, id ASC;
 id |        title         |         due_at         
----+----------------------+------------------------
  1 | write the SQL primer | 2026-09-01 09:00:00+00
  3 | file the tax return  | 2026-09-30 00:00:00+00
  2 | buy milk             | 
  4 | renew passport       | 
  5 | water the plants     | 
  6 | cancel the gym       | 
(6 rows)

The four undated tasks sit at the bottom, ordered among themselves by id — that is the tiebreaker doing its job.

SELECT id, title FROM tasks ORDER BY id ASC LIMIT 2 OFFSET 2;
 id |        title        
----+---------------------
  3 | file the tax return
  4 | renew passport
(2 rows)

That is page 2 with a page size of 2.


10. Counting, and the window-function trick

count(*) counts rows. Chapter 12 adds this query for Chapter 17’s quota check:

-- name: CountActiveTasks :one
SELECT count(*) FROM tasks WHERE user_id = $1 AND status <> 'archived';

Now the trick. A list endpoint needs both a page of rows and the total number of matching rows, so the client can render “page 2 of 9”. Two queries would mean two round trips, and the count could change between them. Instead, Chapter 9 attaches the total to every row of the page:

count(*) OVER() AS total_count

OVER() turns count(*) into a window function: instead of collapsing the result into one row, it computes the count across the whole filtered result set and hands it back as an extra column on each row. One query, one snapshot, count and page guaranteed consistent. The Go side then reads it off any row.

Run this

SELECT count(*) FROM tasks WHERE status <> 'archived';

SELECT id, title, count(*) OVER() AS total_count
FROM tasks WHERE status = 'open' ORDER BY id LIMIT 2;
 count 
-------
     5
(1 row)

 id |        title         | total_count 
----+----------------------+-------------
  1 | write the SQL primer |           4
  2 | buy milk             |           4
(2 rows)

Two rows came back because of the LIMIT, but each carries total_count = 4 — the number of open tasks before the limit. That single column is what fills the metadata block in every taskd list response.


11. Changing and removing rows

UPDATE tasks
SET status = 'done', version = version + 1, updated_at = now()
WHERE id = 2 AND version = 1
RETURNING id, title, status, version;

UPDATE names the table, SET lists the assignments, WHERE chooses the rows. The right-hand side of a SET can read the current value — version = version + 1 increments a counter without a read-then-write round trip.

That AND version = 1 is taskd’s optimistic lock, and it is worth understanding now because Chapter 8 (CRUD done properly) builds a whole error path on it. Two people load the same task, both edit the title, both save. Without the version check the second save silently erases the first — a lost update. With it, the second UPDATE matches zero rows (the version moved on), sqlc reports pgx.ErrNoRows, and the handler returns HTTP 409 Conflict. No locks are held, no one waits; the database’s atomicity does all the work.

DELETE is the same shape without SET:

DELETE FROM tasks WHERE id = 6;
Warning

UPDATE and DELETE without a WHERE clause apply to every row in the table. There is no confirmation prompt and no undo outside a transaction. Type the WHERE first, then go back and type the verb.

Run this

UPDATE tasks
SET status = 'done', version = version + 1, updated_at = now()
WHERE id = 2 AND version = 1
RETURNING id, title, status, version;
 id |  title   | status | version 
----+----------+--------+---------
  2 | buy milk | done   |       2
(1 row)

UPDATE 1

Now run the exact same statement a second time:

 id | title | status | version 
----+-------+--------+---------
(0 rows)

UPDATE 0

Zero rows. The row’s version is 2 now, so version = 1 matches nothing. That empty result is the conflict signal — an HTTP 409 in Chapter 8. Finish with:

DELETE FROM tasks WHERE id = 6;
DELETE FROM tasks WHERE id = 6;

DELETE 1, then DELETE 0. Chapter 8’s DeleteTask query is declared :execrows for exactly this reason: the affected-row count is how the handler tells “deleted” (1) from “never existed” (0), which becomes 204 or 404.


12. A second table, and foreign keys

Now users, exactly as Chapter 10 creates it:

-- migrations/000002_create_users.up.sql — the real thing
CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE users (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    created_at    timestamptz NOT NULL DEFAULT now(),
    name          text        NOT NULL,
    email         citext      NOT NULL UNIQUE,   -- the constraint IS the dedupe logic
    password_hash bytea       NOT NULL,          -- bcrypt output: bytes, never text
    activated     boolean     NOT NULL DEFAULT true, -- flipped to false in ch. 21
    version       integer     NOT NULL DEFAULT 1      -- same optimistic lock as tasks
);

Insert two users, then try to insert a third with an email that differs only in capitalisation ('\x00' is a placeholder byte value standing in for a real bcrypt hash):

INSERT INTO users (name, email, password_hash)
VALUES ('Alice', 'alice@example.com', '\x00') RETURNING id, name, email, activated;

INSERT INTO users (name, email, password_hash)
VALUES ('Bob', 'BOB@Example.com', '\x00') RETURNING id, name, email;

INSERT INTO users (name, email, password_hash)
VALUES ('Bob again', 'bob@example.com', '\x00');
 id | name  |       email       | activated 
----+-------+-------------------+-----------
  1 | Alice | alice@example.com | t
(1 row)

 id | name |      email      
----+------+-----------------
  2 | Bob  | BOB@Example.com
(1 row)

ERROR:  duplicate key value violates unique constraint "users_email_key"
DETAIL:  Key (email)=(bob@example.com) already exists.

Two things happened there. citext stored BOB@Example.com with its original capitals but compares it case-insensitively, so the third insert collided. And the collision reported as duplicate key value violates unique constraint — Postgres error code 23505, the one Chapter 10’s handler catches with errors.As and turns into “a user with this email address already exists”.

Foreign keys

A foreign key is a column that must contain a value present in another table’s primary key. It is the database enforcing “every task belongs to a real user”. Chapter 12 adds it:

-- migrations/000004_add_user_id_to_tasks.up.sql
-- up: dev-grade — production would backfill instead of truncate.
TRUNCATE tasks;
ALTER TABLE tasks
    ADD COLUMN user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE;
CREATE INDEX idx_tasks_user_id ON tasks (user_id);

Try it without the TRUNCATE first, so you feel why it is there:

ALTER TABLE tasks
    ADD COLUMN user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE;
ERROR:  column "user_id" of relation "tasks" contains null values

You asked for a column that can never be NULL, on a table that already has rows, without saying what those rows should contain. In production the answer is the three-step dance — add the column nullable, backfill it, then set NOT NULL. In development, with throwaway rows, Chapter 12 takes the honest shortcut and says so. Run the real migration:

TRUNCATE tasks;
ALTER TABLE tasks
    ADD COLUMN user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE;
CREATE INDEX idx_tasks_user_id ON tasks (user_id);

TRUNCATE empties a table completely, faster than DELETE and with no WHERE. Chapter 20 (Testing what matters) uses TRUNCATE ... RESTART IDENTITY CASCADE between tests for the same reason: a clean table in one statement.

Run this

INSERT INTO tasks (user_id, title, priority) VALUES
  (1, 'write the SQL primer', 'high'),
  (1, 'water the plants',     'none'),
  (2, 'file the tax return',  'high');

INSERT INTO tasks (user_id, title) VALUES (999, 'ghost task');
INSERT 0 3
ERROR:  insert or update on table "tasks" violates foreign key constraint "tasks_user_id_fkey"
DETAIL:  Key (user_id)=(999) is not present in table "users".

There is no user 999, so there can be no task belonging to user 999. Orphan rows are now impossible — not “unlikely if everyone is careful”, impossible.

ON DELETE CASCADE

ON DELETE CASCADE answers the question “what happens to the children when the parent is deleted?” The answer taskd chooses: delete them too, in the same operation.

SELECT count(*) AS bobs_tasks FROM tasks WHERE user_id = 2;
DELETE FROM users WHERE id = 2;
SELECT count(*) AS bobs_tasks FROM tasks WHERE user_id = 2;
 bobs_tasks 
------------
          1
(1 row)

DELETE 1
 bobs_tasks 
------------
          0
(1 row)

Deleting Bob deleted Bob’s task. Nobody wrote code to do that.

Warning

Chapter 12 calls cascades “loaded weapons; know each one you install”. Deleting a user vaporises their tasks, their tokens and their subscription — correct for the account-deletion flow Chapter 22 builds, catastrophic if an admin tool deletes the wrong user. taskd installs exactly three cascades, all pointing at users, all deliberate.


13. JOIN

A join answers one question using two tables at once. This book contains exactly one, in Chapter 11 (Stateful tokens), and it is the whole of taskd’s authentication:

-- name: GetUserForToken :one
SELECT u.id, u.created_at, u.name, u.email, u.activated, u.version
FROM users u
INNER JOIN tokens t ON t.user_id = u.id
WHERE t.hash = $1 AND t.scope = $2 AND t.expiry > now();

Line by line:

  • FROM users u — start with users, and call it u for short. That short name is an alias.
  • INNER JOIN tokens t ON t.user_id = u.id — pair each user with each of their tokens. INNER means: keep a pair only if both sides exist. ON states the pairing rule, which here is the foreign key we just created.
  • WHERE ... — of those pairs, keep the one whose token hash matches, whose scope is right, and whose expiry is still in the future.
  • SELECT u.... — return columns from the users side only. password_hash is deliberately absent, so the user object the middleware carries around physically cannot leak it.

Two design points hide in there. The expiry check lives in SQL, so no Go code path can forget it. And the whole “who is this request from?” question costs one query.

Run this

First create the tokens table, exactly as Chapter 11 does, and put a token in it. sha256(...) is a real Postgres function; here it stands in for what Go computes in Chapter 11.

CREATE TABLE tokens (
    hash    bytea PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
    expiry  timestamptz NOT NULL,
    scope   text NOT NULL
);

INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES (sha256('pretend-this-is-a-real-token'::bytea), 1,
        now() + interval '24 hours', 'authentication');

Now run the book’s authentication query with the values filled in:

SELECT u.id, u.name, u.email
FROM users u
INNER JOIN tokens t ON t.user_id = u.id
WHERE t.hash = sha256('pretend-this-is-a-real-token'::bytea)
  AND t.scope = 'authentication'
  AND t.expiry > now();
 id | name  |       email       
----+-------+-------------------
  1 | Alice | alice@example.com
(1 row)

That is a logged-in user, resolved from a bearer token, in one round trip. Change one character of the token string and you get zero rows — which is a 401.

now() + interval '24 hours' is date arithmetic; Chapter 21 uses the same shape backwards in DELETE FROM stripe_events WHERE received_at < now() - interval '30 days'.

Note

LEFT JOIN, RIGHT JOIN, GROUP BY, HAVING, subqueries and CTEs (WITH) are all real SQL and none of them appear in this book. Learn them when a query needs them, not before.


14. Indexes, and what EXPLAIN shows

Without an index, answering WHERE user_id = 42 means reading every row in the table and discarding the ones that do not match. That is a sequential scan, and its cost grows in a straight line with every signup.

An index is a separate, sorted structure the database maintains alongside the table, mapping column values to row locations — like the index at the back of a book. Looking up a value in it is fast no matter how big the table is. The trade is that every INSERT, UPDATE and DELETE must also update the index, and the index takes disk space. You add indexes for the queries you actually run, not for every column.

Chapter 12 adds CREATE INDEX idx_tasks_user_id ON tasks (user_id); in the same migration as the foreign key, and is blunt about why: “every single query from now on filters by user_id; without the index each one is a sequential scan that gets linearly worse with every signup.” Chapter 21 adds a second one, idx_tokens_user_id, after a review finding — Postgres does not automatically index foreign key columns, and the activation and password-reset flows delete by user and scope constantly.

EXPLAIN shows you the plan Postgres intends to use, without running the query. It is how you find out whether your index is being used, and it is what Chapter 9’s pitfall means by “know which regime you’re in — EXPLAIN ANALYZE is how”.

Run this

Two tasks is not enough data for the planner to care. Make 50,000. generate_series is a Postgres helper for producing test data; the book never uses it.

INSERT INTO users (name, email, password_hash)
SELECT 'user ' || n, 'user' || n || '@example.com', '\x00'
FROM generate_series(1, 500) AS n;

INSERT INTO tasks (user_id, title)
SELECT u.id, 'task ' || n
FROM users u, generate_series(1, 100) AS n;

ANALYZE tasks;
SELECT count(*) FROM tasks;
INSERT 0 500
INSERT 0 50100
ANALYZE
 count 
-------
 50102
(1 row)

ANALYZE tells Postgres to re-measure the table so its planner has current statistics. Now:

EXPLAIN SELECT id, title FROM tasks WHERE user_id = 42;
                                    QUERY PLAN                                    
----------------------------------------------------------------------------------
 Bitmap Heap Scan on tasks  (cost=5.07..263.43 rows=100 width=15)
   Recheck Cond: (user_id = 42)
   ->  Bitmap Index Scan on idx_tasks_user_id  (cost=0.00..5.04 rows=100 width=0)
         Index Cond: (user_id = 42)

Read it inside-out. The innermost step, Bitmap Index Scan on idx_tasks_user_id, uses the index to find where the matching rows live; the outer step fetches them. rows=100 is the planner’s estimate of how many rows come back. cost=5.07..263.43 is an arbitrary unit — useful for comparing two plans, meaningless on its own.

Now take the index away and ask again:

DROP INDEX idx_tasks_user_id;
EXPLAIN SELECT id, title FROM tasks WHERE user_id = 42;
        QUERY PLAN         
-----------------------------------------------------------
 Seq Scan on tasks
   Filter: (user_id = 42)

Seq Scan with a Filter: read all 50,102 rows, keep 100. The estimated cost went from 263 to 1196. Put it back:

CREATE INDEX idx_tasks_user_id ON tasks (user_id);
Remember this

Seq Scan on a large table with a selective WHERE is the shape of a missing index. EXPLAIN is how you find out, and it is free — it does not run the query.


15. Transactions, atomicity, and rollback

A transaction is a group of statements that either all happen or none do. You open one with BEGIN, and end it with COMMIT (make it permanent) or ROLLBACK (pretend it never happened).

The four properties are known by the acronym ACID, and each is one sentence:

Letter Word In plain words
A Atomicity All of it lands, or none of it does. No half-states
C Consistency Every constraint holds before and after; the database refuses writes that break its rules
I Isolation Concurrent transactions don’t see each other’s half-finished work
D Durability Once it says committed, it survives the power going out

The one to actually internalise is atomicity, because this book leans on it constantly without naming it. A single SQL statement is already atomic. That fact is what makes three of the book’s cleverest moves work:

  • Chapter 8’s optimistic lock: UPDATE ... WHERE id = $1 AND version = $7 either matches and bumps the version, or matches nothing. There is no in-between for a competitor to slip into.
  • Chapter 10’s registration: the UNIQUE constraint decides duplicates, with no check-then-insert window.
  • Chapter 15’s INSERT ... ON CONFLICT DO NOTHING as a webhook idempotency ledger — Chapter 15 calls it “atomic idempotency in one statement”.

Run this

BEGIN;
UPDATE tasks SET title = 'a title I will regret' WHERE id = 1;
SELECT id, title FROM tasks WHERE id = 1;
ROLLBACK;
SELECT id, title FROM tasks WHERE id = 1;
BEGIN
UPDATE 1
 id |         title         
----+-----------------------
  1 | a title I will regret
(1 row)

ROLLBACK
 id |        title         
----+----------------------
  1 | write the SQL primer
(1 row)

Inside the transaction, your own session sees the change. After ROLLBACK, it never happened — and no other session ever saw it. This is also the safest way to try a dangerous UPDATE: wrap it in BEGIN, look at the row count, then COMMIT or ROLLBACK accordingly.

Note

taskd never opens an explicit transaction. BEGIN and COMMIT appear nowhere in the codebase. That is a design property, not an oversight: every write the application performs is a single statement, and single statements are already atomic. Chapter 7 mentions transactions once, noting that the generated DBTX interface is satisfied by both *pgxpool.Pool and pgx.Tx — so the day you do need one, the generated code already runs inside it unchanged. Knowing this is why you can stop looking for the transaction you were told to expect.


16. Connections, pools, and reading a DSN

A connection is an open network conversation between your program and the database: a TCP connection, an authentication handshake, and — on the Postgres side — a whole operating-system process forked to serve it. It is expensive to create and cheap to keep.

A web server cannot open one per request. Chapter 4 gives every request its own goroutine, so dozens of handlers may want the database at the same instant, and paying the handshake cost on each would dominate your response time.

New word

connection pool — a small set of already-open database connections the server keeps and reuses. A query borrows one, runs, and returns it. Chapter 6 (Connecting with pgx/v5) creates taskd’s with pgxpool, sized at 25 by default.

Why 25 and not 200? Because Postgres’ max_connections defaults to 100, that budget is shared by every instance of your app plus psql, migrations and backups, and each connection costs a process. Chapter 6’s rule: start at 25 per instance, then let the pool metrics exported in Chapter 18 (Prometheus) tell you the truth.

Reading a DSN

A DSN (data source name) is the whole address of a database squeezed into one string. This is the one Chapter 5 puts in .envrc:

postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable
└──┬───┘   └─┬─┘ └───┬───┘ └───┬───┘ └─┬┘ └─┬─┘ └──────┬──────┘
scheme     user  password    host    port  database  options

Read it once, left to right; you will be reading these for the rest of your career.

Common mistake

You’ll see: FATAL: database "taksd" does not exist, or FATAL: role "nobody" does not exist. It means: the database name or the username in your DSN is wrong — not the password, not the network. Fix: compare the DSN’s /name and user: parts against what docker compose created.

Run this

SHOW max_connections;
SELECT count(*) AS open_connections FROM pg_stat_activity WHERE datname = 'taskd';
 max_connections 
-----------------
 100
(1 row)

 open_connections 
------------------
                1
(1 row)

One connection: the psql session you are typing in. From Chapter 6 onward this number jumps, because the pool opens several at boot and holds them.


17. Migrations: schema changes, ordered and versioned

Your schema changes over a project’s life, and those changes must happen identically on your laptop, your teammate’s, CI and production. A migration is a numbered SQL file that makes one such change, paired with a file that undoes it.

You have already read six of taskd’s seven:

migrations/
  000001_create_tasks.up.sql          §3   CREATE TABLE tasks
  000002_create_users.up.sql          §12  CREATE TABLE users, citext
  000003_create_tokens.up.sql         §13  CREATE TABLE tokens
  000004_add_user_id_to_tasks.up.sql  §12  the FK and idx_tasks_user_id
  000005_billing.up.sql               Ch15 subscriptions, stripe_events
  000006_activation.up.sql            Ch21 activated DEFAULT false, idx_tokens_user_id
  000007_pending_email.up.sql         Ch22 users.pending_email

Order is the point. 000004 adds a foreign key pointing at users, so 000002 must have run first. Numbering is what makes “run everything that hasn’t run yet” a safe, repeatable command. The tool taskd uses is golang-migrate; it records applied versions in a bookkeeping table called schema_migrations (two columns: version and dirty) which appears in \dt from Chapter 5 onward. Leave it alone.

Three rules, all from Chapter 5’s pitfalls:

  1. An applied migration is immutable history. Once it has run anywhere — a teammate’s machine, CI, production — editing it makes two databases claim the same version while differing. Wrong? Write a new migration that fixes it.
  2. Down migrations are for development. Rolling schema back in production usually loses data; real production rollbacks are roll-forwards. Write the down files anyway — they make local iteration pleasant.
  3. If a migration fails halfway, migrate marks the database “dirty” and refuses to continue until a human looks. In development, docker compose down -v and re-up is often the honest reset.

There is a fourth rule that belongs to Chapter 7 and will bite you sooner: never run migrations without regenerating. sqlc reads migrations/ as its schema source, so a schema change that isn’t followed by sqlc generate leaves your Go compiled against yesterday’s tables. Chapter 7’s muscle-memory fix: make db/migrations/up sqlc.

Run this

Nothing new to type — your psql session has been a hand-run migration all along. Instead, look at what you built:

\dt

You should see tasks, tokens and users. That is migrations 000001 through 000004, applied by hand, in order.


18. One more thing: $1 and why it is not string-pasting

Every query in this book has numbers in it: WHERE id = $1 AND user_id = $2. Those are bind parameters — placeholders. You send the query text and the values as two separate things, and the database never confuses one for the other.

The alternative — gluing user input into the query text — is how SQL injection happens. If a search box’s contents get pasted into a statement, a user who types '; DROP TABLE tasks; -- has just sent you a second statement. With $1, that text is a value containing punctuation, and nothing more.

One limitation to know now, because Chapter 9 hits it head-on: a placeholder can stand in for a value, never for a column or table name. ORDER BY $1 is not legal SQL. Chapter 9 works around it with a CASE ladder, and checks the requested sort against a whitelist in Go first. Its rule, worth copying verbatim: never interpolate user input into an ORDER BY. Ever.

You will not type $1 by hand often. From Chapter 7 onward, sqlc reads your $1s and generates a Go struct with a typed field for each one.


The whole schema, at the end of the book

Every table, every relationship, as Appendix C has it. Arrows point from the foreign key to the primary key it references.

                 ┌────────────────────────────────────────┐
                 │ users                                  │
                 │   id                 bigint   PK       │
                 │   created_at         timestamptz       │
                 │   name               text              │
                 │   email              citext   UNIQUE   │
                 │   password_hash      bytea             │
                 │   activated          boolean           │
                 │   stripe_customer_id text     UNIQUE   │
                 │   pending_email      citext   (nullable)│
                 │   version            integer           │
                 └────────────────────────────────────────┘
                       ▲              ▲              ▲
                       │              │              │
          user_id ─────┘   user_id ───┘   user_id ───┘
       ON DELETE CASCADE  ON DELETE CASCADE  ON DELETE CASCADE
  ┌──────────────────┐ ┌──────────────────┐ ┌───────────────────────┐
  │ tasks            │ │ tokens           │ │ subscriptions         │
  │  id       PK     │ │  hash    PK bytea│ │  user_id      PK      │
  │  user_id  FK     │ │  user_id FK      │ │  stripe_subscription_ │
  │  created_at      │ │  expiry  tstz    │ │        id text UNIQUE │
  │  updated_at      │ │  scope   text    │ │  tier         text    │
  │  title    text   │ └──────────────────┘ │  status       text    │
  │  notes    text   │  idx_tokens_user_id  │  current_period_end   │
  │  status   CHECK  │                      │  updated_at           │
  │  priority CHECK  │                      └───────────────────────┘
  │  due_at   (null) │
  │  version  integer│      ┌──────────────────────────┐
  └──────────────────┘      │ stripe_events            │  no foreign key:
   idx_tasks_user_id        │  id          text PK     │  a ledger of
                            │  received_at timestamptz │  webhook ids
                            └──────────────────────────┘

Walking it:

  1. users is the root. Every other table with a person in it points here.
  2. tasks.user_id arrives in Chapter 12 and makes taskd multi-tenant. Its index, idx_tasks_user_id, exists because every query from that chapter on filters by it.
  3. tokens.user_id arrives in Chapter 11. The primary key is the token’s SHA-256 hash, not an id, because the hash is the token’s identity. idx_tokens_user_id arrives in Chapter 21.
  4. subscriptions.user_id is primary key and foreign key at once — that is Chapter 15 spelling “one subscription per user” as a constraint rather than a comment.
  5. stripe_events stands alone. It records which Stripe webhook ids have already been processed, so a redelivered event is a no-op (Chapter 16).
  6. Three cascades, all pointing at users. Deleting a user removes their tasks, tokens and subscription in one statement — which is what Chapter 22’s account-deletion flow relies on.

Common mistakes

You’ll see It means Fix
prompt changed to taskd-# and nothing runs psql is waiting: no semicolon type ; and press Enter
ERROR: relation "task" does not exist wrong table name (Postgres calls tables “relations”) check spelling with \dt
ERROR: column "titel" does not exist with HINT: Perhaps you meant to reference the column "tasks.title". typo in a column name; the hint is usually right fix the spelling
ERROR: column "buy milk" does not exist you quoted a value with double quotes single quotes for values, double quotes only for identifiers
ERROR: syntax error at or near "WHERE" a keyword in the wrong place; the caret ^ points at where Postgres gave up read the caret line — the error is usually just before it
a query returns 0 rows when you expected some often = NULL instead of IS NULL use IS NULL / IS NOT NULL
ERROR: current transaction is aborted, commands ignored until end of transaction block an earlier statement inside BEGIN failed; the transaction is poisoned ROLLBACK; then start again
ERROR: null value in column "title" ... violates not-null constraint you left out a required column with no default supply it
ERROR: duplicate key value violates unique constraint "users_email_key" that email is already taken; Postgres code 23505 this is the error Chapter 10 turns into a 422
ERROR: insert or update on table "tasks" violates foreign key constraint "tasks_user_id_fkey" the user_id you supplied names no existing user insert the user first, or fix the id
ERROR: column "user_id" of relation "tasks" contains null values you added a NOT NULL column to a table that already has rows add it nullable, backfill, then set NOT NULL — or TRUNCATE in dev
psql: error: ... FATAL: database "taksd" does not exist typo in the database name in your DSN or -d flag check the name
Common mistake

You’ll see: UPDATE 50103 when you expected UPDATE 1. It means: you forgot the WHERE clause and just rewrote the entire table. Fix: if you were inside BEGIN, run ROLLBACK; right now. If you were not, restore from backup — or, in this warm-up, delete the container and start again. This is why you type the WHERE before you type the verb.


Check yourself

  1. notes is text NOT NULL DEFAULT '' but due_at is plain timestamptz. What is the difference in what those two columns can say?
  2. You run SELECT * FROM tasks WHERE due_at = NULL; and get zero rows, though you know four tasks have no due date. Why?
  3. UPDATE tasks SET status='done', version=version+1 WHERE id=7 AND version=3 returns UPDATE 0. Give two different explanations, and say how the handler in Chapter 8 tells them apart.
  4. Why does Chapter 9 end its ORDER BY with id ASC when it has already sorted by created_at?
  5. What does count(*) OVER() give you that a separate SELECT count(*) query does not?
  6. idx_tasks_user_id indexes tasks(user_id). Name one query it makes fast and one cost it imposes.
  7. A user is deleted. Without any Go code running, what else disappears, and what mechanism does it?
  8. taskd never writes BEGIN or COMMIT. Is that a bug? Defend your answer in two sentences.
Answers
  1. notes can never be unknown — every task has notes, sometimes empty. due_at can be NULL, meaning this task has no due date at all, which is different from having a due date of nothing. Chapter 5 calls due_at “the ONE honest NULL” for exactly this reason.

  2. NULL is not equal to anything, including itself. due_at = NULL evaluates to NULL rather than true, so no row qualifies. The correct form is WHERE due_at IS NULL.

  3. Either task 7 does not exist (or does not belong to this user, once Chapter 12 scopes the query), or it exists but someone else already updated it so its version is no longer 3. Chapter 8 distinguishes them by re-reading: GetTask succeeding while UpdateTask matched nothing means a version conflict, which is a 409; GetTask also finding nothing is a 404.

  4. To make the ordering total. created_at values can tie, and when they do Postgres is free to return tied rows in any order — so a row can appear on page 1 and again on page 2. id is unique, so appending it removes every tie.

  5. One round trip and one snapshot. The window function computes the total across the same filtered result set that produced the page, so the count and the rows can never disagree. Two separate queries can drift if someone inserts between them.

  6. It makes WHERE user_id = $1 — that is, every task query in the book from Chapter 12 onward — fast, by finding matching rows through the index instead of reading the whole table. The cost is that every insert, update and delete of a task must also maintain the index, and the index occupies disk.

  7. Their tasks, their tokens and their subscription. The mechanism is REFERENCES users ON DELETE CASCADE on each of those three tables — the database performs the deletion itself.

  8. Not a bug. Every write taskd performs is a single SQL statement, and a single statement is already atomic, so there is nothing for an explicit transaction to add. Chapter 7 notes that the generated DBTX interface is satisfied by pgx.Tx as well as the pool, so the code can move inside a transaction unchanged the day a multi-statement write appears.


FAQ

Do I need to memorise this? No. You need to recognise it. When Chapter 9’s query appears with count(*) OVER(), ILIKE, NULLS LAST and a CASE ladder in it, you should feel “I know four of these five things” rather than “this is hieroglyphics”. Come back to the relevant section instead of re-reading the whole warm-up.

Why write SQL at all, when there are libraries that generate it? Chapter 1 argues this properly, and Chapter 7 makes it concrete. The short version: with an ORM you review Go and the database receives SQL you have never seen. taskd writes plain SQL and uses sqlc to generate the tedious part — the scanning of rows into structs — so mismatches become compile errors instead of runtime surprises.

Why is so much validation in the schema rather than in Go? Because a schema rule cannot be bypassed and a Go rule can. A CHECK constraint rejects status = 'banana' no matter which handler, background job, migration or psql session tries it. Your Go validation still exists — it produces friendly error messages — but the database is the thing that is actually load-bearing.

Is Postgres overkill for a task manager? For the first hundred users, yes, and so is almost everything else in this book. The reason to start here is that the alternative is a rewrite at exactly the moment you cannot afford one. Postgres runs happily in a container on your laptop and scales to workloads far past anything you will build in these 27 chapters.

Should I learn GROUP BY, subqueries and window functions properly? Eventually, yes — they are the next things you will need, and they are genuinely useful. But this book does not use them, and a primer full of constructs the codebase never contains is a primer you paid for and did not use. Learn them when a real query demands them.

Everything I typed is gone when I delete the container. Isn’t that wasteful? That is the point. This database was scratch paper. Chapter 5 builds the real one with Docker Compose, a named volume so data survives restarts, and versioned migrations so the schema is reproducible from Git.


Where we are

You can read every .sql file in this book. You have built four of taskd’s five tables by hand, watched Postgres refuse five different kinds of bad data, seen an index change a query plan, and rolled back a transaction.

Clean up:

docker rm -f sql-primer

That deletes the container and everything in it. Chapter 5 starts fresh.

Come back here when

Chapter Which section you’ll want
5 (PostgreSQL and migrations) §3 tables, §4 types, §7 constraints, §17 migrations, §2 psql
6 (Connecting with pgx/v5) §16 connections, pools and DSNs
7 (sqlc) §8 RETURNING, §18 $1 placeholders, §15 the transaction note
8 (CRUD done properly) §11 UPDATE, the version dance, :execrows
9 (Listing at scale) §9 WHERE/ORDER BY/LIMIT, §10 count(*) OVER()
10 (Users and passwords) §12 citext and UNIQUE, §7 the 23505 note
11 (Stateful tokens) §13 the JOIN, §4 bytea
12 (Ownership) §12 foreign keys and cascades, §14 indexes
15–17 (Stripe, entitlements) §8 ON CONFLICT, §10 count(*)
22 (Password reset) §5 IS NOT NULL, §12 ON DELETE CASCADE

For your notes

  • A database is not a file with extra steps. It exists because many things read and write at once, and because rules stored with the data cannot be skipped by the next code path someone writes.
  • NULL means unknown, and unknown is not equal to unknown. IS NULL, never = NULL.
  • A constraint is validation that no code path can bypass. UNIQUE beats check-then-insert because it has no window between the check and the insert.
  • RETURNING turns a write into a read. Every write in this book uses it, and that is why taskd never needs a follow-up SELECT to learn the id it just created.
  • One SQL statement is atomic. That single fact is what makes taskd’s optimistic locking, its duplicate-email handling and its webhook idempotency ledger correct without a single explicit transaction.

Chapter 1 — Introduction: what we’re building and why this shape

This chapter writes no code. It makes four decisions instead, out loud, with the rejected options written down beside the chosen ones: how requests find code, how code talks to the database, which library speaks to PostgreSQL, and whether this is one program or many. Those four are cheap today and expensive in six months, so we make them now, while nothing exists to break.

What you’ll be able to do by the end

  • Say in plain words what taskd is, and translate every phrase in its feature list into English.
  • Name the four decisions every web project makes before its first line, with one reason for each of taskd’s answers.
  • Apply one reusable test — could you rip it out in an afternoon? — to any library, forever.
  • Read the system diagram and trace one request through it, naming what each box does.
  • Prove your machine has the tools the next twenty-six chapters need.

Time: ~30 minutes reading, ~10 minutes typing.

You need before starting: the warm-up chapter Before you begin, with its thirteen-line checklist ticked. Nothing from a previous chapter’s code, because there is none yet. Prove your machine is ready:

go version

You should get a line beginning go version go1.24 or higher. If it says command not found, stop and finish Before you begin; every remaining chapter depends on it.


1. The problem, in plain words

You are going to build a product that other programs pay to talk to. Not a demo. A service with users, passwords, money, limits, and a way to find out what it is doing at three in the morning.

Four questions come before any of that. They sound like technical trivia. They are the questions that decide how much of this codebase you can still change a year from now.

  1. When a request arrives, what decides which of your functions answers it?
  2. How does your Go code ask the database a question, and what happens when the question is wrong?
  3. Which library actually speaks to PostgreSQL over the network?
  4. Is this one program, or several?

A tutorial answers these with whichever tool is most popular this month. A team answers them by asking what each choice costs to undo.

Why this exists

Every library you adopt writes some of its vocabulary into your code. A library whose vocabulary stays in one file is a tool. A library whose vocabulary appears in the first line of four hundred functions is a landlord. The cost of a dependency is not what it does for you — it is how many places would have to change if you stopped using it.

That gives one test, used four times in this chapter and for the rest of your working life.

Remember this

Could you rip it out in an afternoon? If yes, adopt it freely. If no, this is architecture, not convenience, and it deserves an argument.

The book calls this the Nadh test, after Kailash Nadh, a CTO whose teams run enormous systems on deliberately small toolkits (the preface introduces him). You never need to have read a word of his to use it. It is one question.


2. New words in this chapter

These are the words the argument needs. The product vocabulary — CRUD, webhook, quota, distroless and the rest — is defined in section 3’s table, where you first meet each one.

  • API — a way for one program to ask another to do something, over a fixed list of requests with fixed shapes. A drive-through menu.
  • endpoint — one address-plus-method your API answers, e.g. POST /v1/tasks. One menu item.
  • SaaS — software you rent by the month over the internet instead of buying a copy.
  • versioned API / /v1 — a version number in every address, so a future incompatible /v2 can exist without breaking today’s callers.
  • binary — the single runnable file the Go compiler produces. Nobody needs Go installed to run it.
  • standard library — the code that ships with Go and needs no download. In Go, the web server is part of it.
  • framework — a library that supplies your program’s overall structure and asks you to fill in the blanks. You write code inside its shape.
  • router — the code that matches an incoming address like /v1/tasks/48 to the function that should answer it. The mailroom sorting frame.
  • handler — that function. Chapter 2 (The skeleton: a server that answers) writes the first one.
  • middleware — a function that wraps a handler, doing something before and after it, and hands back a handler again — so they stack. Chapter 4 (A server that dies well) builds ours.
  • JSON — the labelled text format data travels in on the web: {"title": "buy milk", "done": false}. Taught in How the web actually works.
  • ORM — a library that writes your database queries for you, from code objects.
  • query builder — the middle option: you assemble SQL by calling Go methods rather than writing it as text.
  • driver — the library that speaks a database’s network language. The phone line to Postgres.
  • container / image — an image is a sealed package of a program plus everything it needs to run; a container is one running copy of an image. Chapter 25 (Docker) builds ours.
  • connection pool — a few already-open database connections the server keeps and reuses, because opening one costs milliseconds. Chapter 6 (Connecting with pgx/v5) sizes ours.
  • code generation — a tool that reads one file and writes another file of real source code before you compile. What it writes is ordinary code you can open and read.
  • monolith — one program containing all the features, deployed as one unit. Microservices is the opposite: many small programs talking over a network.
  • modular monolith — one program, but with internal walls: packages that do not reach into each other’s business.
  • lock-in — not being able to leave a dependency without rewriting a large part of your program.

3. The goal

By the last chapter you will have one file. You can copy it to a server and run it, and it is a business: it keeps each user’s task list and lets them add, change, search and delete over the internet; it knows who is calling and refuses to show one person another’s tasks; it sells three subscription plans through Stripe and enforces what each may do; it survives load and abuse; it reports its own health in a format monitoring tools read; and it rebuilds and redeploys itself when you push code.

Here is the same list in the industry’s own words. Read the bullets, then the table under them.

By the last chapter you will have a single Go binary, taskd, that:

  • serves a versioned JSON API (/v1/...) for tasks — create, read, update, delete, list with filtering, full-text-ish search, sorting and pagination;
  • registers users, hashes passwords properly, and authenticates requests with stateful bearer tokens (and can defend, in an interview, why it didn’t use JWTs);
  • is a real SaaS: three tiers (Free, Pro, Business) sold through Stripe Checkout, kept in sync by webhooks, enforced by quotas and feature gates in the API;
  • caches hot reads in DragonflyDB with O(1) invalidation, and rate-limits per plan using the same store;
  • exposes Prometheus metrics (RED metrics per route, DB pool stats) and emits structured JSON logs with log/slog;
  • ships as a ~15 MB distroless Docker image, with a Compose stack for the whole system and a GitHub Actions pipeline that lints, tests, vets SQL, scans for vulnerable dependencies, builds the image and pushes it to a registry;
  • shuts down gracefully, survives panics, refuses oversized bodies, and does a dozen other small production-grade things that separate demos from services.

The decoder. You are not expected to know any of these yet.

The phrase What it means Built in
CRUD Create, Read, Update, Delete — the four things you do to a stored record Ch. 8
pagination returning results twenty at a time instead of all at once Ch. 9
full-text-ish search “find tasks containing this word”, in plain SQL rather than a search engine Ch. 9
hashes passwords stores a one-way scramble, so a stolen database gives up nothing Ch. 10
bearer token a random string that proves who you are; whoever holds it is treated as you Ch. 11
stateful token a token our database remembers, so we can revoke it Ch. 11
JWT a signed token needing no database lookup — and so impossible to take back Ch. 11
tier / plan one of the things a customer can buy: Free, Pro, Business Ch. 15
Stripe Checkout a payment page Stripe hosts, so card numbers never touch our server Ch. 15
webhook a URL on our server that Stripe calls when something happens on their side Ch. 16
quota a numeric cap: 100 active tasks on the free plan Ch. 17
feature gate a check that turns a feature on or off depending on the plan Ch. 17
cache a fast temporary copy of an answer, so we don’t ask the database twice Ch. 13
O(1) invalidation discarding stale cached answers in one step, however many there are Ch. 13
rate limit a cap on how many requests one caller may make per second Ch. 14
RED metrics Rate, Errors, Duration — the three numbers describing an endpoint’s health Ch. 18
structured logs log lines as labelled key=value fields, so tools can filter them Ch. 19
distroless image a container image with no shell and no package manager, so a break-in finds no tools Ch. 25
Compose stack one file describing several containers, started together as one system Ch. 5, 25
pipeline (CI/CD) a robot that builds and tests every change before it may ship Ch. 26
graceful shutdown refusing new requests, letting in-flight ones finish, then exiting Ch. 4
panic Go’s “this should have been impossible” crash; unhandled, it kills the program Ch. 4
Note

Reading that table and retaining none of it is the correct experience today. It exists so that when Chapter 16 says “webhook”, you have been told once already what one is.


4. The thinking: choosing the shape before writing a line

Every project starts with four decisions that are expensive to reverse. Let’s make them consciously.

4.1 Decision 1 — Framework or stdlib-plus-router?

What is being decided. When a request for GET /v1/tasks/48 arrives, something must read that address and call the right function.

The paths: (a) a full framework (Gin, Echo, Fiber), (b) pure net/http, © net/http plus a tiny router.

Frameworks buy you speed on day one and cost you on day two hundred: their context types leak into every function signature, middleware ecosystems lock you in, and when something misbehaves you’re debugging their abstractions.

That is the whole argument, and it is invisible until you see it. The same handler — “give me the task whose id is in the URL” — three ways:

// illustration only, not a taskd file — the standard library, Go 1.22+
func showTask(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "task %s", id)
}
// illustration only, not a taskd file — chi
func showTask(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    fmt.Fprintf(w, "task %s", id)
}
// illustration only, not a taskd file — a framework with its own context type
func showTask(c *gin.Context) {
    id := c.Param("id")
    c.String(http.StatusOK, "task %s", id)
}

What this code says, line by line

  • A signature is a function’s first line: its name and the types it accepts. The first two are identical — func(http.ResponseWriter, *http.Request) — and both types belong to Go itself. w is where you write the reply; r is the request that arrived.
  • The third names *gin.Context. The function no longer accepts Go’s request type; it accepts the framework’s.
  • r.PathValue("id") and chi.URLParam(r, "id") each pull 48 out of /v1/tasks/{id} — a path parameter, a named hole in a route pattern. The difference between them is one call, in one place.

Now the question that matters: which of these could you paste into a different Go project? The first two, unchanged. The third works only inside Gin — and so does every function that calls it and every test that exercises it. Forty handlers later, the framework is not a dependency; it is the shape of your program.

Pure net/http (with Go 1.22’s improved ServeMux) is genuinely viable now — method matching and path params exist.

New word

HTTP method — the verb of a request: GET fetches, POST creates, PATCH edits part of something, PUT replaces, DELETE removes. Method matching means the router can register GET /v1/tasks and POST /v1/tasks as two different routes; before Go 1.22 you wrote that if by hand in every handler.

We still pick chi: it is net/http (every handler is a standard http.HandlerFunc), adds sub-routers, middleware groups and route patterns we’ll later need for Prometheus labels, and weighs almost nothing. This is the Nadh test: could you rip it out in an afternoon? With chi, yes.

Option What it gives Why rejected / chosen
Gin / Echo / Fiber Batteries included: binding, validation, rendering Rejected. Its Context enters every signature; leaving means rewriting every handler.
net/http alone Zero dependencies; method matching since Go 1.22 Rejected, narrowly. No sub-routers, no middleware groups, and no route pattern for metric labels — Chapter 18 needs /v1/tasks/{id}, not /v1/tasks/48.
net/http + chi A router, and nothing else Chosen. Handlers stay http.HandlerFunc. Removing chi means rewriting routes.go and two call sites.
Remember this

A dependency is cheap when its vocabulary never enters a function signature. That one criterion predicts lock-in better than any dependency count.

4.2 Decision 2 — ORM, query builder, or SQL?

What is being decided. Your Go code holds a Task struct — a labelled box with an id, a title, a status. Your database holds a row of columns. Something must carry values between them and produce the SQL.

The heading names three ways, and the middle one deserves a sentence before we set it aside. A query builder lets you assemble SQL by calling Go methods — .Select(...), .Where(...) — instead of writing it as text. It carries the ORM’s problem in smaller measure: the query that finally reaches Postgres is still assembled by a library, so it is still something you read back rather than something you wrote. The argument below is therefore about the two ends.

GORM-style ORMs generate SQL you didn’t write and can’t easily predict; at scale you end up fighting them.

// illustration only, not a taskd file — an ORM
db.Where("status = ?", "done").Order("created_at desc").Limit(20).Find(&tasks)

Which query did that send? You find out by switching on query logging and reading it back, and the answer can change when you upgrade the library. When it is slow, the thing you must optimise is a thing you did not write.

Hand-rolled database/sql scanning is honest but produces mountains of rows.Scan(&a, &b, &c) boilerplate where one misordered field is a runtime bug:

// illustration only, not a taskd file — writing it all by hand
rows, err := db.Query("SELECT id, created_at, title, status, version FROM tasks")
if err != nil {
    return nil, err
}
defer rows.Close()

var tasks []Task
for rows.Next() {
    var t Task
    err := rows.Scan(&t.ID, &t.CreatedAt, &t.Title, &t.Status, &t.Version)
    if err != nil {
        return nil, err
    }
    tasks = append(tasks, t)
}

What this code says, line by line

  • db.Query(...) sends the SQL text and returns a cursor over the results; for rows.Next() steps through one row at a time.
  • rows.Scan(&t.ID, ...) copies the row’s columns into the struct’s fields, matched by position, not by name. The & means “the address of this field”, which is how Scan writes into it rather than into a copy.
  • There is the bug. title and status are both text. Swap those two arguments and the code compiles, runs, and quietly files every task’s status in its title column — no error, ever. Now picture fourteen columns, edited by someone in a hurry.

sqlc is the modern sweet spot: you write real SQL in .sql files, it compiles them into typed Go functions at build time. Wrong column name? Compile error, not a 3 a.m. page. It is, in spirit, the typed descendant of the goyesql pattern Nadh’s listmonk uses — SQL lives in files, Go calls it by name.

Think of it like

sqlc is a translator who checks your sentence is grammatical before you say it out loud. An ORM is an assistant who paraphrases your email and sends it without showing you the final wording.

New word

listmonk is Kailash Nadh’s open-source mailing-list application, written in Go. The habit worth copying: its SQL lives in plain .sql files you can read and paste into a database console, and Go calls each query by name. goyesql is the small library that loads them. sqlc keeps the habit and adds a compiler.

Option Where the SQL comes from Failure mode
ORM The library writes it from your Go code You debug SQL you never wrote, in production, under time pressure.
Hand-written database/sql You, as a string A misordered Scan is a runtime bug that raises no error.
sqlc You, in a .sql file, checked against the real schema at build time A wrong column name stops the build. Chosen.
Common mistake

You’ll think: sqlc is a library my server needs at runtime, like chi. It isn’t: sqlc is a compiler you run at your desk. It reads .sql files and writes ordinary .go files, which you commit. The finished binary has never heard of it — so you install it with go install, not go get, and it never enters the deployed image.

4.3 Decision 3 — Which driver?

What is being decided. PostgreSQL is a separate program, usually on a separate machine. Talking to it means speaking its network protocol; a driver is the library that does that. Everything in 4.2 sits on top of one.

lib/pq is in maintenance mode. pgx/v5 is the actively developed driver: faster binary protocol, a built-in production-grade pool (pgxpool), native Postgres type handling. sqlc has first-class pgx/v5 support. Done.

New word

maintenance mode — the authors take security fixes but add no features. Not abandoned, not moving. Binary protocol — values cross the wire in Postgres’s internal format instead of being converted to text and back, which is fewer conversions per row.

Notice how quickly that was settled. The driver’s name appears in one setup file and a couple of error checks and nowhere else, so it is the cheapest of the four to reverse — and cheap decisions deserve short arguments.

4.4 Decision 4 — Monolith or services?

What is being decided. Tasks, users, billing and email could all live in one program, or each could be its own program on its own machine, calling the others over the network.

One binary. You are one person (or a small team) building a product, not a résumé. A modular monolith with clean internal packages can be split later, when a real bottleneck — not an architectural fantasy — demands it. Zerodha runs India’s largest broker this way. We can run a todo SaaS this way.

New word

Zerodha is India’s largest stockbroker, serving millions of customers from a small number of ordinary servers running monolithic Go services. It is cited as evidence, not decoration: the simple shape is not a beginner’s compromise that real scale forces you out of.

Option What you gain What you pay
Microservices Independent deploys and scaling, team boundaries Every function call becomes a network call that can fail, time out or half-succeed. Transactions across services need machinery. N pipelines, N log streams.
Modular monolith One process, one log stream, real transactions, the compiler checking every call You scale the whole thing together, and the internal walls are a discipline you must keep. Chosen.
Common mistake

You’ll think: “monolith” means one big messy file. It means: one deployable unit. Internally taskd has hard walls — internal/data, internal/cache, internal/mailer — and Chapter 2 puts them in place on day one. Organisation and deployment are separate questions.

Everything else — cache, billing, metrics — attaches to this spine later, and each gets its own “the thinking” section when its chapter comes.


5. A picture of it

Three pictures: the decisions, the finished system, and one request moving through it.

The four decisions, with the roads not taken

 1  How do requests find code?
    ├─ Gin / Echo / Fiber ......... their Context type in every signature
    ├─ net/http alone ............. no sub-routers, no pattern for metrics
    └─ net/http + chi ... CHOSEN .. handlers stay http.HandlerFunc

 2  How does Go talk to the database?
    ├─ ORM ........................ SQL you did not write, cannot predict
    ├─ hand-written Scan .......... one misordered field = silent bug
    └─ sqlc ............. CHOSEN .. real SQL in files, compiled to typed Go

 3  Which driver?
    ├─ lib/pq ..................... maintenance mode
    └─ pgx/v5 ........... CHOSEN .. active, binary protocol, own pool

 4  One program or many?
    ├─ microservices .............. network calls where function calls were
    └─ modular monolith . CHOSEN .. one binary, internal walls, split later

The system, one screen

Every box in the finished system. Two of them are not our software.

                          ┌─────────────────────────────┐
   client ── HTTPS ──▶    │ Caddy (TLS, ch. 27)         │
                          └──────────────┬──────────────┘
                                         │ :4000
                          ┌──────────────▼──────────────┐
                          │ taskd (single Go binary)    │
                          │ chi router                  │
                          │ auth · quotas · handlers    │
                          └───┬──────────┬──────────┬───┘
                              │          │          │
                     ┌────────▼───┐ ┌────▼─────┐ ┌──▼──────────┐
                     │ PostgreSQL │ │Dragonfly │ │ Stripe API  │
                     │ (pgx/sqlc) │ │(go-redis)│ │ + webhooks  │
                     └────────────┘ └──────────┘ └─────────────┘
                              ▲
                     ┌────────┴────────┐
                     │ Prometheus ──▶ Grafana (scrapes /metrics)
                     └─────────────────┘
  1. client — anything calling the API: a browser, a phone app, curl, another company’s server.
  2. Caddy — an off-the-shelf web server in front, which obtains and renews the HTTPS certificate and forwards plain requests inwards; Chapter 27 (Production checklist) puts it there. TLS is the encryption that puts the S in HTTPS, so ── HTTPS ──▶ means that leg is unreadable to anyone in between. The leg into taskd is not encrypted, because it never leaves the machine.
  3. :4000 — the port taskd listens on. A port is a numbered door on a machine.
  4. taskd — everything this book writes, in one process: chi picks the handler, auth decides who is calling, quotas decide whether they may.
  5. PostgreSQL — the permanent record, reached through pgx using SQL that sqlc turned into Go.
  6. Dragonfly — DragonflyDB, an in-memory store for things that may be lost: cached reads (Chapter 13) and rate-limit counters (Chapter 14). Reached with go-redis, because Dragonfly speaks Redis’s protocol.
  7. Stripe — we call it to start a checkout; it calls us back with webhooks (Chapters 15, 16).
  8. Prometheus → Grafana — Prometheus visits taskd’s /metrics address every few seconds and records what it finds; Grafana draws it. Our job is only to expose the numbers (Chapter 18).
Note

Caddy and Grafana are programs you run beside taskd, not things this book writes; Prometheus is the same, though Chapter 18 adds its configuration file to the project. The part we write is the one box in the middle.

One request, end to end

A single authenticated read, once everything exists. Chapter 2 builds steps 3, 6 and 7 for one small endpoint, and you will run curl against it yourself.

  curl                Caddy              taskd (one process)        Postgres
   │                    │                        │                      │
   │ 1 HTTPS request    │                        │                      │
   ├───────────────────▶│                        │                      │
   │                    │ 2 plain HTTP to :4000  │                      │
   │                    ├───────────────────────▶│                      │
   │                    │                        │ 3 chi matches route  │
   │                    │                        │ 4 middleware: who?   │
   │                    │                        │ 5 handler queries    │
   │                    │                        ├─────────────────────▶│
   │                    │                        │◀─────────────────────┤
   │                    │                        │ 6 rows become JSON   │
   │                    │◀───────────────────────┤                      │
   │◀───────────────────┤ 7 response back out    │                      │

Everything later in this book is a layer added around that path, never a change to its shape.


6. The steps

There is no code to type. There is a machine to prove out, because Chapter 2 opens with go mod init and Chapter 5 with docker compose up, and a failure in either is far easier to diagnose now than mid-chapter.

Step 1 — Prove Go is installed and new enough

go version

This asks the Go toolchain to print its own version and the platform it was built for. It changes nothing.

What you should see: one line beginning go version go1., then a version number, then your operating system and processor — darwin/arm64 on an Apple Mac, linux/amd64 on most Linux machines. The number after go1. must be 24 or higher.

Step 2 — Prove Docker runs, and can start a container

docker compose version
docker run --rm hello-world

Compose is the part of Docker that runs several containers together from one file. docker run --rm hello-world downloads a tiny official test image, runs it, prints a message and exits; --rm deletes the stopped container afterwards, so nothing is left behind.

What you should see: a line beginning Docker Compose version v. Then possibly some download progress, followed by a paragraph containing the sentence This message shows that your installation appears to be working correctly.

Tip

Docker Desktop must be running, not merely installed — the whale icon in the menu bar or system tray. It takes twenty or thirty seconds to settle after you launch it.

Step 3 — Prove curl exists

curl --version

curl is the tool you will use in nearly every chapter to send one HTTP request and print the answer: a browser with no window.

What you should see: a first line beginning curl and a version number, then one or two lines listing protocols and features. Any recent version is fine.

Step 4 — Write down what you think you are building

Before the vocabulary arrives and rearranges your mental model, record the version you have now.

mkdir -p learnings

Create learnings/ch01.md and write one paragraph, in your own words, using no jargon from this chapter, answering: what is taskd, and who would pay for it? Five or six sentences.

This is a step and not a nicety because at Chapter 27 you will read it again. The distance between that paragraph and what you can write then is the only honest measure of what this book taught you.


7. Checkpoint: prove it works

One command chaining all four checks. && means “continue only if the previous command succeeded”, so the last word appears only if everything passed.

go version && docker compose version && curl --version | head -1 && \
  docker run --rm hello-world | grep -q "working correctly" && echo PREFLIGHT-OK
Checkpoint

You should see the Go version line, the Compose version line, curl’s first line, and then PREFLIGHT-OK alone on a line. grep -q prints nothing when it matches — silence there is success.

If you got something else:

You saw Cause Fix
command not found after the first word That tool is not installed, or is not in a folder your shell searches Before you begin §5 installs each; §6 covers PATH
Cannot connect to the Docker daemon Docker is installed but not running Launch Docker Desktop, wait for the icon to settle, retry
A Go version of go1.21 or lower An old Go, often from a Linux distribution’s package manager Install from go.dev/dl and put that copy first on your PATH
Everything prints, but no PREFLIGHT-OK The hello-world output did not contain the expected sentence Run docker run --rm hello-world on its own and read the whole message

8. Common mistakes (and the quick fix)

Almost nothing here is code, so most of this chapter’s mistakes are misunderstandings that surface much later.

The misunderstanding Why it bites The correction
“chi is our framework” You go looking for chi’s validation, JSON binding, ORM. None exist. chi is a router and nothing else. Everything else is written by us or is standard library.
“sqlc must be installed on the server” You add it to the Docker image and wonder why it doubled in size. sqlc runs at your desk and writes .go files you commit. The server never sees it.
“monolith means badly organised” You assume the book is teaching a shortcut you will have to unlearn. Monolith describes deployment, not tidiness. The internal walls go up in Chapter 2.
“the diagram is the deployment plan” You try to install Prometheus, Grafana and Caddy before Chapter 2. It is the destination. Chapter 2 needs Go and nothing else.
Common mistake

You’ll see: zsh: command not found: go (or bash: go: command not found). It means: your shell searched every folder on its path and found no program called go. This is never a problem with your code — there is no code yet. Fix: install Go, or add its folder to PATH. Before you begin §6 walks through both.

Common mistake

You’ll see: after something like go get github.com/sqlc-dev/sqlc, a message saying the module was found but does not contain the package you asked for. It means: you tried to add a tool as if it were a library. go get records a dependency your program imports; sqlc is a program you run. Fix: go install instead, which builds the tool into $(go env GOPATH)/bin. Chapter 7 gives the exact line; there is nothing to install yet.


9. Pitfalls

The original chapter has no pitfalls section, because it ships no code. These are the traps in the decisions — the ones that present a bill much later.

  • Choosing by benchmark. Framework comparisons are won on requests per second, measured on an endpoint that does nothing. Every endpoint here waits on Postgres or Stripe, and that wait dwarfs the routing. Routing speed is not the tiebreaker at your scale; lock-in is.

  • “We’ll split into microservices later” as a plan rather than a trigger. A plan with no condition never fires, or fires at the worst moment. Write the trigger instead: when one part needs different hardware, or a second team owns it, we split that part.

  • Counting dependencies instead of measuring them. “Only four dependencies” is a number, not a property. One dependency whose types are in every signature costs more than eight that each live in one file. Apply the Nadh test per dependency, never to the total.

  • Adopting the shape without the reasons. Copy this layout into your next project without being able to say why the driver choice is the cheap one, and you have adopted a cargo cult: the visible form of something that worked, minus the part that made it work.


10. Check yourself — quiz

  1. Name the four decisions this chapter makes, and one reason for each answer.
  2. chi is called “still net/http”. What specific, checkable fact does that claim rest on?
  3. An ORM and a hand-written rows.Scan both go wrong. Describe each failure mode, and say which one a tool could catch before the program ran.
  4. Why was lib/pq rejected, and why is that the cheapest of the four decisions to reverse?
  5. What does “modular monolith” mean, and what condition would make you split it?
  6. What does /v1 buy you, given that there is no /v2 and may never be one?
  7. In the system diagram, which box calls Stripe, which direction does Prometheus’s arrow point, and which two boxes does this book not write?
  8. You are offered a library that saves a day of work, but its own Session type would appear in the signature of all forty of your handlers. Apply this chapter’s test out loud.
Answers
  1. Router: net/http plus chi, because handlers stay standard http.HandlerFunc and chi can be removed by rewriting one file. Database access: sqlc, because a wrong column name becomes a build error instead of a production incident. Driver: pgx/v5, because lib/pq is in maintenance mode and pgx brings its own pool. Shape: one modular monolith, because a small team paying the cost of network boundaries gets nothing back for it yet.

  2. That a chi handler’s signature is func(w http.ResponseWriter, r *http.Request) — the standard library’s own types. Nothing in the body needs to name chi except chi.URLParam. A Gin handler takes *gin.Context, so the function cannot leave Gin.

  3. The ORM’s failure is unpredictability: it emits SQL you did not write, so debugging means reading generated text. The hand-written Scan’s failure is silence: swap two same-typed arguments and it compiles, runs, and writes values into the wrong fields forever. The second is the one a tool can catch, and that is exactly what sqlc does by checking your SQL against the real schema at build time.

  4. Because it is in maintenance mode — fixes but no development — while pgx/v5 is active, uses the binary protocol, ships pgxpool, and is sqlc’s first-class driver. Cheapest to reverse because its name appears in the database setup file, the sqlc configuration and a few error comparisons, never in a handler’s signature.

  5. One program, one deployment, but with internal packages that do not reach into each other — walls you could cut along. The trigger to split is external and measurable: a part genuinely needs different hardware, or a separate team owns it. “It feels big” is not a trigger.

  6. The ability to make a breaking change later without breaking today’s callers: /v2 can exist beside /v1, and old clients keep working until they choose to move. It costs three characters per URL today and cannot be retrofitted once other people’s software depends on your addresses.

  7. taskd calls Stripe, and Stripe calls back with webhooks. Prometheus’s arrow points inward: it fetches /metrics on a schedule, and taskd never sends anything out. Caddy and Grafana are off-the-shelf programs this book configures but does not write.

  8. Could you rip it out in an afternoon? No — forty signatures name Session, so removing it means editing forty functions and every test that constructs one. That makes it an architecture decision, not a convenience, and one saved day does not pay for it.


11. Practice

Exercise 1 — Redraw the system from memory

Close the book. On paper, draw the boxes of the system diagram and the arrows between them, then write beside each box the chapter that builds or introduces it. Compare with section 5 and note only what you missed.

Solution

Client · Caddy (Ch. 27) · taskd (Ch. 2 onwards) · PostgreSQL (Ch. 5) · DragonflyDB (Ch. 13, reused by Ch. 14) · Stripe (Ch. 15 outbound, Ch. 16 inbound) · Prometheus (Ch. 18) · Grafana (off-the-shelf). Inside taskd: the chi router (Ch. 2), auth (Ch. 11), quotas (Ch. 17).

How to verify: your drawing has no arrow from the client to Postgres, and none from taskd out to Prometheus. Those are the two mistakes almost everyone makes first.

Exercise 2 — Rank the four decisions by cost of reversal

Rank them cheapest to most expensive to change six months in. For each, one sentence naming what the reversal actually costs. The useful question: how many files mention it, and does its vocabulary appear in function signatures?

Solution
Rank Decision Cost of reversing at month six
1 (cheapest) Driver (pgx vs lib/pq) Confined to the database setup file, the sqlc configuration and a regenerate. Nothing in a handler names the driver except one error comparison.
2 Router (chi vs net/http) Every handler is already a plain http.HandlerFunc — the entire point of choosing chi. You rewrite routes.go and two chi.URLParam call sites. This is the Nadh test passing.
3 sqlc vs an ORM Reversible but expensive: every handler calls a generated function with a generated parameter struct. Moving rewrites every data-access site and discards the build-time schema checking later chapters lean on.
4 (most expensive) Monolith vs services Not a code change but an operational one: network boundaries where function calls were, distributed transactions where a database transaction was, N pipelines, and the loss of the compiler escorting you to every call site.

The sentence to state back: the decisions that are cheap to reverse are the ones whose vocabulary never entered a function signature.

How to verify: this is the one exercise in the book with no command to run, and saying so is more honest than faking a check. Where you disagree with the table, write down which files you think would change — naming the files is the skill.

Exercise 3 — Make a fifth decision, in the book’s own format

Create learnings/decisions/005-cache.md and answer: should taskd use DragonflyDB, plain Redis, or no cache at all? Use this chapter’s format — name the candidate paths, state the cost of each, pick one, name the single thing that would change your mind. What you already know: one binary, one small team, and Postgres answers an indexed read in a millisecond or two. Chapter 13 (Caching with DragonflyDB) has the answer; write yours first.

Solution

The book’s own reasoning, condensed. Paths: (a) no cache; (b) Redis; © DragonflyDB. Costs: (a) none today, but rate limiting in Chapter 14 and entitlement lookups in Chapter 17 want a fast shared store anyway, so you would add one regardless; (b) Redis handles commands on a single thread per instance, so using a large machine means running a cluster; © Dragonfly speaks Redis’s own protocol, so every Redis client, tutorial and command-line tool still applies — zero lock-in — and it uses all the cores of one machine. Pick ©. What would change my mind: measurement. A cache is a performance patch applied to a measured wound, not a default layer.

The deciding property is the one from section 4.1: swapping the image for redis:7 needs no Go changes, because Dragonfly’s vocabulary never enters a signature.

How to verify: the graded property is not agreeing with the book, it is producing the shape — three paths, a cost each, a choice, a reversal condition.

test -s learnings/decisions/005-cache.md && echo MEMO-WRITTEN

12. FAQ

Why not use a framework — everyone else does? A framework is a good trade when its shape is your problem’s shape and you will never leave it. Section 4.1’s three code blocks are the honest cost: the framework’s type ends up in every signature, so every handler, test and helper is written in its dialect. For a service you intend to own for years, a router you could delete in an afternoon is worth the two extra lines a day it costs.

Is this over-engineered for a todo app? The tasks are a placeholder. Almost none of this book is about tasks — it is about users, money, limits, failure and observation, which are identical for a todo API and a payroll system. Build only what a todo list needs and you learn only what a todo list teaches.

Can I swap Postgres for MySQL or SQLite? Not without diverging from the book. sqlc supports both, but the SQL here uses Postgres features on purpose — timestamptz, RETURNING, ON CONFLICT, citext, window functions — and pgx is Postgres-only. Porting afterwards is an instructive project; doing it while learning Go is two problems at once.

Will I be able to put this on my CV honestly? Yes, if you can defend the decisions rather than list the technologies. “I built a Go API with Stripe billing” is a claim anyone can make. “I chose stateful tokens over JWTs because revocation mattered more than avoiding a database lookup” is one only someone who did the work can make.

Why is billing in a book about Go? Because taking money is where the interesting failures live: the payment succeeds but the webhook arrives twice; the card expires mid-month; a plan changes while a request is in flight. Idempotency and reconciliation are far easier to learn against Stripe’s test mode than to be told about in the abstract. No real money is involved at any point.

How is this different from a tutorial that builds a todo API in twenty minutes? Roughly twenty-six chapters of what the tutorial left out: timeouts, graceful shutdown, error paths, migrations, revocable authentication, rate limits, metrics, tests, a build pipeline. The tutorial’s version works on your laptop. The difference is what happens the first time something goes wrong and you are not there.


13. Where we are

Nowhere yet — and that’s the correct starting state. The next chapter creates the repository, and by its end you’ll have a running HTTP server answering a healthcheck. Small, verifiable steps: that’s the whole method.

The repository as it now stands:

(nothing — Chapter 2 creates the taskd folder)

learnings/
└── ch01.md          +  your one-paragraph answer to "what am I building?"

What works end to end: your machine. Go compiles, Docker runs containers, curl sends requests. That is the only runnable thing in this chapter, and it is worth having proved.

What is still fake: everything else. No code, no repository, no database, no server. Every claim in section 3 is a promise, redeemed in the chapter named beside it.

For your notes — copy these into learnings/ch01.md in your own words:

  1. The cost of a dependency is the cost of leaving it. Ask “could I rip it out in an afternoon?” before adopting anything, and look for the answer in one place: does its vocabulary appear in your function signatures?
  2. The four decisions, one reason each. chi because handlers stay http.HandlerFunc; sqlc because a wrong column name should be a build error; pgx/v5 because lib/pq stopped moving; one monolith because network boundaries cost real money and buy nothing yet.
  3. An ORM’s failure is unpredictability; hand-written scanning’s failure is silence. sqlc turns the second into a compile error without introducing the first.
  4. “Monolith” describes deployment, not tidiness. Internal walls and a single binary are compatible, and this book has both.
  5. Split into services when something forces you to — different hardware, or a second team — not because the codebase feels large. A plan without a trigger never fires.

Chapter 2 — The skeleton: a server that answers

This chapter creates the folder you will live in for the next twenty-five chapters, and puts one tiny working web server inside it. The server does almost nothing: you ask it a question over the network, it replies with a line of JSON saying it is alive. The value is not the reply. The value is the shape — where files go, and how every future piece of code will reach the things it needs.

What you’ll be able to do by the end

  • Create a Go project from an empty folder and explain what each directory is for.
  • Start a web server on your own machine and get a real HTTP response out of it with curl.
  • Read the signature func (app *application) someHandler(w http.ResponseWriter, r *http.Request) and say out loud what every part of it means.
  • Explain what happens, step by step, between typing curl and seeing JSON.
  • Say why a server without timeouts is a server waiting to fall over.

Time: ~40 minutes reading, ~30 minutes typing.

You need before starting: nothing from Chapter 1 (Introduction: what we’re building and why this shape) except the argument it made — it wrote no code. You do need the tools from Before you begin installed. Prove it with these three commands:

go version
git --version
curl --version

Each should print a version line. If go version says something like command not found, Go is either not installed or not on your PATH — go back to Before you begin and fix that first. Nothing in this chapter works without it.


1. The problem, in plain words

You are about to write a program that other programs talk to over a network. Right now you have an empty folder. Two questions have to be answered before a single useful line gets written, and both of them are annoying to change later.

Question one: where do files go?

A program is not one file. This one ends up being roughly seventy. Some of them are the web server; some are database code; some are generated by a tool and must never be edited by hand. In Go, the folder a file sits in is part of its address — other files refer to it by that address. So moving a folder later means editing every line that mentions it, across the whole project, and the compiler will shout at you until you get every one of them right. It is not dangerous. It is just tedious in a way that makes people avoid reorganising even when they should. The cheap fix is to decide the layout on day one, while nothing refers to anything.

Question two: how does a piece of code reach the things it needs?

Imagine the function that answers “is the server healthy?”. To do its job properly it needs to write a log line, and later it will need to ask the database a question. Neither of those things belongs inside that function — the logger and the database are set up once when the program starts, and shared by everything.

So there has to be some route by which a small function reaches shared machinery. The obvious route is to make the logger and the database available everywhere, to everything, all the time. That works on day one and quietly rots. Six months later you cannot answer “which parts of this program use the database?” without reading all of it, and you cannot run one part in isolation, because it reaches out and grabs the real thing regardless.

Why this exists

Both of this chapter’s decisions are about reversibility. A layout you can’t change and a dependency habit you can’t change are the two ways a young codebase gets old fast. We spend one chapter on them now so that the remaining twenty-five never have to.

By the end of the chapter you will have a running server. It is deliberately trivial. Treat the running server as proof that the skeleton is correct, not as the achievement.


2. New words in this chapter

  • binary — the single runnable file the Go compiler produces from your source code. You run it; nobody needs Go installed to run it.
  • module — one named unit of Go code with its own dependency list. This whole project is one module. go.mod is the file that names it.
  • package — a folder of Go files that share a name and are used together. package main is the special one: it produces a runnable program.
  • import path — a package’s address, e.g. github.com/yourname/taskd/internal/data. It starts with the module name.
  • internal/ — a folder name Go treats specially: code inside it cannot be imported by anyone outside this project. The compiler enforces it.
  • client — whatever is calling your API: a browser, a phone app, curl, another server.
  • port — a numbered door on a machine. Each server program listens on its own. Ours is 4000.
  • TCP connection — the basic two-way network link between two programs. HTTP messages travel over it. The connection is the phone call; HTTP is what you say on it.
  • HTTP method — the verb of a request: GET fetches, POST creates, PATCH edits part of something, PUT replaces, DELETE removes.
  • header — a labelled line of metadata attached to a request or response, e.g. Content-Type: application/json. The markings on the envelope, not the letter inside.
  • body — the actual data carried by a request or response. The letter inside.
  • status code — the three-digit number on every response saying how it went. 2xx worked, 4xx the caller was wrong, 5xx the server was wrong. 200 OK means success and the answer is in the body.
  • endpoint — one address-plus-method your API answers, e.g. GET /v1/healthcheck.
  • route / router — a route is a pattern like /v1/tasks/{id}; the router is the code that matches an incoming request to the right handler.
  • handler — the function that actually answers one kind of request.
  • versioned API / /v1 — putting a version number in every URL so a future incompatible /v2 can exist without breaking today’s callers.
  • dependency — something a piece of code needs in order to work: the logger, the config, the database.
  • dependency injection — handing a piece of code the things it needs instead of letting it fetch them itself. Here it is one struct, and no framework.
  • global variable — a value any code in the package can read or change. Avoided here, for reasons this chapter argues.
  • timeout — a limit on how long something may take before we give up on it.
  • keep-alive connection — reusing one open connection for several requests instead of reconnecting each time.
  • file descriptor — the operating system’s numbered handle for an open file or connection. A process may only have so many; leak them and the server stops accepting anything.
  • slowloris — an attack that opens many connections and sends data one byte at a time, holding your server’s resources open.
  • structured logging — logging as labelled key=value fields instead of prose, so tools can filter and count them. Go’s log/slog package does this.
  • .gitignore — a list of files Git should pretend not to see: build output, secrets.
  • stub — a deliberately fake, minimal version of something written properly later, so the code compiles today.

3. The goal

A Git repository with the final folder structure already in place, a chi-powered HTTP server on port 4000, and a GET /v1/healthcheck endpoint returning JSON. Trivial on purpose — the value of this chapter is the layout, which we will never have to rearrange again.


4. The thinking

4.1 Where did I start? With the directory tree

Not with code. Rearranging packages after imports exist is miserable in Go, so the first real act is committing to a layout. Three candidate paths:

Option What it looks like Why it was rejected / chosen
Flat repo main.go, handlers.go, db.go all in the root Nadh-ish, fine for tools. A SaaS grows past it, and then you’re renaming import paths — the exact tedium we’re avoiding.
“Standard” project layout The sprawling pkg/, api/, build/ template you find on GitHub Mostly cargo cult. pkg/ in particular signals nothing: every folder is a package.
Edwards’ layout cmd/api for the executable, internal/ for private packages, migrations and SQL at the root Chosen. The smallest layout that still has a place for everything this book will add.
New word

cargo cult — copying the visible form of something successful without understanding what made it work. A folder structure you adopted because a popular repo had it is a cargo cult until you can say what each folder buys you.

Two details of path 3 are worth pausing on, because they come up forever.

cmd/api holds the code that becomes the runnable program. The name cmd is a convention meaning “commands”; api is our one command. If this project ever grew a second program — say a CLI admin tool — it would be cmd/admin, sharing everything under internal/.

internal/ is not a naming convention. It is a rule the Go compiler enforces: a package whose path contains internal/ can only be imported by code inside the same module. If somebody finds your project on GitHub and tries to import github.com/yourname/taskd/internal/data, their build fails. That is a feature. It means everything in internal/ is yours to reshape freely, forever, without being anyone’s dependency.

Think of it like

internal/ is a staff-only door that the building enforces, not just signposts. Other folder names ask nicely. This one is locked.

4.2 The second decision: how handlers reach their dependencies

This is subtler, and it shapes every file after this one.

A handler is a function that answers one HTTP request. But to do its job it needs shared machinery: somewhere to log, a database to query, settings to read. Three ways to give it that access:

Option A — global variables. Declare var logger = ... at the top of the package, outside any function, and every function in the package can use it:

// illustration only — this is the version we are rejecting, not a taskd file
package main

var logger *slog.Logger
var cfg config

func healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    logger.Info("healthcheck")     // where did this logger come from?
    // ... and what else in this program can change it?
}

It is easy to write, and it is the classic beginner trap. Any function can secretly read or change these. Nothing in the handler’s signature admits it uses a logger. And here is the wall you hit first, without needing to know anything about testing yet: there is no way to run healthcheckHandler with a different logger except by editing the source. The handler doesn’t take one. It reaches out and grabs the one global. If you want the version that writes to a file instead of the screen, you change the program.

Option B — a dependency-injection framework (wire, fx). Machinery to solve a problem Go doesn’t really have. Java cosplay.

Option C — Edwards’ pattern, the application struct. One plain struct holds every shared dependency. Every handler is written as a method on that struct (func (app *application) someHandler(...)), so inside the handler, app.logger and later app.db are just there — visible, typed, and swappable in tests by constructing a different application.

// illustration only — the real one arrives in Step 3
type application struct {
    config config
    logger *slog.Logger
}

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    app.logger.Info("healthcheck")   // came in through (app *application)
}

The bit in parentheses before the function name is called the receiver. Read it as “this function belongs to *application, and inside the body, that particular application is called app”. It does two jobs at once: it names the bag of dependencies inside the body, and it attaches the function to the type, which is what makes app.healthcheckHandler a legal thing to write elsewhere. (If receivers are still fuzzy, Go in one sitting derives them from scratch.)

We take Option C. It is dependency injection with zero magic, and it is the single most important pattern in this book: every handler you’ll write for the next 25 chapters hangs off this one struct.

Think of it like

The application struct is a workshop pegboard. One board, every tool hanging where any worker can reach it. Handlers don’t go hunting for the logger; they turn round and it’s on the board behind them. And when you want a worker to use different tools — a test logger, a fake database — you hand them a different board, and change nothing about how they work.

Remember this

A global is a tool nailed to the floor. A field on application is a tool on a board you can hand to someone else.


5. A picture of it

Two pictures, actually: the tree you’re about to create, and the path a request takes through it.

Here is the layout with a column saying which chapter fills each directory. Most of it is empty today; that is the point. You are laying out the shelves before the stock arrives.

taskd/
├── cmd/api/             program entry point + handlers      ch. 2  (now)
├── internal/data/       domain types, validation, plans     ch. 8
├── internal/db/         sqlc GENERATED code                 ch. 7
├── internal/validator/  tiny reusable validation helper     ch. 8
├── internal/cache/      DragonflyDB wrapper                 ch. 13
├── migrations/          ordered .up.sql / .down.sql files   ch. 5
├── sql/queries/         the SQL that sqlc compiles          ch. 7
├── bin/                 build output (gitignored)           app. B
├── Makefile             one-word commands for everything    ch. 5
├── sqlc.yaml            tells sqlc where to look            ch. 7
├── config.toml          settings, not secrets               ch. 3
├── Dockerfile           how to build the image              ch. 25
└── docker-compose.yml   Postgres, Dragonfly, Mailpit        ch. 5

And here is the whole journey of one request, which is the mental model for the entire book. Read it top to bottom; the numbers are walked through underneath.

   curl                    http.Server            chi router      handler
    │                          │                      │              │
 1  ├── TCP connect :4000 ────▶│                      │              │
 2  ├── GET /v1/healthcheck ──▶│                      │              │
    │                          │ parse into *Request  │              │
 3  │                          ├── srv.Handler ──────▶│              │
    │                          │                      │ match method │
    │                          │                      │ + path       │
 4  │                          │                      ├── call ─────▶│
    │                          │                      │              │ set
    │                          │                      │              │ header
    │                          │                      │              │ write
    │                          │                      │              │ body
 5  │                          │◀─────────────────────┴──────────────┤
 6  │◀── HTTP/1.1 200 OK ──────┤                                     │
    │    + JSON body           │                                     │
  1. curl opens a TCP connection to port 4000 on your machine.
  2. It sends the bytes of an HTTP request: a method, a path, some headers, a blank line.
  3. Go’s http.Server parses those bytes into an *http.Request value and hands it, along with a blank response to fill in, to whatever we set as srv.Handler — our chi router.
  4. chi looks at GET plus /v1/healthcheck, finds the match, and calls app.healthcheckHandler(w, r).
  5. The handler sets one header and writes the body.
  6. The server flushes the whole thing back down the connection and curl prints it.

Every request in this book follows that path. Later chapters only add layers around step four.


6. The steps

Step 1 — Create the module

A module is one named unit of Go code. Naming it is the first thing you do, because the name becomes the prefix of every import path inside it. Use your own path; the book uses a placeholder throughout.

mkdir taskd && cd taskd
git init
go mod init github.com/yourname/taskd

What these commands say, line by line

  • mkdir taskd && cd taskd — make a folder called taskd, and if that succeeded, move into it. && means “only if the previous command worked”.
  • git init — start recording this folder’s history. Git may print a hint about the default branch name; it is harmless.
  • go mod init github.com/yourname/taskd — creates go.mod, the file that names your module and records its dependencies. The name doubles as the import prefix: code in internal/data will be imported as github.com/yourname/taskd/internal/data.
Note

The name does not have to be a URL you own, and nothing is uploaded anywhere. It looks like a URL because that is how Go finds other people’s modules. For your own, it is just a unique name. Whatever you choose, use it consistently — if the module is github.com/me/taskd and you later type github.com/yourname/taskd/internal/data in an import, you get package github.com/yourname/taskd/internal/data is not in std.

What you should see: go mod init prints two or three lines starting with go: creating new go.mod: module github.com/yourname/taskd. Open go.mod and you will find three short lines: the module name, the Go version, and nothing else yet.

Step 2 — Lay down the full tree now

Empty directories are fine — Git ignores them until files land in them.

taskd/
├── cmd/api/               # the executable: main.go + handlers + middleware
├── internal/data/         # domain types, validation glue, plan definitions
├── internal/db/           # sqlc GENERATED code (never edit by hand)
├── internal/validator/    # tiny reusable validation helper
├── internal/cache/        # DragonflyDB wrapper (ch. 13)
├── migrations/            # *.up.sql / *.down.sql
├── sql/queries/           # the SQL that sqlc compiles
├── bin/                   # build output (gitignored)
├── Makefile
├── sqlc.yaml
├── config.toml
├── Dockerfile
└── docker-compose.yml

Create the directories in one go:

mkdir -p cmd/api internal/data internal/db internal/validator internal/cache \
         migrations sql/queries bin

mkdir -p creates parent folders as needed and does not complain if a folder already exists. The backslash at the end of the first line means “the command continues on the next line”.

Don’t create the five files at the bottom of the tree yet — each arrives in the chapter that needs it, listed in the table in section 5.

Note

Two directories join this tree later and are not in the original listing: internal/mailer/ in Chapter 21 (Background work and email) and cmd/api/docs/ in Chapter 24 (OpenAPI). The promise is that we never rearrange what’s here — not that nothing is ever added.

Step 3 — Install chi and write main.go

chi is a router: the piece that looks at an incoming request’s method and path and picks the handler. We install it as a dependency of the module.

go get github.com/go-chi/chi/v5@latest

This downloads chi, records it in go.mod, and writes a checksum for it into a new file go.sum. Both files are yours; both get committed.

Now the program itself. Note the shape: main only wires things together and delegates; real logic never lives in main.

// cmd/api/main.go — new file
package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "time"
)

// version is reported by the healthcheck; ch. 25 stamps the real git
// commit in here at build time.
const version = "0.1.0"

// config holds every runtime setting. Hardcoded for now; ch. 3 replaces
// this with a real loader (file + environment variables).
type config struct {
    port int
    env  string // "development" or "production"
}

// application is the dependency container described above. Every handler
// and middleware in this book is a method on *application, which is how
// they all get access to the logger, config, and (soon) the database.
type application struct {
    config config
    logger *slog.Logger
}

func main() {
    // 1. Build the config.
    var cfg config
    cfg.port = 4000
    cfg.env = "development"

    // 2. Build the logger. slog is the standard library's structured
    //    logger: instead of free-form text it logs key=value pairs,
    //    which tools can filter and parse.
    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    // 3. Build the application struct, handing it its dependencies.
    //    The & means "give me a pointer to this struct" — all methods
    //    share the one instance rather than copies of it.
    app := &application{config: cfg, logger: logger}

    // 4. Describe the HTTP server.
    srv := &http.Server{
        Addr:    fmt.Sprintf(":%d", cfg.port), // ":4000"
        Handler: app.routes(),                 // the router (next step)

        // These three timeouts are explained below. Never skip them.
        IdleTimeout: time.Minute,
        ReadTimeout: 5 * time.Second,
        WriteTimeout: 10 * time.Second,

        // Route the http package's internal error messages into our
        // structured logger instead of raw stderr.
        ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
    }

    // 5. Start serving. ListenAndServe blocks forever; it only returns
    //    when the server stops, and then always with an error.
    logger.Info("starting server", "addr", srv.Addr, "env", cfg.env)
    err := srv.ListenAndServe()
    logger.Error(err.Error())
    os.Exit(1)
}

What this code says, line by line

  • package main — this package produces a runnable program. Every file in cmd/api/ will start with this same line; Go treats all files in one folder as one package.
  • The import ( ... ) block lists the packages this file uses. All five here ship with Go itself (the standard library) — nothing was downloaded for them. If you list one and don’t use it, the build fails with "fmt" imported and not used. That is deliberate: Go refuses to let dead references accumulate.
  • const version = "0.1.0" — a value that can never change while the program runs.
  • type config struct { ... } — defines a new type: a small box with two labelled slots. port is an int (whole number), env is a string (text). Lowercase names are private to this package; that is Go’s entire visibility system — capital letter means visible outside the package.
  • var cfg config then cfg.port = 4000var cfg config creates a config and fills it with zero values: 0 for the int, "" for the string. Go has no uninitialised memory. The two following lines then set the fields.
  • logger := slog.New(...):= is declare-and-assign in one step: it creates logger and works out its type from the right-hand side. Use it inside functions; use var when you want the zero value first.
  • slog.NewTextHandler(os.Stdout, nil) — a handler in the slog sense is the thing that decides what a log line looks like and where it goes. This one writes key=value text to standard output. The nil is “no extra options”. Note the word collision: an slog handler and an HTTP handler are unrelated ideas that share a name.
  • app := &application{config: cfg, logger: logger} — build one application and take its address. & means “a pointer to this”, so every method later shares this exact instance rather than a copy of it.
  • srv := &http.Server{...} — we don’t call a function that starts a server; we describe one by filling in a struct, then start it. Every field we leave out gets its zero value, which is why the timeouts below matter so much.
  • fmt.Sprintf(":%d", cfg.port) — builds a string from a template. %d means “put a number here”. The result is ":4000", which reads as “listen on port 4000 on every network interface”.
  • time.Minute and 5 * time.Second — Go has a type for durations, and you write them by multiplying the named units. 5 * time.Second is five seconds, not the number 5.
  • slog.NewLogLogger(logger.Handler(), slog.LevelError) — an adapter. http.Server predates slog and wants an older-style logger; this wraps our slog handler in that older shape so the server’s own complaints land in the same stream, tagged as errors, instead of leaking to raw stderr in a different format.
  • err := srv.ListenAndServe() — this call blocks: it does not return while the server is running. If it returns at all, something ended, so the next two lines log the reason and exit with status 1 (a non-zero exit status is how a program tells the operating system it failed).
New word

standard library — the code that ships with Go itself, needing no download. net/http, log/slog, fmt, os and time are all standard library. chi is not.

Step 3a — Those three timeouts are not decoration

  • ReadTimeout: 5s — max time a client gets to send its request. Without it, an attacker can open a connection and drip one byte per minute, holding your server’s resources open indefinitely (the “slowloris” attack).
  • WriteTimeout: 10s — max time we get to write the response. A dead client can’t pin a handler forever.
  • IdleTimeout: 1m — how long a keep-alive connection may sit unused before we close it. Without it, dead connections pile up until you run out of file descriptors.

Here is which slice of a connection’s life each one bounds:

  accepted   headers read   body read   handler done   response sent   next req
     │            │             │            │              │             │
     ├────────────┴─────────────┤            │              │             │
     │   ReadTimeout: 5s        │            │              │             │
     │   client must FINISH SENDING by here  │              │             │
     │                                       │              │             │
     │            ├─────────────────────────────────────────┤             │
     │            │  WriteTimeout: 10s — we must FINISH REPLYING by here  │
     │                                                      │             │
     │                                                      ├─────────────┤
     │                                                      │ IdleTimeout │
     │                                                      │ 1m          │

And here is what slowloris looks like on the wire — one client, one connection, no rush:

  a normal client              a slowloris client
  ───────────────              ──────────────────
  GET /v1/healthcheck ...      G
  Host: localhost              (waits)
  <blank line ends headers>    E
  finished in ~2 ms            (waits)
                               T
                               ... never sends the blank line, ever

The server cannot start work until the headers are complete, so it waits — and each waiting connection costs a file descriptor and a little memory. A few thousand of those, from one laptop, and a server with no ReadTimeout has nothing left for real users.

The dangerous part: Go’s defaults for all three are zero, which means no timeout at all. Set them on day one, forever.

Warning

“It’s just development, I’ll add timeouts before we ship” is how servers ship without timeouts. The defaults are silent — nothing warns you, and everything works perfectly until the day it doesn’t.

Step 4 — Routes live in their own file

One place to see the whole API surface.

// cmd/api/routes.go — new file
package main

import (
    "net/http"

    "github.com/go-chi/chi/v5"
)

// routes builds and returns the router: the object that looks at each
// incoming request's method + path and decides which handler runs.
func (app *application) routes() http.Handler {
    r := chi.NewRouter()

    // What happens when no route matches. (notFoundResponse gets its
    // real implementation in ch. 8; ch. 4 adds a temporary stub so
    // the project compiles until then.)
    r.NotFound(func(w http.ResponseWriter, r *http.Request) {
        app.notFoundResponse(w, r)
    })

    // Everything lives under /v1 so a breaking /v2 can exist someday
    // without disturbing old clients.
    r.Route("/v1", func(r chi.Router) {
        r.Get("/healthcheck", app.healthcheckHandler)
    })

    return r
}

What this code says, line by line

  • func (app *application) routes() http.Handler — a method on *application (so it can reach app.healthcheckHandler), taking nothing, returning an http.Handler.
  • http.Handler is an interface: a description of a capability rather than a concrete type. Any value that knows how to answer an HTTP request qualifies. We promise to return something that can answer requests, and main doesn’t care what it actually is. That is why swapping chi for something else later would touch this one line.
  • r.NotFound(func(w http.ResponseWriter, r *http.Request) { ... }) — the argument is a function with no name, written inline. Go lets you pass functions around like any other value. An inline function like this can also see the variables around it — which is exactly why it can say app.
  • app.notFoundResponse does not exist yet. Step 5 creates it. If you run the program now you get ./routes.go:14:13: app.notFoundResponse undefined (type *application has no field or method notFoundResponse) — that is expected, not a mistake on your part.
  • r.Route("/v1", func(r chi.Router) { ... }) — everything registered inside this function gets /v1 glued to the front. Note the inner r shadows the outer one; it is a sub-router, and that is chi’s normal idiom.
  • r.Get("/healthcheck", app.healthcheckHandler) — register: method GET, path /v1/healthcheck, run this. Notice there are no parentheses after healthcheckHandler. We are handing over the function itself, not calling it.
  • return r — the chi router satisfies http.Handler because it has the right method, with no declaration anywhere saying so. Go’s interfaces are satisfied implicitly.

Step 5 — The temporary error stub

routes.go calls app.notFoundResponse, which the book writes properly in Chapter 8 (CRUD done properly). To keep the build green until then, create a deliberately crude stub:

// cmd/api/errors.go — TEMPORARY: fully replaced in chapter 8.
package main

import "net/http"

func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.logger.Error("server error", "error", err, "path", r.URL.Path)
    http.Error(w, "the server encountered a problem", http.StatusInternalServerError)
}

func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "the requested resource could not be found", http.StatusNotFound)
}
Note

This file is printed here for the first time. The original edition introduced it in Chapter 4, two chapters after routes.go starts calling it — meaning a reader typing along in Chapter 2 hit a hard stop. It has been moved to where it is first needed. When Chapter 4 (A server that dies well) says “create this stub”, you already have it; check it matches and move on.

serverErrorResponse is unused today — nothing calls it yet, and Go is fine with an unused method (unlike an unused local variable or import). Chapter 4’s middleware is its first caller.

http.Error is a one-line standard-library helper: it writes a plain-text message with the status code you give it. http.StatusNotFound is a named constant for 404 — the same number, spelled so you can read it.

New word

stub — a deliberately fake, minimal version of something written properly later, so the code compiles today. A cardboard cut-out on the set until the real prop arrives. Stubs are safe only when they are loud: note the shouting comment on line 1.

Step 6 — The first handler

Read the signature slowly, because you’ll write it fifty more times: w http.ResponseWriter is the pen you write the response with; r *http.Request is everything about the incoming request (method, URL, headers, body).

   r *http.Request  — the envelope that arrived
   ┌──────────────────────────────────────────────┐
   │ r.Method      "GET"                          │
   │ r.URL.Path    "/v1/healthcheck"              │
   │ r.Header      Accept: */*   User-Agent: curl │
   │ r.Body        (empty, for a GET)             │
   └──────────────────────────────────────────────┘

   w http.ResponseWriter — the blank reply you fill in, in this order
   ┌──────────────────────────────────────────────┐
   │ w.Header().Set(k, v)   headers    — FIRST    │
   │ w.WriteHeader(code)    status     — SECOND   │
   │ w.Write(bytes)         body       — LAST     │
   └──────────────────────────────────────────────┘
// cmd/api/healthcheck.go — new file
package main

import (
    "fmt"
    "net/http"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    // Tell the client the body is JSON — before writing the body,
    // because headers must be sent first.
    w.Header().Set("Content-Type", "application/json")

    // %q prints a string wrapped in quotes, i.e. valid JSON strings.
    fmt.Fprintf(w, `{"status":"available","environment":%q,"version":%q}`,
        app.config.env, version)
}

What this code says, line by line

  • The order in the diagram is not a style preference, it is how HTTP works: headers travel down the wire before the body, so once a single byte of body has been written, the headers are gone and changing them does nothing.
  • We never call w.WriteHeader. Go notices the first Write and sends 200 OK for us. Any other status code has to be said out loud.
  • The backticks around {"status":...} make a raw string literal: everything between them is taken literally, so the double quotes inside need no escaping. That is why hand-writing JSON is bearable at all here.
  • fmt.Fprintf(w, template, a, b) — format template, filling each verb from the arguments in order, and write the result to w. The verbs this book uses:
Verb Means Example result
%d a whole number 4000
%s a string, as-is development
%q a string, in double quotes "development"
%v any value, default format varies
%w wrap an error (Chapter 3)
%% a literal percent sign %
  • app.config.env and version — the payoff of the receiver. env came from the struct hanging off app; version is the package-level constant. No arguments were threaded through to get either.

(Yes, hand-writing JSON with Fprintf is a crime. We commit it exactly once, so that Chapter 8’s writeJSON helper has a crime to solve. Chapter 8 replaces this whole body with a call to app.writeJSON(w, http.StatusOK, envelope{...}, nil).)

Step 7 — Run and verify

go run ./cmd/api

go run compiles the package in the folder you give it and immediately runs the result. The ./ matters: it means “the folder at this path”, not “a package published somewhere on the internet”. The program does not exit — that is ListenAndServe blocking, exactly as designed. Leave it running and open a second terminal.

go run ./cmd/api
# time=2026-... level=INFO msg="starting server" addr=:4000 env=development

curl -i localhost:4000/v1/healthcheck
# HTTP/1.1 200 OK
# {"status":"available","environment":"development","version":"0.1.0"}

curl is a browser with no window: it sends one HTTP request and prints the reply. -i means “include the response headers”, not just the body.

The full reply is a status line, three header lines, a blank line, and the body:

HTTP/1.1 200 OK
Content-Type: application/json
Date: <the current time, so yours will differ>
Content-Length: 68

{"status":"available","environment":"development","version":"0.1.0"}

Content-Length: 68 is the body’s exact size in bytes; Go counted it for us because the whole response was small enough to buffer. Stop the server with Ctrl-C when you’re done.

Now trace what just happened, once, end to end — this is the mental model for the whole book: curl opened a TCP connection to port 4000 → srv accepted it and parsed the HTTP request → srv.Handler (our chi router) matched GET /v1/healthcheck → chi called app.healthcheckHandler(w, r) → the handler wrote headers and body through w → the server flushed it back down the connection. Every request in this book follows that path; later chapters only add layers around step four.

Remember this

Layers get added around the handler, never inside the plumbing. Authentication, rate limiting, metrics and logging are all wrappers on step four. That is why the picture never changes.

Step 8 — A .gitignore and first commit

A .gitignore lists files Git should pretend not to see. Two kinds of file belong on it: things you can rebuild, and things that must never leave your machine.

bin/
*.pdf
.env

One more line is needed, and this is the first place it can go:

.gitignore — add this line
.envrc
Note

This line is new in the beginner edition. Chapter 5 (PostgreSQL and migrations) creates a file called .envrc holding your database password, and later chapters describe it as ignored — but the original edition’s .gitignore only listed .env, which does not match .envrc. Adding it here, before the file exists, is the only way the promise is true when it matters.

Warning

A secret committed to Git is not fixed by deleting it in the next commit. The history keeps it. Get the ignore rule in place before the secret exists — which is what we just did.

Now make the first commit:

git add .
git commit -m "skeleton: chi server with healthcheck"

git add . stages every file in the current folder that isn’t ignored; git commit -m "..." records them as one snapshot with that message. If Git complains that it doesn’t know who you are, it prints the two git config --global user.email / user.name commands to run — do those, then commit again.


7. Checkpoint: prove it works

With the server running in one terminal, run these three in another:

curl -i localhost:4000/v1/healthcheck
curl -i localhost:4000/v1/nope
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck
Checkpoint

The first prints HTTP/1.1 200 OK and the JSON body. The second prints HTTP/1.1 404 Not Found and the text the requested resource could not be found — your notFoundResponse stub doing its job. The third prints just 200.

The third command is worth keeping in your head: -s silences the progress meter, -o /dev/null throws the body away, and -w '%{http_code}\n' prints only the status code. It is the shortest way to ask “did that work?”.

If you got something else:

You saw Cause Fix
curl: (7) Failed to connect to localhost port 4000 The server isn’t running, or it exited on startup Look at the first terminal. If it printed a level=ERROR line, read it — that is ListenAndServe’s error
HTTP/1.1 404 Not Found on the healthcheck too Path typo, or the r.Get line is outside the r.Route("/v1", ...) block Compare against Step 4; the full path is /v1/healthcheck
{"status":"available","environment":"","version":"0.1.0"} cfg.env was never set before app was built In main, the two cfg. assignments must come before the &application{...} line
The body appears but with no Content-Type header w.Header().Set was written after the Fprintf Headers must be set before the first byte of body

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: ./main.go:41:18: app.routes undefined (type *application has no field or method routes) It means: you typed main.go and ran it before routes.go existed. Fix: write Step 4. Go compiles a whole package at once, so every referenced method must exist before anything runs.

Common mistake

You’ll see: main.go:9:2: no required module provides package github.com/go-chi/chi/v5; to add it: go get github.com/go-chi/chi/v5 It means: you imported chi without installing it. Fix: run exactly the command the error suggests, from the project root.

Common mistake

You’ll see: go: cannot find main module; see 'go help modules' It means: you’re in the wrong directory. There is no go.mod here or in any parent folder. Fix: cd back into taskd. Run pwd to see where you actually are, and ls go.mod to confirm.

Common mistake

You’ll see: time=... level=ERROR msg="listen tcp :4000: bind: address already in use", then the program exits. It means: something is already listening on port 4000 — almost always a copy of this server you forgot to stop. Fix: lsof -i :4000 lists the process and its PID; kill <PID> stops it. You will meet this error in twenty later chapters, so learn those two commands now.

Common mistake

You’ll see: ./main.go:12:2: declared and not used: logger It means: you created a variable inside a function and never used it. Go treats that as a bug, not a warning. Fix: use it or delete it. The same rule applies to imports: "fmt" imported and not used.

Two silent ones — no error message, wrong behaviour:

  • Setting a header after writing the body. The header is ignored. Nothing complains. Always set headers first.
  • A module name that doesn’t match your later imports. You won’t notice until Chapter 6, where you get package github.com/yourname/taskd/internal/db is not in std. Fix it now by checking go.mod matches what you plan to type.

9. Pitfalls

  • internal/ misunderstanding. It is not a naming convention; the toolchain hard-blocks imports of internal/... from outside your module. Use it for everything that isn’t a deliberate public library. The practical consequence: you can rename, split and gut anything under internal/ without ever being someone else’s breaking change.

  • Handlers as free functions + globals. It works until the first test, where you discover you can’t substitute the logger or DB. The application-struct pattern costs one line per handler (func (app *application) ...) and buys you testability forever. Chapter 20 (Testing what matters) is where that bill would have come due.

  • Skipping server timeouts because “it’s just dev”. Defaults are zero, meaning no timeout. The day this hits production unchanged is the day you learn what a file descriptor leak smells like: the process is alive, healthy by every metric you have, and refusing every new connection with accept: too many open files.

  • Editing generated or vendored directories. internal/db will soon be machine-written by sqlc. Decide now that generated directories are read-only; hand-edits there are silently destroyed on the next generate. Chapter 7 (sqlc: SQL in, type-safe Go out) puts real code in there.


10. Check yourself — quiz

  1. Why can’t someone else’s project import your internal/data package? Is that a convention or a rule?
  2. What does go mod init github.com/yourname/taskd actually create, and what else does that name get used for?
  3. In func (app *application) healthcheckHandler(...), what do the parts before the function name do? Name both jobs.
  4. What is Go’s default value for ReadTimeout, and what does that default mean in practice?
  5. Which of the three timeouts is the one that stops a slowloris attack, and why not the other two?
  6. srv.ListenAndServe() is followed immediately by logger.Error(err.Error()) with no if err != nil. Why is that correct here?
  7. When a request for GET /v1/healthcheck arrives, what does chi do with it?
  8. The healthcheck writes JSON with fmt.Fprintf. The book calls this a crime and does it anyway. What is the crime, and why commit it?
Answers
  1. It is a rule the compiler enforces, not a convention. Any import path containing an internal/ element may only be imported from within the module that contains it. A stranger’s build fails outright. This is what makes everything under internal/ free to reshape.

  2. It creates go.mod, which names the module and will record its dependencies (chi lands there in Step 3). The name is also the import prefix: every package inside the module is addressed as that name plus its folder path.

  3. (app *application) is the receiver, and it does two things. It attaches the function to the *application type — which is what makes app.healthcheckHandler a legal expression in routes.go — and it names the dependency container inside the body, which is how app.config.env resolves. Delete it and you break both at once.

  4. Zero — which for these fields means no timeout at all, not “one second” or “some sensible library default”. A client that never finishes sending is waited on forever.

  5. ReadTimeout. Slowloris attacks the sending phase: it opens a connection and never completes its request headers. WriteTimeout bounds our reply, which never begins here, and IdleTimeout bounds connections between requests, and this one never completes a first request.

  6. Because ListenAndServe blocks while the server is running and only returns when it stops, always with a non-nil error. Reaching the next line already means something went wrong, so there is nothing to test. Chapter 4 revisits this, because a deliberate shutdown also produces an error value, and it must not be treated as a failure.

  7. It compares the request’s method (GET) and path (/v1/healthcheck) against its registered routes, finds the one registered inside the /v1 sub-router, and calls app.healthcheckHandler(w, r) with the response writer and the parsed request. If nothing matched, it calls the function given to r.NotFound instead.

  8. The crime is building JSON by string formatting. Nothing escapes special characters for you, the status code is implicit, and every future handler would copy the pattern. We do it once so that Chapter 8’s writeJSON helper is introduced as the fix to a problem you have personally felt, rather than as a helper you’re told to trust.


11. Practice

Exercise 1 — Break the receiver on purpose

In cmd/api/healthcheck.go, delete (app *application) from the handler’s signature so it reads func healthcheckHandler(w http.ResponseWriter, r *http.Request). Run go build ./.... Read the errors. Then restore it and explain, in one sentence, why two separate things broke.

Solution

Restore func (app *application) healthcheckHandler(...).

Two failures, because the receiver does two jobs at once. Without it, app is an undefined identifier inside the body where app.config.env is used, and app.healthcheckHandler is no longer a method on the type, so routes.go can’t refer to it:

./healthcheck.go:14:9: undefined: app
./routes.go:20:24: app.healthcheckHandler undefined (type *application has no field or method healthcheckHandler)

Verify the repair:

go build ./... && echo BUILD-OK
# BUILD-OK

This is the chapter’s thesis in one experiment. The receiver is the hook every handler hangs on.

Exercise 2 — Add a second endpoint, all four moves

Add GET /v1/version returning {"version":"0.1.0"}. Do it the book’s way: a new file, a method on *application, one line in routes.go. Do not touch healthcheck.go.

Solution
// cmd/api/version.go — new file
package main

import (
    "fmt"
    "net/http"
)

func (app *application) versionHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w, `{"version":%q}`, version)
}
// cmd/api/routes.go — add this line inside r.Route("/v1", ...)
r.Get("/version", app.versionHandler)

Two things worth naming. The file name is irrelevant to Go — every file in cmd/api is one package main, and splitting by topic is purely for humans. And %q prints a string wrapped in double quotes, which is why it produces valid JSON.

Verify:

go run ./cmd/api &
sleep 1
curl -s localhost:4000/v1/version
# {"version":"0.1.0"}
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/nope
# 404
kill %1

(& runs the server in the background; kill %1 stops it.)

Exercise 3 — Prove ReadTimeout is real

Predict what a client could do if each timeout were removed. Then demonstrate ReadTimeout with a client that starts a request and never finishes it.

Solution

The predictions:

Removed What a client can now do What it costs you
ReadTimeout: 5s Open a socket, dribble one byte per minute, never complete a request Each connection pins a goroutine and a file descriptor forever; a few thousand cheap connections exhaust the server
WriteTimeout: 10s Accept the response infinitely slowly, or vanish mid-response The handler blocks in w.Write indefinitely — later in the book, that also means holding a database connection
IdleTimeout: 1m Keep an unused keep-alive connection open forever File descriptors accumulate until accept: too many open files, which presents as a total outage with a confusing error

To demonstrate, send the start of a request and never send the blank line that ends the headers:

# with the server running, in another terminal
time ( { printf 'GET /v1/healthcheck HTTP/1.1\r\nHost: localhost\r\n'; sleep 8; } | nc localhost 4000 )

The server has an incomplete request, so it waits — and at five seconds it closes the connection. nc returns with no response body, and time reports roughly five seconds, not eight.

Now set ReadTimeout: 0 in main.go, restart, and run the same command. This time nc sits until its own sleep 8 ends: the server would have waited indefinitely. Put the 5 seconds back.

(If nc isn’t installed on your machine, the prediction table is the part that matters; skip the demonstration.)


12. FAQ

Why so many folders for one file? Because eleven of the thirteen entries are placeholders for things this book adds, and creating them now costs nothing while creating them later costs a round of import-path edits. The alternative — starting flat and reorganising at Chapter 8 — is the exact tedium section 4.1 rejected.

What is internal/ protecting me from? From your own future obligations. Anything importable by strangers becomes something you can’t change without breaking them. internal/ makes that impossible, so every design decision under it stays reversible. It protects nothing at runtime — it is not a security feature.

Why is main allowed to be this dumb? Because it is the one function that can’t be tested, reused, or called from anywhere else. Anything that lives in main is effectively unreachable to the rest of the program. Keeping it to “build the dependencies, hand them over, start” means everything of value lives somewhere a test can reach it. Watch main in Chapter 4 — it gets smaller while the program gets bigger.

Can I put handlers in main.go? Yes, and the compiler will not care in the slightest — every file in cmd/api is the same package, and file boundaries mean nothing to Go. They mean a great deal to you at Chapter 22, when there are thirty handlers. Splitting by topic is a convenience for humans, and it costs nothing.

Why hand-write JSON if it’s wrong? Because you will believe Chapter 8’s helper is worth the code only after you have felt what it replaces. Doing it wrong once, on the smallest possible payload, is cheaper than being told to trust a helper you can’t yet evaluate. It stays wrong for exactly six chapters.

Why do I have to set timeouts myself — isn’t that the library’s job? It would be, if Go could pick numbers that suit every server. A file upload service needs a different ReadTimeout from a JSON API, and a library that guessed would break someone silently. Go chose to make the absence obvious rather than the value wrong. The cost is that you must know the defaults are zero — which is why this chapter says it three times.


13. Where we are

A running server with the final layout. Config is hardcoded and logs are plain text — both fixed next.

The repository as it now stands (+ marks files that exist; the rest are still empty folders):

taskd/
├── cmd/api/
│   ├── main.go          +  config, application struct, server, timeouts
│   ├── routes.go        +  the router, /v1 group, NotFound
│   ├── healthcheck.go   +  the one handler
│   └── errors.go        +  TEMPORARY stub, replaced in ch. 8
├── internal/data/
├── internal/db/
├── internal/validator/
├── internal/cache/
├── migrations/
├── sql/queries/
├── bin/
├── go.mod               +  module name and dependencies
├── go.sum               +  checksums, written by go get
└── .gitignore           +  bin/, *.pdf, .env, .envrc

What works end to end: go run ./cmd/api starts a server on port 4000, GET /v1/healthcheck returns real JSON with a 200, and any other path returns a 404 in plain text.

What is still fake: the port and environment are hardcoded in main.go (Chapter 3 replaces them with a config file plus environment variables). The JSON is built by string formatting (Chapter 8). errors.go returns plain text, not JSON, and is labelled temporary. There is no database, no authentication, and no graceful shutdown — pressing Ctrl-C right now kills the process mid-request if there is one (Chapter 4).

For your notes — copy these into learnings/ch02.md in your own words:

  1. In Go, a folder is part of a package’s address. Decide the layout before anything imports anything, because the compiler makes you fix every reference when you move a folder.
  2. internal/ is enforced by the compiler, not by etiquette. Everything under it stays yours to change forever.
  3. The application struct is the pegboard: one value holding the logger, config, and later the database, with every handler written as a method on it. That receiver is how a handler reaches anything shared, and it is the pattern behind every remaining chapter.
  4. http.Server’s three timeouts default to zero, which means no limit. ReadTimeout bounds the client’s sending, WriteTimeout bounds our replying, IdleTimeout bounds an unused keep-alive connection. Set all three on day one.
  5. Every request follows the same path: connection → http.Server parses → router matches → handler writes through w. Everything later in this book is a layer added around the handler, never a change to that path.

Chapter 3 — Configuration and logging, the Nadh way

Right now two facts about your server are typed into its source code: the port number and the word "development". That is fine for a program that only ever runs on your laptop. It is not fine for one that also has to run on a server, in a container, talking to a real database with a real password. This chapter moves every setting out of the code into a file, lets any of them be replaced by an environment variable at startup, and switches the logs from “text a human reads” to “text a machine can search” when the environment says so. When you finish, the same compiled program will behave differently on your laptop and in production without a single line of it changing.

What you’ll be able to do by the end

  • Keep every runtime setting in one committed config.toml and read it into a typed Go struct.
  • Override any setting from the environment — TASKD_APP__PORT=9999 — with no rebuild, and explain exactly why that works, with no appeal to magic.
  • Say where a production database password lives, and why it is not in your repository.
  • Switch your logs between human-readable text and machine-readable JSON with one config value.
  • Recognise the four ways this wiring goes wrong, and read the error message each one produces.

Time: ~40 minutes reading, ~25 minutes typing.

You need before starting: the working server from Chapter 2 (The skeleton: a server that answers). Prove it still runs — start it in one terminal:

go run ./cmd/api

and in another terminal:

curl -i localhost:4000/v1/healthcheck

You should get HTTP/1.1 200 OK and a line of JSON. Stop the server with Ctrl-C. If that did not work, fix Chapter 2 first; everything below edits those same files.


1. The problem, in plain words

Your program needs to know things that are not part of what it does: which port to listen on, where the database lives, the password to open it, which log level to print, and later a fistful of Stripe keys. Call all of that configuration.

The beginner move is to type those values straight into the source, the way Chapter 2 typed cfg.port = 4000. That is hardcoding, and it has three costs that arrive in order.

Cost one: every change is a rebuild. Moving to port 8080 means editing a .go file, compiling, and shipping a new binary. The thing you changed is not code. It should not need a compiler.

Cost two: the same binary cannot run in two places. Your laptop’s database is localhost:5432 with the password pa55word. The production database is somewhere else with a password nobody should ever type twice. If both live in the source, you need two different builds of the same program, and now you have to remember which is which.

Cost three, the expensive one: your production password ends up on GitHub. Not through carelessness — through the ordinary act of committing a file that has the password in it because that is where the program reads it from. Git keeps history, so deleting it tomorrow does not remove it. That secret is now permanently in the repository and must be rotated everywhere it is used.

Why this exists

Every fix for this is a version of the same sentence: settings live outside the code, and the code reads them at startup. The rest of this chapter is about where outside, and how two “outsides” combine without either one winning by accident.

There is a second, smaller problem hiding in the same chapter. Chapter 2’s logger prints lines like msg="starting server" addr=:4000, which is pleasant to read at 11pm with one server. In production you have thousands of those lines an hour and you want to ask questions of them: show me every error from the last ten minutes, count requests by status code. Prose does not answer questions. Labelled fields do. So the logger needs to speak text when a human is watching and JSON when a machine is — and it should decide that from the same configuration as everything else.


2. New words in this chapter

  • configuration — every setting that differs between your laptop and production: ports, addresses, passwords, keys.
  • hardcoding — writing a setting directly into the source, so changing it means editing and rebuilding the program. Painting the price onto the wall.
  • TOML — a plain-text settings-file format built to be read by humans: key = value lines grouped under [section] headings.
  • environment variable — a named value the operating system hands to a program when it starts. A note pinned to the door before the shop opens.
  • prefix assignment — the shell syntax NAME=value command, which sets an environment variable for that one run of that one command and nothing else.
  • override / precedence — when two sources set the same setting, the rule deciding which wins. Here: environment beats file.
  • 12-factor — a widely-cited twelve-rule checklist for apps that run well in containers. The rule we use is “keep configuration in environment variables, not in code”.
  • koanf — the Go library we use to read config from several sources and merge them.
  • provider (koanf sense) — one source of settings that koanf can read: a file, the environment, a command line.
  • DSN — “Data Source Name”: one string containing everything needed to reach a database — type, username, password, host, port, database name, options. A full postal address on one line.
  • secret — a value that grants access if stolen: database passwords, Stripe keys, SMTP passwords. Never committed to Git.
  • redact — deliberately blanking a sensitive value before logging or displaying it.
  • fail fast — refusing to start at all when something essential is wrong, rather than half-running and failing later on some user’s request.
  • flag (command-line) — an option typed after the program name, e.g. -config config.toml.
  • structured logging — logging as labelled key=value fields instead of prose, so tools can filter and count them.
  • log level — how important a log line is (debug, info, warn, error), so production can keep the loud ones and drop the chatty ones.
  • handler (slog sense) — the object that decides what a log line looks like and where it goes. Unrelated to an HTTP handler; the two ideas share a word.
  • stdout / stderr — the two output streams every program has: normal output, and error output. Terminals show both; other tools can route them separately.
  • time.Duration — Go’s type for a length of time, written "15m" in config or 5 * time.Second in code.
  • blank identifier _ — a deliberate throwaway: “I must accept this value, and I am ignoring it on purpose.”
  • error wrapping (%w) — attaching context to an error while keeping the original inside it, so the message reads loading config.toml: no such file or directory.
  • nested anonymous struct — a struct field whose type is a struct written inline, with no name of its own. Used here purely to group settings: cfg.db.dsn.
  • Viper — the most popular Go configuration library; koanf exists because its author found Viper too large.
  • systemd unit — the Linux file describing how to start a background service. Cited below as the thing that gets unwieldy when all config arrives as command-line flags.
  • pre-commit hook — a script Git runs before each commit that can refuse it, e.g. if it spots something that looks like a secret. A bag check on the way out.

3. The goal

All runtime settings in one config.toml, every value overridable by environment variables (so containers stay 12-factor), parsed into a typed config struct with koanf. Logs switch to structured JSON via log/slog.


4. The thinking

4.1 Why configuration deserves a whole chapter

An API server needs a pile of settings — port, database connection string, cache address, Stripe keys, log level — and they differ between your laptop and production. The beginner move is hardcoding (dsn := "postgres://..." in the source), which means every change is an edit + recompile + redeploy, and your production database password ends up committed to GitHub. The fix is always some form of: settings live outside the code; the code reads them at startup.

4.2 Three shapes of “outside the code”

All three are defensible, and real production systems ship each of them.

Option What it looks like Verdict
1. Flags only ./api -port=4000 -db-dsn=... -db-max-conns=25 ... Edwards’ choice in Let’s Go Further. Explicit, zero dependencies. But by the time you have DB DSN + pool sizes + cache addr + four Stripe keys + limiter settings, your systemd unit is a ransom note.
2. Env vars only TASKD_PORT=4000 TASKD_DSN=... ./api 12-factor purity; miserable for local dev — untyped, undiscoverable, scattered across shells. Nothing in the repo tells a newcomer which variables exist.
3. File + env overrides a committed config.toml, overridden per-value by TASKD_* Chosen. The file documents every knob with sane dev defaults; production overrides secrets and endpoints via env.

Option 3 is exactly how listmonk works — a widely used open-source newsletter application written by Kailash Nadh, whose temperament this codebase borrows (the preface introduces both him and Alex Edwards). It uses koanf, Nadh’s own configuration library, built because Viper’s kitchen-sink design and dependency graph annoyed him. Poetic justice: we use the minimalist’s config library in the minimalist’s slot.

New word

systemd unit — on Linux, the small file that tells the machine how to start your service at boot. Every command-line flag your program needs has to be spelled out on one line in there. Ten flags is a paragraph nobody wants to edit over SSH at 2am.

Option 3 gives us a layered hierarchy: the file is the base, the environment is the override. That picture is section 5, because it is the whole idea and deserves a page of its own.

On your laptop, only the file exists, so you get the dev defaults. In production, the container sets TASKD_STRIPE__SECRET_KEY=sk_live_... and that value wins — no code edit, no file edit, no rebuild.

4.3 Logging: why the standard library wins now

For logging: log/slog is in the standard library since Go 1.21, does structured levels and JSON, and removes the once-obligatory zap/zerolog debate. Third-party loggers now need to justify themselves; for an API server they can’t. Text handler in dev (human eyes), JSON in prod (machine eyes) — decided by the same config.

New word

zap / zerolog — two popular third-party Go logging libraries, both older than log/slog. They exist because for years the standard library had nothing structured. Now it does, and the choice costs you a dependency for a benefit you can’t measure on an API server.

4.4 The secrets rule

Secrets rule, decided now and never revisited: secrets (DB password, Stripe keys) may live in config.toml only on your laptop. The committed file carries dev-container defaults and empty strings; production injects real values through env vars. Never commit a real secret; add a pre-commit hook if you don’t trust future-you.

Warning

The dev DSN you are about to commit contains the password pa55word. That is deliberate and safe: it is the password of a throwaway Postgres container that Chapter 5 (PostgreSQL and migrations) creates on your own machine, reachable by nothing outside it. The rule is not “no password-shaped strings in Git”. It is “no password that opens anything real”.


5. A picture of it

Three pictures. The first is the whole idea; the second is the mechanism people find magical; the third is the payoff.

Picture 1 — the layer stack. Two sources, and a rule about which wins.

┌──────────────────────────────────────────┐
│ environment variables    (HIGHEST prio)  │    e.g. TASKD_APP__PORT=8080
├──────────────────────────────────────────┤
│ config.toml              (base defaults) │    e.g. port = 4000
└──────────────────────────────────────────┘

Underneath that stack, koanf keeps one flat table of keys and values. Loading is not “reading two files and choosing”; it is writing into the same table twice, in a fixed order:

 step 1: load the file        step 2: load TASKD_*       the merged table
 ┌───────────────────────┐    ┌──────────────────────┐   ┌────────────────┐
 │ app.env  = development│    │                      │   │ development    │
 │ app.port = 4000       │ +  │ app.port = 9999      │ = │ 9999  ◀ changed│
 │ db.dsn   = postgres://│    │                      │   │ postgres://    │
 └───────────────────────┘    └──────────────────────┘   └────────────────┘
   every key in the file        only the keys the          later write wins,
                                environment happens        one key at a time
                                to set
  1. The first load fills the table from config.toml. Every setting now has a value.
  2. The second load writes only the keys the environment mentions. Anything it does not mention is untouched, which is why you override one setting without restating the other thirteen.
  3. Nothing “chooses” between the two. The second write lands on top of the first.
Think of it like

The file is the printed timetable on the wall: complete, public, correct most days. The environment is a handwritten note taped over one line of it this morning. You do not reprint the timetable to change one shift, and nobody has to guess which version is current — the note is on top.

Picture 2 — how a shouty variable name becomes a quiet config key. This is the part beginners read as magic. It is two ordinary string operations meeting in the middle.

  the shell says                                the file said
  ──────────────                                ─────────────
  TASKD_DB__DSN=postgres://…                    [db]
        │                                       dsn = "postgres://…"
        │ 1. TrimPrefix "TASKD_"                      │
        ▼                                             │ section + key,
  DB__DSN                                             │ joined with "."
        │ 2. ToLower                                  │
        ▼                                             │
  db__dsn                                             │
        │ 3. ReplaceAll "__" → "."                    │
        ▼                                             ▼
  db.dsn ◀──────────── the same key ──────────────────┘

Both roads arrive at the string db.dsn. The file got there first; the environment arrives second and overwrites. That is the entire override feature.

Picture 3 — one binary, three environments. Nothing below is recompiled. Only the surrounding environment differs.

       laptop                  CI / tests              production
  ┌────────────────┐      ┌────────────────┐      ┌────────────────────┐
  │ (no TASKD_ set)│      │ TASKD_DB__DSN= │      │ TASKD_APP__ENV=    │
  │                │      │   …/taskd_test │      │   production       │
  └───────┬────────┘      └───────┬────────┘      │ TASKD_STRIPE__…=…  │
          │                       │               └─────────┬──────────┘
          ▼                       ▼                         ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │                    the same compiled binary                      │
  └──────────────────────────────────────────────────────────────────┘
          │                       │                         │
          ▼                       ▼                         ▼
   port 4000, text logs,    the test database,      port 4000, JSON logs,
   empty Stripe keys        text logs               live Stripe keys

Chapter 25 (Docker) is where the right-hand column becomes real: the container image ships this same config.toml, and Compose sets the TASKD_* variables around it.


6. The steps

Step 1 — Install koanf

koanf reads settings from one or more providers (a file, the environment) and merges them into a single lookup table. It is deliberately split into small modules so you download only the providers and parsers you use: here, one for TOML files and one for environment variables.

go get github.com/knadh/koanf/v2 \
       github.com/knadh/koanf/parsers/toml \
       github.com/knadh/koanf/providers/file \
       github.com/knadh/koanf/providers/env

What this command says

  • go get <module> downloads a module, records it in go.mod, and writes its checksum into go.sum. Both files are yours and both get committed.
  • The backslashes mean “this command continues on the next line”. You can type it all on one line.
  • Four separate modules, one job each: the koanf core (/v2), a parser that understands TOML text, a provider that reads a file from disk, and a provider that reads environment variables.

What you should see: a few go: downloading ... lines the first time, then one go: added ... line per module. Open go.mod afterwards and you will find the four modules in the require block, plus six more marked // indirect — modules that koanf itself depends on. Indirect dependencies are things you did not ask for but your dependencies did; Go records them so builds are reproducible.

Note

This edition was written against koanf/v2 v2.1.2 with parsers/toml, providers/file and providers/env all at v0.1.0. go get without a version takes the newest, which is normally what you want. If a future version changes something, go.mod is where you pin it back.

Step 2 — Write config.toml, and commit it

This file is the documentation of every knob the program has. It lives at the project root, next to go.mod, and it goes into Git — that is the point of it. Secrets are empty strings; the only password in here belongs to a local container that does not exist yet.

# config.toml — dev defaults. Production overrides via TASKD_* env vars.
[app]
env  = "development"
port = 4000

[log]
level = "debug"           # debug | info | warn | error

[db]
dsn               = "postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable"
max_conns         = 25
max_idle_time     = "15m"

[cache]
addr = "localhost:6379"

[limiter]
enabled = true

[stripe]
secret_key            = ""
webhook_secret        = ""
price_id_pro          = ""
price_id_business     = ""
success_url           = "http://localhost:4000/v1/billing/success"
cancel_url            = "http://localhost:4000/v1/billing/cancel"

TOML in sixty seconds — everything you need to read that file:

  • A line starting with # is a comment, ignored.
  • [app] opens a table (a section). Every key = value line after it belongs to that section until the next [...]. Full name of a setting = section, a dot, key: app.port.
  • Text values need double quotes: env = "development".
  • Numbers and true / false are written bare, with no quotes: port = 4000, enabled = true.
  • Extra spaces around = are only for alignment. port=4000 and port = 4000 are identical.
  • Blank lines mean nothing. Order inside a section means nothing.

What each block is for, and which chapter starts using it:

Section Used by
[app] this chapter — the port to listen on, and the word that selects text-vs-JSON logs
[log] this chapter — how noisy to be
[db] Chapter 6 (Connecting with pgx/v5) — the DSN and the pool’s size and idle limit
[cache] Chapter 13 (Caching with DragonflyDB) — the address of the cache server
[limiter] Chapter 14 (Rate limiting) — a switch to turn rate limiting off in tests
[stripe] Chapters 15–17 — keys, plan price IDs, and the two browser return URLs
New word

DSN (“Data Source Name”) — one string holding everything needed to reach a database. Read postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable left to right: speak the postgres protocol, as user taskd, with password pa55word, to the machine localhost on port 5432, and use the database named taskd, without TLS encryption. Chapter 5 (PostgreSQL and migrations) creates exactly that database and user.

Note

success_url and cancel_url point at /v1/billing/success and /v1/billing/cancel, and this book never registers routes at those paths. They are where Stripe sends the customer’s browser after a checkout, and in a real deployment they belong to your web front end, not your API. Keep the values as printed — Chapter 15 (Stripe I) uses them — but if you follow that chapter’s demo to the end, expect to land on taskd’s own 404 page. That is the honest state of it.

Two sections arrive later and are not in this listing: [smtp] in Chapter 21 (Background work and transactional email) and [cors] in Chapter 23 (Hardening the edge). Each is added the same mechanical way as everything here — a TOML block, a struct field, one k.String line — and each of those chapters shows all three.

Step 3 — The loader: cmd/api/config.go

This is a new file, and it is the heart of the chapter. It comes in four blocks below; they are one file, in this order, top to bottom.

First the package line and imports:

// cmd/api/config.go — new file (block 1 of 4)
package main

import (
    "fmt"
    "strings"
    "time"

    "github.com/knadh/koanf/parsers/toml"
    "github.com/knadh/koanf/providers/env"
    "github.com/knadh/koanf/providers/file"
    "github.com/knadh/koanf/v2"
)

The blank line inside the import block separates standard-library packages from downloaded ones. Go’s formatter keeps that grouping; it is convention, not a rule.

Now the type that everything else in the book will read settings from:

// cmd/api/config.go — block 2 of 4

// config is the typed home for every setting. The nested anonymous
// structs (db, cache, ...) act as namespaces, so call sites read
// naturally: cfg.db.dsn, cfg.stripe.secretKey, cfg.limiter.enabled.
// Because each field has a real Go type, a bad value is a compile-time
// or load-time error — not a runtime surprise three weeks later.
type config struct {
    env      string
    port     int
    logLevel string
    db       struct {
        dsn         string
        maxConns    int32
        maxIdleTime time.Duration
    }
    cache   struct{ addr string }
    limiter struct{ enabled bool }
    stripe  struct {
        secretKey, webhookSecret    string
        priceIDPro, priceIDBusiness string
        successURL, cancelURL       string
    }
}

What this code says, line by line

  • type config struct { ... } — the same kind of declaration Chapter 2 used, with more fields. It replaces Chapter 2’s two-field version; Step 4 deletes that one.

  • db struct { ... } — a field called db whose type is a struct written inline, with no name of its own. That is a nested anonymous struct, and it exists purely so call sites read as cfg.db.dsn rather than cfg.dbDSN. Written the long way it would be two declarations:

    // illustration only — the same thing, named. Not a taskd file.
    type dbConfig struct{ dsn string }
    type config struct{ db dbConfig }
    

    Both give you cfg.db.dsn. The inline version saves naming a type nobody else refers to.

  • cache struct{ addr string } — the same idea on one line, which Go allows for short structs.

  • secretKey, webhookSecret string — two fields of the same type declared together, to keep the block short.

  • Field names are lowercase — env, not Env. In Go, capitalisation is visibility: a capitalised name is usable from other packages, a lowercase one is private to this one. Nothing outside package main needs these, so they stay lowercase.

  • Notice the naming shift between file and code: TOML says max_conns, Go says maxConns, TOML says price_id_pro, Go says priceIDPro. Each language uses its own convention, and Step 3’s final block is where the two are stitched together, by hand, one line each.

  • maxIdleTime time.Duration — a length of time, not a number. time.Duration is what Go passes around wherever a timeout or interval is needed.

  • maxConns int32 — a whole number that fits in 32 bits. It is int32 and not int because the pgx pool in Chapter 6 (Connecting with pgx/v5) asks for exactly that type.

Now the loader itself. First half: create the table, then load the two layers in a deliberate order.

// cmd/api/config.go — block 3 of 4

func loadConfig(path string) (config, error) {
    // koanf keeps one flat key→value map in memory; "." says nested
    // keys are addressed with dots, e.g. "db.dsn".
    k := koanf.New(".")

    // LAYER 1 — the TOML file. [db] dsn = "..." lands in the map as
    // key "db.dsn". A missing or malformed file is a fatal error:
    // better to refuse to boot than to run on half a config.
    if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
        return config{}, fmt.Errorf("loading %s: %w", path, err)
    }

    // LAYER 2 — environment variables. Loaded SECOND on purpose:
    // when two loads produce the same key, the later load wins, and
    // that ordering is the entire "env overrides file" behavior.
    //
    // env.Provider grabs every env var starting with TASKD_ and runs
    // our function on the NAME to turn it into a map key:
    //
    //   "TASKD_DB__DSN"
    //     → TrimPrefix   → "DB__DSN"
    //     → ToLower      → "db__dsn"
    //     → Replace __ . → "db.dsn"      <- same key the file used!
    //
    // Why the double underscore? Shells don't allow dots in variable
    // names, so "__" is the stand-in for "." — the industry-standard
    // convention. Single "_" stays part of the word (secret_key).
    err := k.Load(env.Provider("TASKD_", ".", func(s string) string {
        s = strings.TrimPrefix(s, "TASKD_")
        return strings.ReplaceAll(strings.ToLower(s), "__", ".")
    }), nil)
    if err != nil {
        return config{}, fmt.Errorf("loading env: %w", err)
    }

What this code says, line by line

  • func loadConfig(path string) (config, error) — a function taking one string and returning two values: the filled-in config, and an error. Returning an error alongside the result is how Go reports failure; there are no exceptions. The caller must look at both.
  • k := koanf.New(".") — create the empty table. The "." says: when a key contains a dot, treat it as nesting. db.dsn is the dsn setting inside the db group.
  • k.Load(provider, parser) — read one source into the table. file.Provider(path) fetches the bytes at that path; toml.Parser() turns TOML text into keys and values. They are separate arguments because the pairing is free: the same file provider works with a JSON or YAML parser.
  • if err := k.Load(...); err != nil { — Go lets you declare a variable and test it in one if. Read it as “do the load, call its error err, and if err is not nothing, take this branch”. The variable exists only inside the if.
  • return config{}, fmt.Errorf("loading %s: %w", path, err) — on failure, return an empty config (config{} is a config with every field at its zero value) and an error describing what went wrong. %s inserts the path; %w inserts the original error and keeps it inside the new one, so later code can still inspect it. That is error wrapping, and the book uses it constantly from here on. The result reads loading config.toml: open config.toml: no such file or directory — the outer half is ours, the inner half is the operating system’s.
  • env.Provider("TASKD_", ".", func(s string) string { ... }) — three arguments. First, only look at variables whose name starts with TASKD_ (so your PATH and HOME do not become config keys). Second, the delimiter: once our function has produced a key, split it on . to work out the nesting. Third, a function to transform each variable’s name.
  • The transform function has no name and is written where it is used. Go treats functions as ordinary values you can pass around. Inside it: strings.TrimPrefix removes the leading TASKD_, strings.ToLower lowercases the rest, and strings.ReplaceAll turns every __ into ..
  • err := k.Load(env.Provider(...), nil) — the nil in the parser slot means “no parsing needed”: environment variables are already names and values.
Important

To be very explicit about the part that looks like magic: nothing about TASKD_STRIPE__SECRET_KEY is special to koanf. The override works because of two ordinary things composed: our transform function turns that env var’s name into the string "stripe.secret_key" — the exact key the TOML file already created — and koanf’s merge rule says a later Load overwrites an existing key. Same key + later load = silent, seamless override. If you ever add a new setting, you get its env override for free, because the naming rule is mechanical.

And the last block: copy the merged table into the typed struct, one line per setting.

// cmd/api/config.go — block 4 of 4

    // Finally, copy koanf's merged map into the typed struct. k.Int,
    // k.String, k.Bool, k.Duration each fetch a key and convert it.
    var cfg config
    cfg.env = k.String("app.env")
    cfg.port = k.Int("app.port")
    cfg.logLevel = k.String("log.level")
    cfg.db.dsn = k.String("db.dsn")
    cfg.db.maxConns = int32(k.Int("db.max_conns"))
    cfg.db.maxIdleTime = k.Duration("db.max_idle_time") // "15m" → 15*time.Minute
    cfg.cache.addr = k.String("cache.addr")
    cfg.limiter.enabled = k.Bool("limiter.enabled")
    cfg.stripe.secretKey = k.String("stripe.secret_key")
    cfg.stripe.webhookSecret = k.String("stripe.webhook_secret")
    cfg.stripe.priceIDPro = k.String("stripe.price_id_pro")
    cfg.stripe.priceIDBusiness = k.String("stripe.price_id_business")
    cfg.stripe.successURL = k.String("stripe.success_url")
    cfg.stripe.cancelURL = k.String("stripe.cancel_url")
    return cfg, nil
}

What this code says, line by line

  • var cfg config — create a config with every field at its zero value (0, "", false), then fill it in.
  • k.String("app.env") — look up that key and give it back as a string. k.Int, k.Bool and k.Duration do the same with a conversion. Every environment variable arrives as text, so these conversions are doing real work: "9999" becomes the number 9999.
  • int32(k.Int("db.max_conns"))k.Int returns an int; the struct field is an int32. Go never converts number types for you, so you say it out loud. int32(x) means “take x, as an int32”.
  • k.Duration("db.max_idle_time") — turns the string "15m" into fifteen minutes. It accepts what Go accepts: "300ms", "5s", "15m", "1h30m".
  • return cfg, nil — the filled config, and nil in the error slot, meaning “nothing went wrong”.
Remember this

The environment layer loads second. That one ordering is the entire override feature; reverse the two k.Load calls and production silently runs on development defaults.

Why manually map instead of k.Unmarshal into a tagged struct? Taste. Explicit mapping is grep-able, survives renames loudly, and keeps struct fields unexported. Unmarshal is fine too; pick one and stop thinking about it.

Step 4 — Rewire main.go

Two edits: delete the old hardcoded type config struct {...} block (config.go now owns that type — leaving both gives a “config redeclared” compile error), then make main load instead of hardcode. Deleting also means the three lines var cfg config, cfg.port = 4000 and cfg.env = "development" go away, and "flag" joins the imports.

The complete file now reads:

// cmd/api/main.go — complete listing after this chapter
package main

import (
    "flag"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "time"
)

const version = "0.1.0"

type application struct {
    config config
    logger *slog.Logger
}

func main() {
    // -config lets deployments point at a different file
    // (the Docker image in ch. 25 uses exactly this flag).
    configPath := flag.String("config", "config.toml", "path to config file")
    flag.Parse()

    // Fail fast: if the config is broken, refuse to start AT ALL.
    // A server that boots with half a config fails later, weirdly,
    // on some poor user's request instead of loudly right here.
    cfg, err := loadConfig(*configPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    logger := newLogger(cfg)

    app := &application{config: cfg, logger: logger}

    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", cfg.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(logger.Handler(), slog.LevelError),
    }

    logger.Info("starting server", "addr", srv.Addr, "env", cfg.env)
    err = srv.ListenAndServe()
    logger.Error(err.Error())
    os.Exit(1)
}

// newLogger picks the log format and level from config:
// human-readable text in dev, machine-parseable JSON in production.
func newLogger(cfg config) *slog.Logger {
    var lvl slog.Level
    _ = lvl.UnmarshalText([]byte(cfg.logLevel)) // bad value → info

    opts := &slog.HandlerOptions{Level: lvl}
    if cfg.env == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, opts))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, opts))
}

What this code says, line by line

  • flag.String("config", "config.toml", "path to config file") — declare a command-line flag named config, defaulting to config.toml, with that description. It returns a pointer to a string, not a string: at declaration time nobody has read the command line yet, so what you get is the address of a box that will be filled in later.
  • flag.Parse() — read the actual command line and fill those boxes. After this, and only after it, the values are real.
  • *configPath — the * in front of a pointer means “the value in the box”. So loadConfig(*configPath) passes the string, not the address.
  • fmt.Fprintln(os.Stderr, err) — print the error to stderr rather than stdout. Both appear in your terminal; the split matters when something else is consuming your output, because then logs and errors can be routed separately. This runs before the logger exists, which is why it is a raw print and not a log call.
  • os.Exit(1) — stop the program immediately with exit status 1. Zero means success; anything else means failure, and that is how Docker, systemd and CI decide the program died.
  • logger.Error(err.Error()) — the last line before exiting, unchanged from Chapter 2. slog’s first argument is a message string, so err.Error() turns the error into its text. Everywhere else in this book you will see the labelled form instead — app.logger.Error("...", "error", err) — which puts the error in a searchable field rather than in the sentence. Section 9 says why.
  • var lvl slog.Level — a level variable at its zero value, which for slog.Level is exactly LevelInfo.
  • _ = lvl.UnmarshalText([]byte(cfg.logLevel)) — parse the configured level name ("debug", "info", …) into lvl. []byte(...) converts the string to raw bytes, which is the shape that method wants. The method returns an error, and _ = throws it away deliberately: the blank identifier is Go’s way of saying “I know there is a value here and I am ignoring it”. If the level string is nonsense, lvl is left untouched at info — a working logger rather than no logger, which is the right call this early in startup.
  • opts := &slog.HandlerOptions{Level: lvl} — options for the log handler. Level is the floor: anything less important is dropped, silently and cheaply.
  • slog.NewJSONHandler(os.Stdout, opts) vs slog.NewTextHandler(os.Stdout, opts) — the same log events, two renderings. Both write to stdout.
  • if cfg.env == "production" — the entire dev/prod switch. One string, from config, which means an environment variable can flip it.
  • return slog.New(handler) — a *slog.Logger wrapped around whichever handler we picked, handed back to main and stored on the application struct where every handler in the book can reach it.
New word

handler, twice — an HTTP handler answers one kind of request; an slog handler decides what a log line looks like and where it goes. Unrelated ideas, same word. This is the standard library’s word choice, not ours, and it does not get better.

Here is the difference the env value makes, on one identical log event:

  cfg.env = "development"  →  slog.NewTextHandler
  time=2026-…  level=INFO  msg="starting server"  addr=:4000  env=development
       ▲            ▲            ▲                    ▲
     when       how bad    what happened         the details

  cfg.env = "production"   →  slog.NewJSONHandler
  {"time":"2026-…","level":"INFO","msg":"starting server",
   "addr":":4000","env":"production"}

Same fields, both times (the JSON is one long line; it is wrapped above to fit the page). The top one is for your eyes at 11pm. The bottom one is for a program: a log search tool can filter on .level == "ERROR" without guessing where the level sits in a sentence. That is the whole argument for structured logging, and Chapter 19 (Logging that pays rent) builds on it.

Step 5 — Prove the override path works

This is the contract Docker will rely on in Chapter 25 (Docker), so verify it now rather than three months from now inside a container.

go run ./cmd/api
# ... msg="starting server" addr=:4000 ...          <- value from config.toml

TASKD_APP__PORT=9999 go run ./cmd/api
# ... msg="starting server" addr=:9999 ...          <- env var won, no code touched

What the second command says. TASKD_APP__PORT=9999 go run ./cmd/api is one command with a prefix. The shell sets TASKD_APP__PORT to 9999 for that one command only, runs it, and forgets. Your shell does not keep the variable; the next command sees nothing. (To set one for a whole terminal session you would write export TASKD_APP__PORT=9999, and unset it to undo.) Note the double underscore between APP and PORT, and the single one after TASKD — the prefix is TASKD_, then APP__PORT becomes app.port.

What you should see: the first run prints a line ending addr=:4000 env=development; the second prints the same line ending addr=:9999 env=development. Nothing else changes, and nothing was recompiled between them beyond Go’s usual build.

Stop each server with Ctrl-C before starting the next, or the second will fail with bind: address already in use — the first is still holding the port.

Tip

When an override “isn’t working”, check what your shell actually has set before suspecting the code: printenv | grep TASKD lists every TASKD_* variable currently in your environment. An export you typed an hour ago in that same terminal is the usual culprit.


7. Checkpoint: prove it works

Four commands, in order. The first needs no server running.

go build ./... && echo BUILD-OK
Checkpoint

Prints BUILD-OK and nothing else. If instead you see config redeclared in this block, Step 4’s deletion did not happen — see the first entry in section 8.

go run ./cmd/api
Checkpoint

One line, in text format, of the shape time=2026-... level=INFO msg="starting server" addr=:4000 env=development. The exact timestamp is yours. The program then sits there, which is ListenAndServe blocking, as in Chapter 2.

With that still running, in a second terminal:

curl -s localhost:4000/v1/healthcheck

You should get {"status":"available","environment":"development","version":"0.1.0"} — the same answer as Chapter 2, but "development" now travelled from config.toml rather than from a line of Go.

Now stop it with Ctrl-C and run the two proofs of the layering:

TASKD_APP__PORT=9999 go run ./cmd/api
Checkpoint

The startup line ends addr=:9999 env=development. curl -s localhost:9999/v1/healthcheck in the other terminal answers; localhost:4000 now refuses to connect.

TASKD_APP__ENV=production go run ./cmd/api
Checkpoint

The startup line is now a single line of JSON beginning {"time":"..." and containing "level":"INFO", "msg":"starting server" and "env":"production". Same event, different rendering — chosen by an environment variable, with no edit to any file.

If you got something else:

You saw Cause Fix
loading config.toml: open config.toml: no such file or directory You are not in the project root. The path is relative to where you ran the command, not to where the code lives pwd to see where you are, cd to the folder containing go.mod, retry
./config.go:...: config redeclared in this block The old type config struct is still in main.go Delete that block from main.go — Step 4
The server starts but curl localhost:4000 refuses to connect app.port did not come out as 4000. A typo in a TASKD_APP__PORT value that is not a number silently becomes 0, and port 0 means “any free port the OS likes” Read the addr= field in the startup line; it always tells the truth about which port was used
bind: address already in use A previous run is still going lsof -i :4000 to find it, kill <PID> to stop it

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: two lines. The first names a file and position and says config redeclared in this block; the second is indented and says other declaration of config, naming the other file. It means: two files in the same package both declare type config. Go compiles the whole folder as one unit, so this is one name defined twice. Fix: delete the type config struct { port int; env string } block from main.go. Read both lines of the error before editing: Go lists the files in the order it read them, so the arrow can land on either file. The one to delete is always the old two-field version.

Common mistake

You’ll see: loading config.toml: (12, 23): parsing error: no value can start with m It means: the TOML parser hit something it cannot read at that line and column — classically max_idle_time = 15m written without quotes. TOML has no concept of a duration; 15m is neither a number nor a string. Fix: quote it — max_idle_time = "15m". The two numbers are line and column in your file, so they shift if your spacing differs.

Common mistake

You’ll see: nothing at all. You set TASKD_APP_PORT=9999 (one underscore) and the server starts on 4000 anyway. It means: the transform turned that name into the key app_port, which no line of config.go reads. koanf stored it happily; nothing asked for it. Fix: double underscore between the section and the key: TASKD_APP__PORT. There is no warning for this and never will be — koanf cannot know which keys you meant to exist.

Common mistake

You’ll see: cannot use err (variable of interface type error) as string value in argument to logger.Error It means: you wrote logger.Error(err). slog’s first argument is the human-readable message, a string — the error goes in as a labelled field after it. Fix: app.logger.Error("could not load config", "error", err). Standardise on that shape and your JSON logs always have an error key you can search.

Common mistake

You’ll see: the server starts, prints addr=:0, and curl localhost:4000 refuses to connect. It means: app.port is zero — usually because an environment variable held something that is not a number (TASKD_APP__PORT=4o00, with a letter instead of a zero), and k.Int returns 0 when it cannot convert. Port 0 tells the operating system “give me any free port”, so the server really is listening, at an address you did not choose. Fix: correct the value. Note the general shape of this bug: koanf converts on a best-effort basis and reports nothing. What you configure is what you get; what you mistype is a zero value.

Warning

If you ever put a real key in config.toml and commit it, deleting it in the next commit does not help — Git keeps history, and anything pushed must be considered public. Stop the file being tracked with git rm --cached config.toml, then rotate the key at the provider (in Stripe’s dashboard, revoke and reissue). Rotation is the only real fix; everything else is tidying.


9. Pitfalls

  • Precedence bugs. Whatever k.Load runs last wins. Env must load after file, or production overrides silently do nothing. Write one test or one manual check; people ship this bug constantly. It is invisible on a laptop, because a laptop usually sets no TASKD_* variables at all — the failure only appears in the one environment you cannot easily poke at.

  • time.Duration from TOML. max_idle_time = "15m" is a string in TOML; k.Duration parses it. Write 15m unquoted and you’ll get a confusing type error. There are two quieter versions of the same mistake. Write max_idle_time = 900 as a bare number and koanf reads it as 900 nanoseconds, not 900 seconds — for a whole class of settings, a plausible-looking number is a disaster in a unit you did not intend. Write "15 minutes" and you get 0s, with no complaint at all. Quote it, and use Go’s units: ms, s, m, h.

  • Logging secrets at startup. Do not log the whole config struct; the DSN contains a password. If you want a startup dump, redact explicitly — exercise 3 below writes exactly that.

  • slog gotcha: logger.Error(err) doesn’t compile; slog wants logger.Error(err.Error()) or "msg", "error", err. Standardize on app.logger.Error("...", "error", err) so JSON output always has an error key.

  • Forgetting the delete in step 4. Both main.go and config.go defining type config struct is a compile error — and the error message points at the second definition, which confuses people into “fixing” the wrong file.

Note

“Second” there means the file Go read second, and Go reads a package’s files in alphabetical order. With these two names, config.go is read first, so the headline error names main.go — which happens to be the file you want to edit. Do not rely on that: rename a file and the arrow moves. Read both lines and delete the old two-field declaration, wherever it is reported.

Warning

One more, which bites in Chapter 5 (PostgreSQL and migrations): that chapter creates a file exporting TASKD_DB_DSNsingle underscore — for the migrate command-line tool, which is a different program with its own rules. Under the transform in this chapter, TASKD_DB_DSN becomes the key db_dsn, which taskd never reads. Two nearly identical names: TASKD_DB__DSN configures your server, TASKD_DB_DSN configures the migration tool. Neither warns you when you use the other.


10. Check yourself — quiz

  1. loadConfig calls k.Load twice. Which call wins when both produce the same key, and which line of code decides that?
  2. Why is the separator in TASKD_DB__DSN a double underscore instead of a dot?
  3. max_idle_time = "15m" is quoted in the TOML file. What turns it into a duration, and what happens if you remove the quotes?
  4. loadConfig returns an error when the file is missing, and main exits with status 1. Why is refusing to boot better than starting with defaults?
  5. Why must cfg.db.dsn never appear in a log line?
  6. You are deploying to production and need Stripe’s live secret key in place. Where does it live, and what is it not allowed to be near?
  7. When are JSON logs better than text logs, and who is the beneficiary?
  8. What is the -config flag for, given that config.toml is already the default — and which later chapter cashes it in?
Answers
  1. The second call — environment variables — wins, because koanf’s rule is that a later Load overwrites an existing key. The deciding “line” is really the order of the two k.Load calls in loadConfig. Swap them and every production override silently stops working, with no error anywhere.

  2. Because shells do not allow dots in environment-variable names. TASKD_DB.DSN=x is not a legal assignment in bash or zsh. The double underscore is the conventional stand-in, and a single underscore is left alone so that key names that genuinely contain one — secret_key, max_conns — survive the transform intact.

  3. k.Duration("db.max_idle_time") turns the string into a time.Duration. Remove the quotes and the TOML parser fails before Go ever sees the value, with a parsing error pointing at the line and column, because 15m is not a valid TOML number or string. The subtler failures: a bare 900 parses fine and means 900 nanoseconds, and "15 minutes" parses fine and means zero.

  4. Because the failure is loud, immediate, and in front of the person deploying. A server that boots with a missing DSN starts fine and then fails on the first request that touches the database — later, weirdly, and in front of a user, with an error that looks like a database outage rather than a deployment mistake. Failing fast trades a broken deploy for a broken service.

  5. It contains the database password. Logs get shipped to other systems, read by more people than your database credentials should be, and kept for months. Redact it, as exercise 3 does.

  6. In an environment variable — TASKD_STRIPE__SECRET_KEY — set by whatever starts the container in production. It must not be in config.toml, because that file is committed to Git, and anything in Git is permanent and effectively public.

  7. When the reader is a program: log searching, alerting, counting errors by level. A tool can filter JSON on "level":"ERROR" exactly; on a text line it would be pattern-matching a sentence. The beneficiary is you, six months later, at 3am, trying to find the twelve requests that failed.

  8. It lets a deployment point the same binary at a config file in a different place, without moving the working directory or the file. Chapter 25 (Docker) cashes it in: the image’s entrypoint is ["/bin/api","-config","/config.toml"], because in a container the file sits at the filesystem root rather than next to the binary.


11. Practice

Exercise 1 — Add a new setting end to end

Add an [app] shutdown_timeout setting with a dev default of 30 seconds, make it reachable as cfg.shutdownTimeout, and prove an environment variable overrides it. This is the exact four-move loop repeated in Chapters 13, 14, 21 and 23, so it is worth doing once slowly.

Solution

Four edits, one per layer.

# config.toml — add this line inside the existing [app] section
shutdown_timeout = "30s"
// cmd/api/config.go — add this field to the config struct, under logLevel
    shutdownTimeout time.Duration
// cmd/api/config.go — add this line in block 4, under the log.level line
    cfg.shutdownTimeout = k.Duration("app.shutdown_timeout")
// cmd/api/main.go — replace the existing startup log line
    logger.Info("starting server", "addr", srv.Addr, "env", cfg.env,
        "shutdown_timeout", cfg.shutdownTimeout)

Verify both layers:

go run ./cmd/api
# ... msg="starting server" addr=:4000 env=development shutdown_timeout=30s

TASKD_APP__SHUTDOWN_TIMEOUT=5s go run ./cmd/api
# ... msg="starting server" addr=:4000 env=development shutdown_timeout=5s

Note what you did not have to do: nothing in the loader knows this setting is special, and no code was written to make the override work. The mechanical naming rule did it.

Chapter 4 (A server that dies well) hardcodes a 30-second shutdown grace period. Keep this field if you want to wire it in there yourself — or revert the four edits to stay exactly in step with the book’s listings.

Exercise 2 — Make the info lines disappear

Run the server so that its startup line is not printed at all, without editing any file. Then explain in one sentence where the line went.

Solution
TASKD_LOG__LEVEL=error go run ./cmd/api

The terminal shows nothing, and the server is running — confirm it with curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck, which prints 200.

The line went nowhere: newLogger set slog.HandlerOptions{Level: slog.LevelError}, so the handler discards every event below error. logger.Info(...) still runs — the cost is a function call and nothing else. That is what a log level is: a floor, applied inside the handler, not a decision made at each call site.

Two things worth trying while you are here. TASKD_LOG__LEVEL=banana go run ./cmd/api prints the startup line normally: the level failed to parse, _ = discarded that error, and lvl stayed at its zero value, which is info. And TASKD_LOG__LEVEL=error TASKD_APP__PORT=9999 go run ./cmd/api sets both — prefix assignments stack, space-separated, before the command.

Exercise 3 — A startup dump that cannot leak

Write a logConfig method that logs the whole configuration at startup with the DSN’s password replaced, so the line is safe to ship to a log service. Call it from main after the logger exists.

Solution
// cmd/api/config.go — add to the end of the file
// (new imports: "net/url")

// redactDSN returns the DSN with its password replaced, so a connection
// string can be logged without leaking the credential it contains.
func redactDSN(dsn string) string {
    u, err := url.Parse(dsn)
    if err != nil {
        return "***" // unparseable: reveal nothing at all
    }
    if _, hasPassword := u.User.Password(); hasPassword {
        u.User = url.UserPassword(u.User.Username(), "redacted")
    }
    return u.String()
}

// logConfig writes one startup line describing the configuration,
// with every secret redacted or reduced to a yes/no.
func (app *application) logConfig() {
    app.logger.Info("configuration loaded",
        "env", app.config.env,
        "port", app.config.port,
        "log_level", app.config.logLevel,
        "db_dsn", redactDSN(app.config.db.dsn),
        "db_max_conns", app.config.db.maxConns,
        "cache_addr", app.config.cache.addr,
        "limiter_enabled", app.config.limiter.enabled,
        "stripe_key_set", app.config.stripe.secretKey != "",
    )
}
// cmd/api/main.go — add this line after the app := &application{...} line
    app.logConfig()

Run it:

go run ./cmd/api

You get a line containing db_dsn="postgres://taskd:redacted@localhost:5432/taskd?sslmode=disable" and stripe_key_set=false, followed by the usual startup line.

Three decisions worth naming. url.Parse is used rather than string surgery because a DSN is a URL and the standard library already knows where the password sits. The Stripe key is reported as a boolean, not a redaction — for a key, whether it is set is the only thing worth logging. And redactDSN returns *** if parsing fails, because the failure case must reveal less, never more.

(If you prefer the literal *** as the replacement, note that u.String() percent-encodes it into %2A%2A%2A, since * is not a plain character in the userinfo part of a URL. Correct, and ugly. "redacted" needs no encoding.)


12. FAQ

Why a file and environment variables? Isn’t one enough? Each alone fails at one end. A file alone means secrets live in the repository or in a file someone has to copy onto the server by hand, and containers have nowhere natural to put it. Environment variables alone mean nothing in the repository documents which settings exist — a newcomer has to read the source to find out that cache.addr is a thing. The file is the documentation and the defaults; the environment is the override for the handful of values that actually differ.

A .env file is not a third option, by the way — it is environment variables in a file, loaded by a helper, so it inherits both weaknesses: untyped, and usually gitignored, which puts you back to “nothing in the repository says what settings exist”. This book does use one, narrowly: Chapter 5 (PostgreSQL and migrations) writes a .envrc holding the database DSN for the migrate command-line tool, which is a different program with no config file of its own.

Is koanf necessary? Could I parse the TOML myself? You could. Go’s ecosystem has TOML parsers, and reading a file into a struct is not hard. What you would then write yourself is the second layer: enumerate the environment, transform each name, decide precedence, merge, and convert types. That is the part koanf is, and it is about two hundred lines you would get subtly wrong once. Note that the library is doing something small and boring here on purpose — no watching, no reloading, no framework.

How do secrets actually get to production, if not through the file? Whatever starts your container sets them. In this book that is Docker Compose in Chapter 25 (Docker), reading from a file on the server that is not in Git and is readable only by the deploy user, and passing the values in as TASKD_* variables. The deploy pipeline in Chapter 26 (CI/CD) never sees the values at all — it ships an image, and the image finds its secrets when it starts. Bigger shops replace that file with a secrets manager; the interface to your program is the same either way, which is the point of using env vars as the boundary.

Why not use k.Unmarshal with struct tags? It would be shorter. It would, by about fifteen lines. The trade named in section 6 is real: explicit assignments are greppable (you can find every use of db.max_conns in one search), they fail loudly when a name changes, and they let struct fields stay lowercase and private. Unmarshal is a legitimate choice made by plenty of good codebases. Pick one and stop thinking about it.

What log level should production run at? info. debug in production is a flood that costs money to store and makes real problems harder to find; warn hides the ordinary events — a user registered, a subscription changed — that you need when reconstructing what happened. Our config.toml ships debug because that is the right default for a laptop, and production overrides it with TASKD_LOG__LEVEL=info. Chapter 19 (Logging that pays rent) revisits this once there is something worth logging.

This is ninety lines of loader for something that was two lines in Chapter 2. Is that proportionate? Today, no. This seam becomes the entire container interface in Chapter 25 (Docker): the compiled binary, one committed file, and a handful of environment variables is the whole contract between your code and the machine it runs on. It also stops growing — every setting added between here and Chapter 27 costs exactly three lines, and you have already met all three.


13. Where we are

Typed config from file+env, structured logs. The server still dies rudely on Ctrl-C and panics kill the whole process — next.

The repository as it now stands (+ marks new in this chapter, ~ changed):

taskd/
├── cmd/api/
│   ├── main.go          ~  loads config, builds the logger, adds -config flag
│   ├── config.go        +  the config struct and loadConfig
│   ├── routes.go           unchanged
│   ├── healthcheck.go      unchanged (now reports env from the file)
│   └── errors.go           unchanged, still the temporary stub
├── internal/…              still empty
├── config.toml          +  every setting, committed, dev defaults
├── go.mod               ~  four koanf modules added
├── go.sum               ~  their checksums
└── .gitignore              unchanged

What works end to end: go run ./cmd/api reads config.toml, applies any TASKD_* overrides, starts on the configured port, and logs in text or JSON according to app.env. GET /v1/healthcheck still returns 200, and its environment field now comes from configuration rather than from source code.

What is still fake: most of config.toml is read into memory and used by nobody — [db], [cache], [limiter] and [stripe] are wiring for Chapters 6, 13, 14 and 15. newLogger lives in main.go for now, and Appendix E’s final main.go keeps it there. Ctrl-C still kills the process mid-request, and a panic in a handler still takes the whole server down — both fixed in Chapter 4 (A server that dies well).

For your notes — copy these into learnings/ch03.md in your own words:

  1. Settings live outside the code and are read at startup. A committed file supplies documented defaults; environment variables override individual values in production. The same binary then runs anywhere.
  2. The override is not magic and has exactly two moving parts: a transform that turns TASKD_DB__DSN into the key db.dsn, and koanf’s rule that a later Load overwrites an earlier one. Load the environment second, always.
  3. __ means . because shells forbid dots in variable names; a single _ stays part of the word. A single underscore where you meant two is a silent no-op — the worst kind of bug, and one no tool will catch for you.
  4. Fail fast on a broken config: a server that boots with half its settings fails later, on a user’s request, disguised as a different problem.
  5. Structured logging means labelled fields, not sentences. Text for human eyes in development, JSON for machines in production, chosen by one config value — and never, ever log the DSN.

Chapter 4 — A server that dies well: lifecycle and core middleware

Your server currently has two ways of ending badly. Press Ctrl-C while somebody is halfway through a request and the request dies with the process, mid-sentence. Write a handler with a bug in it and that request’s connection is severed with no answer at all. This chapter fixes both: the server learns to finish its work before it exits, and a crash in one handler becomes an ordinary 500 response instead of a hole in your logs. Along the way you meet middleware — fifteen lines of Go that the next twenty chapters are built on top of.

What you’ll be able to do by the end

  • Stop the server with Ctrl-C and watch it wait for requests already in progress before exiting.
  • Explain the two-goroutine shutdown choreography, and say which return value answers which question.
  • Write a middleware from scratch and predict what order several of them run in.
  • Make a handler crash on purpose, get a clean 500, and see the server still serving.
  • Read main.go as a wiring diagram — build the dependencies, hand them over, serve.

Time: ~50 minutes reading, ~30 minutes typing.

You need before starting: Chapter 3 (Configuration and logging, the Nadh way) finished — a config.toml, a loadConfig, and a newLogger. Prove it still works:

go run ./cmd/api

The server should print one line like time=2026-... level=INFO msg="starting server" addr=:4000 env=development and then sit there. In a second terminal:

curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck

That should print 200. Stop the server with Ctrl-C before continuing.


1. The problem, in plain words

Two stories. Both are about the edges of a request’s life rather than its middle, which is why neither has come up until now.

Story one: the deploy that eats requests

Right now, deploying a new version of your program means something like this: stop the old copy, start the new one. “Stop the old copy” is not a polite request. It is the operating system delivering a signal — a one-word message to a running program — and the default answer to that particular word is die, immediately, wherever you are.

If a request was in progress at that instant, it does not finish. The client is holding a connection that goes silent halfway through the response. What the client sees is not a 500; it is a network error, which is worse, because a 500 at least tells them the server heard them and failed. A dropped connection tells them nothing. If the request was “create this task”, the caller now genuinely does not know whether the task exists.

One dropped request while you are learning is nothing. But this is how every release works forever: Docker’s docker stop sends exactly that signal to your container on every deploy, and Chapter 25 (Packaging with Docker) is where your program starts living in one. You will deploy dozens of times before you finish this book. A server that cannot survive being asked to stop is a server that damages a handful of users every single release.

The mechanism the operating system gives you is generous: the signal is a request, not an execution. Your program is allowed to notice it, take a few seconds to tidy up, and exit on its own terms. It has to be written to notice, though, and yours currently isn’t.

Story two: the handler that gives up

The second story is about a bug rather than a deploy.

Somewhere in the next twenty chapters you will write a handler that reads a value that turns out to be missing, and Go will panic — the runtime’s way of saying “this should have been impossible, I am not continuing down this path”. A panic is not an error value you check. It unwinds — it abandons the current work and climbs back up through every function that was waiting, running only their cleanup, until either something catches it or the program stops.

Go’s HTTP server has a small safety net of its own, so one panicking handler does not take down your whole process. What it does instead, in the exact configuration you have right now, is this:

  • the client’s connection is closed with no reply at all — curl reports curl: (52) Empty reply from server;
  • your log gets one level=ERROR line whose message is the panic text plus the entire stack trace crammed into a single field, newlines and all, escaped as \n;
  • and the request never appears in your normal request log, because the code that would have logged it never ran.

So you lose the answer, the client can’t tell a bug from a network glitch, and the diagnosis is one enormous unreadable line. All three are fixable by a piece of code shorter than this paragraph.

Why this exists

Both stories are about boundaries — the moment a process ends, and the moment a request goes wrong. Nothing inside your handlers can fix either one, because by the time either happens the handler is no longer in control. Both fixes therefore live around your code rather than in it. That is the shape of this whole chapter, and of most of the production concerns in this book.


2. New words in this chapter

  • lifecycle — the whole life of the program: start up, serve, be told to stop, stop cleanly.
  • process — one running copy of a program, as the operating system sees it. It has a number (a PID) and can be sent signals.
  • signal — a one-word message the operating system delivers to a process. The operating system tapping your program on the shoulder rather than pulling the plug.
  • SIGINT — the signal Ctrl-C sends. Short for “interrupt”.
  • SIGTERM — the “please stop” signal; what docker stop sends on every deploy. Short for “terminate”.
  • SIGKILL — the signal that kills a process instantly with no chance to clean up. It cannot be caught. This is pulling the plug.
  • graceful shutdown — stopping the server by refusing new requests, letting the ones already in progress finish, then exiting.
  • in-flight request — a request that has arrived and is still being worked on.
  • drain — letting in-flight work finish while accepting no new work.
  • deploy — replacing the running copy of your program with a new build of it.
  • goroutine — a piece of work running at the same time as the rest of the program, extremely cheap to start. Written go f().
  • channel — a typed pipe for passing values between goroutines; receiving from one waits until something arrives. Written make(chan error).
  • buffered channel — a channel with room to hold a few values, so a sender does not have to wait for a receiver to be ready. make(chan os.Signal, 1) holds one.
  • context — a value carried through function calls that answers “should I still be doing this?”. context.WithTimeout makes one that answers “no” after a set time.
  • sentinel error — a specific named error value you compare against, such as http.ErrServerClosed. errors.Is(err, target) is how you compare.
  • middleware — a function that wraps a handler, doing something before and/or after it, and returns a handler again — so they stack.
  • wrap — put one handler inside another so the outer one runs first and last.
  • panic — Go’s “this should be impossible” crash. It unwinds the current goroutine and, if nothing catches it, kills the program.
  • recover — catches a panic inside a deferred function and hands you the value it was carrying.
  • stack trace — the printed list of function calls that led to a crash.
  • stdout / stderr — the two output streams every program has: normal output and error output.
  • stub — a deliberately fake, minimal version of something written properly later, so the code compiles today.
  • load balancer — a machine in front of several copies of your app that spreads requests among them.
  • Kubernetes — a large system for running containers across many machines automatically. Mentioned here for context; deliberately not used in this book.

3. The goal

Graceful shutdown on SIGINT/SIGTERM (in-flight requests get up to 30 s to finish), a panic in any handler returns a clean 500 instead of nuking the process, and every request leaves one structured log line. Also: the serve() extraction that keeps main readable forever.


4. The thinking

4.1 Why care this early?

Because Docker sends SIGTERM on every deploy. Without graceful shutdown, every release drops whatever requests are mid-flight — you’ll ship dozens of times while following this book, so we fix it before it can ever bite. The mechanism is srv.Shutdown(ctx): stop accepting, drain, time-box the drain.

Three words there, and each is a deliberate choice:

Phase What Shutdown does Why it matters
stop accepting closes the listening socket immediately new requests fail fast and get retried elsewhere, instead of being accepted and then killed
drain waits for handlers already running to return the in-flight work finishes and its clients get real answers
time-box gives up after a deadline you set one stuck handler must not keep a dying process alive forever
Think of it like

Closing time at a shop. You lock the front door so nobody new comes in, you serve everyone already inside, and — because you do want to go home — there is a point at which you politely tell the last browser to leave. Locking the door is Shutdown being called; serving the people inside is the drain; going home anyway is the 30-second budget.

4.2 Two Go words, stated plainly

The subtle part is the choreography, and it needs two Go concepts stated plainly first.

A goroutine (go func() {...}()) is a function running concurrently with the rest of the program — cheap enough that the http package already starts one per request without you noticing.

A channel (make(chan error)) is a typed pipe for passing values between goroutines: one side sends (ch <- value), the other blocks until it can receive (v := <-ch).

We need both because signal-waiting must not block serving: the main goroutine sits in ListenAndServe, while a second goroutine sleeps until the OS delivers a shutdown signal — and when it does, the result of the shutdown has to travel back to the main goroutine somehow. That “somehow” is the channel.

Read the arrows as direction of travel. ch <- v puts v into the pipe; <-ch takes something out of it. Both sides wait for each other, which is exactly the property we want: the main goroutine will sit at <-shutdownError doing nothing at all until the other goroutine has an answer to give it. (Go in one sitting, section 11, builds both ideas from scratch with runnable examples — that section is written as a warm-up for this page.)

Why can’t the main goroutine wait for the signal itself? Because ListenAndServe blocks: it does not return while the server is running. A single goroutine cannot both sit inside ListenAndServe and sit waiting for a signal. Here are the options, and why only one survives:

Approach What happens Verdict
Ignore signals (today’s code) the OS’s default action stops the process instantly, mid-request drops in-flight requests on every deploy
Wait for the signal in main, before serving you never reach ListenAndServe; the server never starts broken
Wait for the signal in a second goroutine, report back over a channel main serves traffic, the goroutine sleeps until needed, the verdict travels home chosen

4.3 The trap everyone falls into once

When Shutdown is called, ListenAndServe returns http.ErrServerClosed immediately — that’s an acknowledgment (“I’ve stopped accepting”), not the outcome. The real outcome — did every in-flight request finish in time? — is the return value of Shutdown itself, over in the other goroutine. Hence the channel.

The value Which goroutine sees it The question it answers
ListenAndServe’s return, http.ErrServerClosed main “have we stopped accepting new connections?”
Shutdown(ctx)'s return the signal goroutine “did every in-flight request finish inside the budget?”
Think of it like

ListenAndServe returning is the doorman saying “I’ve locked the front door”. Shutdown returning is the manager saying “everyone’s out”. You need to hear the second one, and it is being said in a different room — which is what the channel is for.

Remember this

http.ErrServerClosed is not a failure. It is the expected, correct return value of a server that was asked to stop. Treating it as an error is the single most common bug in this shape of code.

The listing in Step 1 numbers the choreography (1)–(6) so you can follow it in order.

4.4 Middleware, from first principles

The chapter’s second topic, middleware, is the most important abstraction you’ll meet in Go web development, and it’s smaller than it sounds. A middleware is a function that takes a handler and returns a new handler which does something extra before/after calling the original:

// illustration only — the shape, not a taskd file
func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ... code here runs BEFORE the handler ...
        next.ServeHTTP(w, r) // call through to the wrapped handler
        // ... code here runs AFTER the handler ...
    })
}

Four things are happening in those five lines, and every one of them is a Go idea you have already met in Go in one sitting:

  • http.Handler is an interface — a description of a capability, not a concrete type. Anything with a ServeHTTP(w, r) method qualifies. So “takes a handler, returns a handler” means “takes something that can answer a request, returns something else that can answer a request”. The router doesn’t know or care which.
  • Functions are values. next is a handler we were handed. We can store it, pass it on, and decide when — or whether — to call it.
  • The inner function is a closure. It is written inside middleware, so it can still see next when it runs, long after middleware itself has returned. That capture is what makes the chain hold together.
  • http.HandlerFunc(f) is a type conversion, not a function call. http.HandlerFunc is a named function type that has a ServeHTTP method attached to it. Converting your plain function to that type is what gives it the method, and having the method is what makes it an http.Handler. It is Go’s way of saying “this ordinary function is now a handler”.

Stack several and requests pass through them like onion layers:

request ──▶ recoverPanic ──▶ logRequest ──▶ router ──▶ your handler
response ◀── recoverPanic ◀── logRequest ◀── router ◀──┘

Note that the picture has two rows, and the second one is the interesting one. Anything written before next.ServeHTTP happens on the way in; anything written after it happens on the way out, when the handler has finished and the answer is known. That is why logRequest can report how long a request took, and why Chapter 18 (Metrics with Prometheus) and Chapter 19 (Logging that survives production) both record on the way out too.

Everything cross-cutting in this book — logging, panic recovery, auth (Chapter 11, Stateful tokens), rate limits (Chapter 14, Rate limiting), metrics (Chapter 18, Metrics with Prometheus), idempotency (Chapter 23, Hardening) — is exactly this pattern. Understand these fifteen lines and you’ve understood a third of the codebase in advance.

Think of it like

A middleware chain is airport security: every passenger passes the same checks in the same order before reaching the gate — and passes back through some of them, in reverse, on the way out.

4.5 Panic recovery, and why it goes outermost

Panic recovery specifically: a panic in a handler goroutine kills only that connection — but leaves the client with a severed socket and you with a stack trace lost to stderr. A tiny middleware converts it to a logged 500.

Recovery middleware must sit outermost, so it also catches panics thrown by other middleware. The reasoning is mechanical rather than stylistic: a recovery only catches what it wraps, because a panic unwinds outwards. Put recoverPanic inside the logging middleware and a panic thrown by the logging middleware sails straight past it.

Note

Because Chapter 3 wired ErrorLog into our slog logger, “lost to stderr” is not quite what happens in this codebase — the trace lands in the structured log instead, as one enormous escaped line. Losing it is not the problem; the problem is that the client got no response and the line is unreadable. Step 6 shows you both.

4.6 Why write these by hand?

We write these two middleware by hand (they’re ~15 lines each and you should know what’s in them) even though chi/middleware ships equivalents. Knowing the cost of your dependencies starts with knowing which ones are small enough to write yourself.

Option What you get Why the book chooses otherwise
chi/middleware.Recoverer + .Logger working panic recovery and request logs, zero lines written its log format is not ours, its error response is not our errors.go, and you would be trusting fifteen lines you have never read
Hand-rolled (chosen) the exact response shape we want, in our logger, in our vocabulary you have to write thirty lines once

That is not an argument against dependencies. Chapter 7 (sqlc) adds a code generator and Chapter 15 (Stripe I) adds a payments SDK, because writing either yourself would be reckless. It is an argument for knowing which is which.


5. A picture of it

The shutdown, on a time axis

Two columns are two goroutines running at the same time; the left edge is the operating system. Time runs downwards. The numbers match the comments in the code you are about to write.

   OS                 main goroutine              signal goroutine
    │                      │                            │
    │                      │ build srv                  │
    │                      │ shutdownError := chan      │
    │                      │ go func() ────────────────▶│ (1) starts
    │                      │                            │ signal.Notify(quit)
    │                      │ (5) ListenAndServe()       │ (2) s := <-quit
    │                      │     serving traffic...     │     ...asleep...
    │  Ctrl-C /            │                            │
    │  docker stop         │                            │
    ├── SIGINT/SIGTERM ────┼───────────────────────────▶│     wakes up
    │                      │                            │ log "shutting down"
    │                      │                            │ (3) ctx: 30s budget
    │                      │◀── stops the listener ─────┤ (4) srv.Shutdown(ctx)
    │                      │ ListenAndServe returns     │     waits for
    │                      │ http.ErrServerClosed       │     in-flight
    │                      │ (6) <-shutdownError        │     requests...
    │                      │     blocked, waiting       │
    │                      │◀── the real verdict ───────┤     sends result
    │                      │ log "stopped server"       │ (goroutine ends)
    │                      │ return nil                 │
    ▼                      ▼                            ▼
  1. serve() starts a second goroutine whose only job is to wait for a signal.
  2. That goroutine blocks on <-quit, possibly for weeks. It costs a few kilobytes to sit there.
  3. When a signal arrives it wakes, logs, and builds a context that expires in 30 seconds — the drain budget.
  4. It calls srv.Shutdown(ctx), which closes the listener at once and then waits for running handlers.
  5. Back in main, ListenAndServe returns http.ErrServerClosed the moment the listener closes. That is the acknowledgment, so main does not treat it as a failure — it moves on.
  6. Main then blocks on <-shutdownError until the other goroutine sends the verdict: nil if everything drained, or a deadline error if the budget ran out.

The onion, once more, with the return path

This is the same diagram as section 4.4, and it is worth committing to memory, because Chapters 11, 14, 18, 19 and 23 all add layers to this exact picture and never redraw it.

        ┌──────────────── recoverPanic ────────────────┐
        │  ┌────────────── logRequest ──────────────┐  │
        │  │  ┌──────────── chi router ──────────┐  │  │
   in ─────────────▶ app.healthcheckHandler      │  │  │
        │  │  │              │                   │  │  │
  out ◀────────────────────── ┘                  │  │  │
        │  │  └──────────────────────────────────┘  │  │
        │  └── logs method, path, duration ─────────┘  │
        └── catches any panic from anything inside ────┘

6. The steps

Six steps, in two halves. Steps 1 and 2 are the lifecycle: the server learns to die well. Steps 3 to 6 are the middleware: requests learn to be logged and crashes learn to be caught.

Part A — the lifecycle

Step 1 — Extract lifecycle into its own file

Everything about starting, serving and stopping moves out of main.go into a file of its own, where it can grow without making main unreadable. Create this file; nothing is deleted yet.

// cmd/api/server.go — new file
package main

import (
    "context"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func (app *application) serve() error {
    // The server definition moves here from main.go, unchanged.
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", app.config.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(app.logger.Handler(), slog.LevelError),
    }

    // The pipe that carries the REAL shutdown result back to us.
    shutdownError := make(chan error)

    // (1) A background goroutine that exists only to wait for a signal.
    go func() {
        // Ask the OS to deliver Ctrl-C (SIGINT) and Docker's stop
        // signal (SIGTERM) into this channel instead of killing us.
        quit := make(chan os.Signal, 1)
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

        // (2) Sleep here — possibly for days — until a signal arrives.
        s := <-quit

        app.logger.Info("shutting down server", "signal", s.String())

        // (3) Give in-flight requests up to 30s to finish.
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        // (4) Shutdown stops new connections immediately, then waits
        // for active requests to drain (or the 30s to expire). Its
        // return value is the true verdict — send it down the pipe.
        shutdownError <- srv.Shutdown(ctx)
    }()

    app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)

    // (5) Blocks here serving traffic. When Shutdown is called in (4),
    // this returns http.ErrServerClosed at once — which is EXPECTED,
    // not an error. Anything else means the server died for real.
    err := srv.ListenAndServe()
    if !errors.Is(err, http.ErrServerClosed) {
        return err
    }

    // (6) Now wait for the goroutine to report how the drain went.
    if err := <-shutdownError; err != nil {
        return err // e.g. requests still running after 30s
    }

    app.logger.Info("stopped server", "addr", srv.Addr)
    return nil
}

What this code says, line by line

  • func (app *application) serve() error — a method on the application struct from Chapter 2 (The skeleton), the one box holding the config and logger. Being a method is what lets the body say app.config.port and app.logger without any arguments being threaded in. It returns an error, which is how it tells main whether the exit was clean.
  • The srv := &http.Server{...} block is copied from main.go unchanged, timeouts and all. If the three timeouts still look arbitrary, Chapter 2 (The skeleton) derives each one.
  • shutdownError := make(chan error) — creates the pipe. It carries values of type error. No size is given, so it is unbuffered: a send waits for a receive, and a receive waits for a send. Exactly one value ever travels down it.
  • go func() { ... }() — three separate things: func() {...} writes a function with no name, the trailing () calls it, and the leading go says “run that call in a new goroutine, don’t wait for it”. Execution continues on the next line immediately.
  • quit := make(chan os.Signal, 1) — a second channel, this one carrying signals, with room for one value. The buffer matters: the runtime delivers a signal by sending it on this channel and it will not block waiting for a receiver, so a channel with no room would drop signals that arrive at an awkward moment. One slot is all we need, because one signal is all it takes.
  • signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) — this is the line that changes the program’s fate. It tells the runtime: from now on, when either of these two signals arrives, do not perform the default action (die); send the signal down this channel instead. Signals we don’t name here are unaffected — SIGKILL in particular can never be caught by anyone.
  • s := <-quit — receive from the channel, and block until there is something to receive. This is where the goroutine spends its entire life, using no CPU at all.
  • s.String() — turns the signal into a word for the log: interrupt for SIGINT, terminated for SIGTERM. Being able to read which one arrived tells you afterwards whether a human pressed Ctrl-C or an orchestrator asked the container to stop.
  • context.WithTimeout(context.Background(), 30*time.Second) — a context is a value that answers “should I still be doing this?”; this one starts answering “no” after thirty seconds. context.Background() is the empty root context, used when nothing above you has one. The call returns two things: the context, and a cancel function.
  • defer cancel()defer schedules a call for when the surrounding function returns, by any route. Calling cancel releases the timer the context is holding. It is not optional bookkeeping: skipping it leaks a timer per call. Pair WithTimeout and defer cancel() on adjacent lines, always, and you never have to think about it again.
  • shutdownError <- srv.Shutdown(ctx) — read it inside out. srv.Shutdown(ctx) runs first and returns an error (or nil); the <- then sends that value into the pipe. Because the channel is unbuffered, this send waits until main is ready to receive it.
  • err := srv.ListenAndServe() — the main goroutine blocks here for the entire life of the server.
  • if !errors.Is(err, http.ErrServerClosed) { return err }http.ErrServerClosed is a sentinel error: one specific named error value, exported by net/http, that means “this server was deliberately closed”. errors.Is(err, target) asks “is this error that one, or does it contain that one?” — the wrapper-aware version of ==. So the line reads: if the error is anything other than the expected shutdown acknowledgment, give up and report it.
  • if err := <-shutdownError; err != nil { return err } — receive the real verdict, blocking until it arrives, and hand any failure up to main. The err := inside the if creates a variable scoped to that one statement; it does not clash with the err above it.
  • The last two lines only run when the drain succeeded, which is why stopped server in your logs is a meaningful thing to see rather than decoration.
Common mistake

You’ll see: nothing. No log lines, no listening port, and curl fails with curl: (7) Failed to connect to localhost port 4000. It means: you wrote func() {...}() and left off the go. The function then runs on the main goroutine and blocks forever at s := <-quit, so ListenAndServe is never reached. Fix: put the go back. One missing word, and the program is a very expensive way to wait for Ctrl-C.

Step 2 — Rewire main.go

This is the payoff of the extraction. Delete the whole srv := &http.Server{...} block and the ListenAndServe lines from main.go (and with them, the now-unused net/http and time imports), and let main end with a call to serve(). Here is the complete file afterwards:

// cmd/api/main.go — replaces the whole file
package main

import (
    "flag"
    "fmt"
    "log/slog"
    "os"
)

const version = "0.1.0"

type application struct {
    config config
    logger *slog.Logger
}

func main() {
    configPath := flag.String("config", "config.toml", "path to config file")
    flag.Parse()

    cfg, err := loadConfig(*configPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    logger := newLogger(cfg)

    app := &application{config: cfg, logger: logger}

    // serve() blocks until shutdown. A nil error means a clean,
    // graceful exit; anything else is fatal and worth a loud log.
    err = app.serve()
    if err != nil {
        logger.Error("server error", "error", err)
        os.Exit(1)
    }
}

// newLogger picks the log format and level from config:
// human-readable text in dev, machine-parseable JSON in production.
func newLogger(cfg config) *slog.Logger {
    var lvl slog.Level
    _ = lvl.UnmarshalText([]byte(cfg.logLevel)) // bad value → info

    opts := &slog.HandlerOptions{Level: lvl}
    if cfg.env == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, opts))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
Note

Two corrections to the original edition’s listing, both flagged here rather than hidden. The original printed this file with only flag, fmt and os imported, then said in the following paragraph “keep log/slog in the imports”. A reader who typed the listing as printed got a run of undefined: slog errors, one for each line that mentions it, and no explanation. The import is included above. The original also stopped the listing before newLogger, which Chapter 3 put in this same file; it is kept here so the listing really is the whole file. No logic has changed.

What changed, and why it matters

main is now nine statements long, and every one of them is either “build a dependency” or “hand the dependencies over”. There is no HTTP in it at all. That is the point:

  before this chapter                 after this chapter
  ───────────────────                 ──────────────────
  main()                              main()
   ├── load config                     ├── load config
   ├── build logger                    ├── build logger
   ├── build application               ├── build application
   ├── describe http.Server            └── app.serve()  ──▶ server.go
   ├── ListenAndServe
   └── log the error and exit

main stays exactly this small for the rest of the book: every future dependency — DB pool, cache, mailer, Stripe — slots in between “build app” and “serve”, one construction block each. Appendix E shows the final, fully-grown main.go so you can always compare yours against the destination.

Checkpoint

Run go build ./.... It should print nothing at all, which is how Go says the build succeeded. If it complains "net/http" imported and not used or "time" imported and not used, you removed the server block but left its imports — delete those two lines from the import list.

Part B — the middleware

Step 3 — The middleware file

Two middleware, about fifteen lines each, in a new file. Both are methods on *application, so both can reach the logger and the error helpers.

// cmd/api/middleware.go — new file
package main

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

// recoverPanic turns a panic anywhere downstream into a logged 500,
// instead of a silently severed connection.
func (app *application) recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // defer runs this function on the way OUT of the request —
        // including the abnormal exit a panic causes. recover() stops
        // the panic's propagation and hands us its value.
        defer func() {
            if err := recover(); err != nil {
                // The connection is in an unknown state; tell the
                // client (and Go) to close it after this response.
                w.Header().Set("Connection", "close")
                app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// logRequest writes one structured line per request, after it finishes,
// so the duration is known.
func (app *application) logRequest(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r) // run the actual request
        app.logger.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "remote", r.RemoteAddr,
            "duration", time.Since(start).String(),
        )
    })
}

What this code says, line by line

  • func (app *application) recoverPanic(next http.Handler) http.Handler — the middleware shape from section 4.4, with a receiver bolted on so it can use app. In: a handler. Out: a handler.
  • defer func() { ... }() — two levels of nesting, and both are load-bearing. The inner func() {...} is an anonymous function; the trailing () calls it; defer postpones that call until the surrounding function returns. recover() only does anything inside a deferred function — call it anywhere else and it returns nil and catches nothing, silently. That restriction is the whole reason this shape exists.
  • if err := recover(); err != nilrecover() returns nil when there is no panic in progress (the normal case, on every single request) and the panicking value when there is. So this reads: “if we are here because something exploded, deal with it”.
  • w.Header().Set("Connection", "close") — the handler stopped partway through, so nobody knows how much of the response was already written. Reusing that connection for another request would be guesswork, so we tell both the client and Go to close it after this reply.
  • fmt.Errorf("%s", err) — a panic can carry a value of any type: a string, an error, a struct. serverErrorResponse wants an error. %s formats whatever arrived as text and fmt.Errorf wraps it in an error value, so any panic value becomes something loggable.
  • next.ServeHTTP(w, r) — the call through to whatever we are wrapping. No return follows it; the deferred function runs afterwards either way.
  • In logRequest, start := time.Now() before and time.Since(start) after are the only way to know how long a request took, and the reason the log line is written after next.ServeHTTP rather than before.
  • app.logger.Info("request", "method", r.Method, ...) — slog’s key/value style: a message, followed by alternating keys and values. Each pair becomes one key=value field in the output, so a log search tool can filter on path or sort by duration without parsing prose.
  • r.RemoteAddr — the address the connection came from, host and port. On your laptop this is [::1]:54492 or similar: [::1] is the IPv6 way of writing “this machine”, and the number after it is the client’s ephemeral port, different every time.

We aren’t capturing the status code yet — that needs a ResponseWriter wrapper, which arrives with the Prometheus middleware in Chapter 18 (Metrics with Prometheus). Log what’s cheap now; enrich when there’s a real consumer.

Note

One consequence worth knowing now, because you will see it in Step 6: when a handler panics, logRequest’s logging call never runs. The panic unwinds straight past it — deferred calls run during an unwind, ordinary statements do not. So a panicking request produces the 500 and its error line, but no msg=request line. Chapter 19 (Logging that survives production) revisits this file; the shape stays.

Step 4 — The error helpers both middleware need

Both middleware and the router reference error helpers that get their real implementation in Chapter 8 (CRUD done properly). To keep the build green until then, we use a deliberately crude stub:

// cmd/api/errors.go — TEMPORARY: fully replaced in chapter 8.
package main

import "net/http"

func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.logger.Error("server error", "error", err, "path", r.URL.Path)
    http.Error(w, "the server encountered a problem", http.StatusInternalServerError)
}

func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "the requested resource could not be found", http.StatusNotFound)
}
Note

You already have this file. In this edition it was created in Chapter 2 (The skeleton), because routes.go calls notFoundResponse from that chapter onwards and the project would not have compiled without it. Open cmd/api/errors.go, check it matches the listing above character for character, and move on. The original edition introduced it here, two chapters after it was first needed.

recoverPanic is the first thing that ever calls serverErrorResponse. Until now it sat there unused — legal for a method, unlike an unused local variable or import.

This is a pattern you’ll see throughout the book: when a forward dependency appears, we stub it in one obvious place with a loud comment, rather than reordering chapters into knots.

Step 5 — Wire the middleware into the router

Order matters, outermost first. Add the two r.Use lines to the top of routes():

// cmd/api/routes.go — add the two r.Use lines; the rest is unchanged
func (app *application) routes() http.Handler {
    r := chi.NewRouter()

    // Registration order = wrapping order: recoverPanic is outermost,
    // so it also catches panics thrown inside logRequest.
    r.Use(app.recoverPanic)
    r.Use(app.logRequest)

    r.NotFound(func(w http.ResponseWriter, r *http.Request) {
        app.notFoundResponse(w, r)
    })

    r.Route("/v1", func(r chi.Router) {
        r.Get("/healthcheck", app.healthcheckHandler)
    })

    return r
}

r.Use(f) registers f as a middleware for every route on this router, including the NotFound handler. Note that we pass app.recoverPanic with no parentheses: we are handing chi the function itself, to be called later with the next handler, not calling it now.

Here is what those two lines actually build:

  WHAT YOU WRITE                    WHAT CHI BUILDS
  ──────────────                    ───────────────
  r.Use(app.recoverPanic)   ──▶     recoverPanic(          ◀── registered
  r.Use(app.logRequest)               logRequest(              first =
  r.Route("/v1", ...)                   router))              outermost
                                                          ◀── registered
                                                              last =
                                                              innermost

And here is the same picture with the two lines swapped, which is the bug this ordering exists to prevent:

  r.Use(app.logRequest)     ──▶     logRequest(
  r.Use(app.recoverPanic)             recoverPanic(
                                        router))

  a panic thrown inside logRequest itself unwinds OUTWARDS,
  past recoverPanic — which is now inside it — and escapes:
        logRequest ──panic──▶ (nothing catches it) ──▶ net/http
Remember this

Registration order is wrapping order. The middleware registered first is outermost: it runs first on the way in and last on the way out. A recovery only protects what it wraps, so it goes first, always.

Common mistake

You’ll see: the server dies the instant you start it, with panic: chi: all middlewares must be defined before routes on a mux. It means: you put r.Use(...) after r.Route(...) — chi builds its routing tree when the first route is registered and refuses to have layers added afterwards, because the result would silently not apply to the routes already registered. Fix: move both r.Use lines above r.Route, as in the listing.

Step 6 — Test both behaviours

A panic-recovery middleware you have never seen catch a panic is a middleware you are trusting on faith. Add a route that explodes on purpose:

// cmd/api/routes.go — add inside r.Route("/v1", ...), delete after testing
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
    panic("boom") // TEMPORARY — delete after testing
})

Now run the two experiments:

go run ./cmd/api
curl -i localhost:4000/v1/panic
# HTTP/1.1 500 Internal Server Error <- clean response, server still alive
curl -i localhost:4000/v1/healthcheck
# HTTP/1.1 200 OK                     <- proof it survived

# now graceful shutdown: hit Ctrl-C in the server terminal
# msg="shutting down server" signal=interrupt
# msg="stopped server" addr=:4000     <- drained, exited zero

What you should see, in full. The panic request replies with headers and a plain-text body:

HTTP/1.1 500 Internal Server Error
Connection: close
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: <the current time, so yours will differ>
Content-Length: 33

the server encountered a problem

Connection: close is recoverPanic’s doing. The body text comes from the errors.go stub, which is why it is plain text rather than JSON for now.

Meanwhile the server terminal shows one error line for the panic — and, as promised in Step 3, no msg=request line for it, because the panic unwound past that code:

time=... level=ERROR msg="server error" error=boom path=/v1/panic
time=... level=INFO msg=request method=GET path=/v1/healthcheck
                    remote=[::1]:54492 duration=72.458µs

(The second entry is one line in your terminal; it is wrapped here to fit the page.)

(Your timestamps, ports and durations will differ. error=boom is the panic’s own value, having travelled through recover() and fmt.Errorf into the log.)

Then Ctrl-C in the server terminal:

time=... level=INFO msg="shutting down server" signal=interrupt
time=... level=INFO msg="stopped server" addr=:4000

With no traffic in flight those two lines appear a millisecond apart — there is nothing to drain. The Practice section makes the wait visible by giving the server something slow to finish.

Now see what you have been protected from. Comment out r.Use(app.recoverPanic), restart, and hit /v1/panic again:

curl -i localhost:4000/v1/panic
# curl: (52) Empty reply from server

No status line, no body, nothing — the connection closed without a reply. On the server side, one level=ERROR line whose msg begins http: panic serving [::1]:54617: boom\ngoroutine 9 [running]:\nnet/http.(*conn).serve.func1()\n\t... and continues for roughly thirty escaped \n-separated frames on that same line. That is Go’s own per-connection safety net catching the panic, and it is exactly the outcome section 1 described: no answer for the client, and a stack trace that no log tool can read. Uncomment the line, restart, confirm you get the clean 500 back.

Delete the panic route.

Warning

Really delete it. A live “crash this endpoint” URL is a denial-of-service button you built yourself and left in production. git grep panic before you commit.


7. Checkpoint: prove it works

Four checks. The first three need the server running in one terminal; run the rest in another.

go build ./... && echo BUILD-OK
go run ./cmd/api
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/nope
Checkpoint

go build prints BUILD-OK and nothing else. The server prints msg="starting server" addr=:4000 env=development. The two curls print 200 and 404. In the server terminal, each curl adds one line containing msg=request with a method, path, remote and duration field.

Now the lifecycle check. Press Ctrl-C in the server terminal and immediately run, in the same terminal:

echo $?
Checkpoint

The server logs msg="shutting down server" signal=interrupt followed by msg="stopped server" addr=:4000, and echo $? prints 0. Zero means the process exited successfully — a graceful shutdown is a success, not a crash, and everything from Docker to your CI pipeline judges your program by that number.

If you got something else:

You saw Cause Fix
Nothing at all: no log lines, curl fails with (7) Failed to connect the go keyword is missing before func() in serve(), so the program is blocked waiting for a signal and never listens add go back
level=ERROR msg="server error" error="listen tcp :4000: bind: address already in use", then exit an older copy of the server is still holding port 4000 lsof -i :4000 to find it, kill <PID> to stop it
Ctrl-C prints shutting down server and then the shell hangs a request is still in flight and being drained; this is the feature working wait — up to 30 seconds — or check what long request you started
stopped server never appears, but the process exits you deleted or skipped the <-shutdownError block, so nothing waits for the drain restore step 1’s block (6)

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: ./main.go:16:10: undefined: slog, repeated once for each line that mentions slog (your line and column numbers will differ). It means: main.go uses *slog.Logger but no longer imports log/slog — the classic result of deleting import lines by eye when the server block moved out. Fix: the import block in Step 2 is the complete, correct one: flag, fmt, log/slog, os.

Common mistake

You’ll see: ./main.go:6:5: "net/http" imported and not used It means: the opposite mistake — the server block left, its imports stayed. Go treats an unused import as an error, not a warning. Fix: delete net/http and time from main.go’s imports. They live in server.go now.

Common mistake

You’ll see: panic: chi: all middlewares must be defined before routes on a mux, at startup. It means: r.Use(...) was called after r.Route(...) or another route registration. Fix: both r.Use lines go at the top of routes(), before any route.

Common mistake

You’ll see: a panicking handler still returns curl: (52) Empty reply from server, even though you wrote recoverPanic. It means: most likely you called recover() directly instead of inside a deferred function. Outside a defer, recover() returns nil and catches nothing, with no complaint from the compiler. Fix: the defer func() { if err := recover(); ... }() shape in Step 3 is not stylistic. It is the only shape that works.

Common mistake

You’ll see: fatal error: all goroutines are asleep - deadlock! It means: every goroutine in the program is blocked waiting for something that can never arrive — typically a send on shutdownError with nothing left to receive it, or a receive with nothing left to send. Fix: check that block (6) receives exactly once from shutdownError and that the goroutine sends exactly once. One send, one receive.

Two silent ones — no error message, wrong behaviour:

  • Registering recoverPanic second. Nothing fails, and a panicking handler still produces a clean 500, so the mistake passes review. It only shows up the day a panic comes from logRequest or any middleware registered outside the recovery — and then the client gets nothing at all. Exercise 3 reproduces it deliberately.
  • A shutdown grace period shorter than your slowest request. Also invisible on a laptop with no traffic, and a guaranteed dropped request on every deploy under load. See the first pitfall.

9. Pitfalls

  • Draining the wrong channel. Returning ListenAndServe’s http.ErrServerClosed as a failure, or forgetting to wait on shutdownError, are the two classic bugs. The shape above is worth memorizing. The second is the dangerous one, because deleting the wait looks fine: the process still logs stopped server and exits 0, and only the in-flight requests — which you don’t have on a laptop — notice they were killed.

  • Shutdown timeout vs. WriteTimeout. If a handler can legally run 25 s, a 10 s shutdown grace guarantees dropped requests on deploy. Grace period >= your slowest legitimate request. The other half of that arithmetic is WriteTimeout: 10 * time.Second on the server itself: a handler that takes longer than ten seconds to produce its response has its connection closed by that timeout regardless of how generous your drain budget is. The three numbers — slowest handler, write timeout, drain budget — have to be consistent with each other, and this book’s are: fast handlers, 10 s, 30 s.

  • Recovery placement. Put recoverPanic after (inside) the logger and a panic in the logger escapes. Outermost. Always.

  • defer misconception. defer is per-function, not per-request-magic: it runs when the surrounding function returns, however it returns. That’s precisely why a deferred recover() catches panics — the panic is a way of returning. It is also why defer inside a loop does not run at the end of each iteration, which surprises everybody once.

  • Kubernetes/Compose nuance for later: Shutdown stops new connections instantly, but load balancers may still route for a beat. In Chapter 25 (Packaging with Docker) we add a stop grace period — stop_grace_period: 35s, deliberately five seconds longer than this chapter’s 30-second drain, so Docker never SIGKILLs a server that is still tidying up. For real load-balancer setups you’d also add a small pre-stop sleep, so the balancer stops sending you traffic before you stop accepting it. Filed for when it matters.

  • Ctrl-C a second time does not abort the drain. Once signal.Notify has registered SIGINT, the default “die now” behaviour is gone: extra presses land in the one-slot channel that nobody is reading any more, and the drain continues undisturbed. This is usually what you want and occasionally maddening — the only way to stop a draining server immediately is kill -9 <PID>, which sends SIGKILL and cannot be caught by anything.


10. Check yourself — quiz

  1. In one sentence, and including its signature, what is a middleware?
  2. srv.ListenAndServe() returns http.ErrServerClosed. Did the shutdown succeed?
  3. Why does the signal-waiting code need its own goroutine? What breaks if you delete the go?
  4. make(chan os.Signal, 1) — what does the 1 do, and why isn’t it 0?
  5. Why is defer cancel() there, given that the context expires by itself after 30 seconds anyway?
  6. Why does logRequest write its log line after next.ServeHTTP rather than before?
  7. Your slowest legitimate request takes 45 seconds. What goes wrong on deploy, and which numbers would you change?
  8. A request panics. With recoverPanic registered first, how many log lines does that request produce, and which?
Answers
  1. A middleware is a function that takes an http.Handler and returns a new http.Handler which does something before and/or after calling the original: func mw(next http.Handler) http.Handler. Because it takes and returns the same interface, they stack without knowing about each other.

  2. You don’t know yet. ErrServerClosed only means the listener has closed — “I’ve stopped accepting”. Whether the in-flight requests finished is the return value of srv.Shutdown(ctx), computed in the other goroutine and delivered over shutdownError. That is the entire reason the channel exists.

  3. ListenAndServe blocks for the life of the server, so the goroutine that is inside it cannot also be sitting at s := <-quit. Delete the go and the anonymous function runs inline, blocks on the channel receive, and ListenAndServe is never reached: the program sits silent with no port open and no log lines, and curl gets connection refused.

  4. It gives the channel room for one value, making it buffered. Signal delivery does not wait for a receiver, so a signal arriving at a moment when the goroutine is not sitting at the receive would be dropped by an unbuffered channel. One slot is enough because one signal is enough.

  5. Because the context holds a timer until it is cancelled, and cancel is what releases it. Skipping it leaks that timer until the deadline passes. It is also insurance: on the happy path the drain finishes in a fraction of a second, and cancel frees the resources at that moment rather than 30 seconds later. Pair WithTimeout with defer cancel() on adjacent lines, always.

  6. Because the duration is only known once the handler has returned — time.Since(start) needs the end of the request, not the start. More generally, anything a middleware wants to report about a request (duration, status, bytes) has to be recorded on the way out. That is exactly what Chapters 18 and 19 build on.

  7. The 30-second drain budget expires while the request is still running, so Shutdown returns a deadline error, serve() returns it, main logs server error and exits 1 — and the client gets a severed connection. Raise the drain budget above 45 seconds and raise WriteTimeout, which would otherwise cut the response off at 10 seconds anyway. In Chapter 25 the Compose stop_grace_period has to move up too, or Docker SIGKILLs you mid-drain.

  8. One: the level=ERROR msg="server error" line written by serverErrorResponse. There is no msg=request line, because the panic unwound past logRequest’s logging statement — deferred calls run during an unwind, ordinary statements do not.


11. Practice

Exercise 1 — Watch the drain happen, then break it

The shutdown you tested had nothing to drain. Give it something. Add a temporary slow route, start a request, and stop the server while that request is running.

// cmd/api/routes.go — add inside r.Route("/v1", ...), delete afterwards
r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
    time.Sleep(8 * time.Second)
    w.Write([]byte("done\n"))
})

(routes.go will need "time" in its import block for this.) Then: start the server, start a request to /v1/slow, press Ctrl-C about a second later, and record what the client gets, what the server logs, and how long it all takes. Afterwards, change the drain budget in server.go from 30*time.Second to 2*time.Second and do it again.

Solution

With the 30-second budget, in two terminals:

# terminal 2
curl -s -m 20 -w '\nHTTP=%{http_code} total=%{time_total}\n' localhost:4000/v1/slow

Press Ctrl-C in terminal 1 while that is running. The client waits out its eight seconds and then prints its answer:

done

HTTP=200 total=8.003480

And the server logs, in this order: msg="shutting down server" signal=interrupt at the moment you pressed the keys, then — eight seconds later — the msg=request line for /v1/slow with a duration of about 8.0s, then msg="stopped server". The exit status is 0.

Try a second curl to /v1/healthcheck during those eight seconds: it fails immediately with curl: (7) Failed to connect. That is “stop accepting, then drain” in one observation — the door is locked, the person inside is still being served.

Now set the budget to 2*time.Second, rebuild, and repeat. This time:

HTTP=000 total=2.99

The client gets nothing — no status code, connection dropped after roughly three seconds (one second of request plus the two-second budget). The server logs level=ERROR msg="server error" error="context deadline exceeded", never logs stopped server, and exits with status 1. That is precisely the pitfall: a grace period shorter than your slowest legitimate request drops that request on every single deploy.

Put 30*time.Second back and delete the /v1/slow route.

Exercise 2 — Write a third middleware

Write serverHeader, a middleware that sets the response header Server: taskd on every response, including 404s. Mount it. Then answer: could you have mounted it inside the r.Route("/v1", ...) group instead, and what would that change?

Solution
// cmd/api/middleware.go — add this function
func (app *application) serverHeader(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Server", "taskd")
        next.ServeHTTP(w, r)
    })
}
// cmd/api/routes.go — add as the third r.Use, below the other two
r.Use(app.recoverPanic)
r.Use(app.logRequest)
r.Use(app.serverHeader) // innermost of the three

Verify:

curl -sD- -o /dev/null localhost:4000/v1/healthcheck | grep -i '^server:'
# Server: taskd
curl -sD- -o /dev/null localhost:4000/v1/nothing-here | grep -i '^server:'
# Server: taskd

(-D- dumps the response headers to the screen and -o /dev/null throws the body away.)

Mounted inside r.Route("/v1", ...) it would still work for /v1/... paths, but the NotFound handler is registered on the root router, so a 404 for /nope would come back without the header — and so would /metrics when Chapter 18 adds it at the root. Where you register decides which requests get wrapped at all; the order of registration decides how deep the wrapping goes.

One ordering constraint that does matter for this middleware: the header must be set before next.ServeHTTP. Once a handler has written the status line, header changes are ignored silently. Move that line below the next.ServeHTTP call and the middleware compiles, runs, and does nothing.

Exercise 3 — Find out where a panic escapes

Swap the two r.Use lines so logRequest is registered first, re-add the temporary /v1/panic route, and predict before you run: (a) what status does the client get, (b) does the server survive, © does the request appear in the logs? Then run it. Then find the case where the order does break something.

Solution

With logRequest outermost and recoverPanic inside it, hitting /v1/panic gives:

  • (a) still HTTP/1.1 500 Internal Server Error;
  • (b) yes, the server survives;
  • © and now the request line does appear — msg=request method=GET path=/v1/panic — because recoverPanic swallowed the panic below logRequest, so logRequest’s call to next.ServeHTTP returned normally.

So this test does not show the bug. That is exactly why the mistake ships: for a panic thrown by a handler, either order works.

The order only matters for a panic thrown inside logRequest itself, or inside any middleware registered outside the recovery. Prove it by temporarily adding panic("logger exploded") as the first line of logRequest’s inner function and hitting any route at all:

Order What the client gets What the log gets
recoverPanic first (correct) HTTP/1.1 500 with Connection: close level=ERROR msg="server error" error="logger exploded" path=/v1/healthcheck
logRequest first (broken) curl: (52) Empty reply from server one level=ERROR line containing http: panic serving ... and the whole stack trace escaped onto that line

Remove the deliberate panic, put r.Use(app.recoverPanic) back on top, and delete the /v1/panic route. The general rule: a recovery middleware protects only what it wraps, and the code most likely to panic unexpectedly is whatever you wrote most recently — which is usually another middleware.


12. FAQ

Why not use chi’s built-in Recoverer and Logger? Because these two are fifteen lines each and they decide what your users see when something breaks. chi’s Recoverer writes its own error response, which would not be the JSON envelope Chapter 8 introduces; its Logger writes to its own format, not our slog fields. You would end up either configuring them or replacing them, having never read either. For dependencies this small, reading them and owning them is cheaper. That reasoning does not scale — nobody should hand-roll a Postgres driver — and the book takes the opposite decision, loudly, in Chapters 7 and 15.

Why does Docker send SIGTERM rather than killing the process? Because SIGTERM is the polite half of a two-step protocol. docker stop sends SIGTERM, waits (ten seconds by default), and only then sends SIGKILL, which cannot be caught. The design assumes your program will use those seconds to finish what it is doing. A program that ignores SIGTERM is choosing to be killed on every deploy while a perfectly good grace period goes unused. Chapter 25 sets that waiting period explicitly to 35 seconds, so it is longer than our 30-second drain.

What happens to a request that is still running at 30 seconds? Shutdown gives up on it: it returns context deadline exceeded, serve() returns that error, main logs it and exits 1. The client’s connection is closed with no reply. Nothing waits forever, by design — a process that refuses to die is a worse problem than a dropped request, because the deploy that is trying to replace it stalls too.

Is a panic the same thing as an exception in Python or JavaScript? Mechanically it is close: both unwind the stack looking for a catcher. Culturally it is the opposite. In those languages, throwing is a normal way to report a normal failure. In Go, expected failures are ordinary values you return and check (if err != nil), and a panic means “the program is in a state I said was impossible”. That is why recover is rare, ugly on purpose, and lives in exactly one place in this codebase.

Should I recover from panics inside my handlers too? No. One recovery, at the outermost edge, is the whole policy. Recovering inside a handler means carrying on after something impossible happened, with data you cannot reason about. The middleware turns the panic into a 500 and a log line — which is honest — and the request ends there.

Why is main getting smaller while the program gets bigger? Because main is the one function nothing else can call, test, or reuse. Every line of logic in it is a line no test can reach. Moving the lifecycle into serve() means the interesting behaviour — “how does this program stop?” — lives in a method on application, next to everything else. By Chapter 27 main will have grown to about fifty lines, and every one of them will still be either “build a dependency” or “hand it over”. Appendix E is that final version.


13. Where we are

A polite, observable server with clean lifecycle. main is a wiring diagram, serve() owns life and death, and two hand-rolled middleware demystified the pattern the rest of the book leans on. It still has no data. Time for Postgres.

The repository as it now stands (+ marks files new in this chapter, * marks files changed):

taskd/
├── cmd/api/
│   ├── main.go          *  now only wiring: config, logger, app, serve()
│   ├── server.go        +  serve(): timeouts, signals, drain, verdict
│   ├── middleware.go    +  recoverPanic, logRequest
│   ├── routes.go        *  two r.Use lines at the top
│   ├── config.go           koanf loader                          (ch. 3)
│   ├── healthcheck.go      the one handler                       (ch. 2)
│   └── errors.go           TEMPORARY stub, replaced in ch. 8     (ch. 2)
├── internal/data/
├── internal/db/
├── internal/validator/
├── internal/cache/
├── migrations/
├── sql/queries/
├── bin/
├── config.toml             dev defaults, env-overridable         (ch. 3)
├── go.mod
├── go.sum
└── .gitignore

What works end to end: the server loads its config, logs one structured line per request, answers GET /v1/healthcheck with 200 and anything else with 404, converts any panic below recoverPanic into a logged 500 while staying alive, and — on Ctrl-C or SIGTERM — stops accepting new connections, finishes the requests already running (up to 30 seconds), and exits with status 0.

What is still fake: there is no database, so the healthcheck’s "status":"available" is an opinion rather than a measurement (Chapter 6, Connecting with pgx/v5, makes it check something). errors.go returns plain text instead of JSON and says so in a shouting comment (Chapter 8, CRUD done properly). The request log has no status code and no request ID (Chapters 18 and 19). And there is exactly one endpoint.

For your notes — copy these into learnings/ch04.md in your own words:

  1. ListenAndServe returning http.ErrServerClosed means “I’ve stopped accepting”, not “everyone is out”. The real verdict is Shutdown’s return value, computed in another goroutine — which is the entire reason there is a channel.
  2. A middleware is a function that takes a handler and returns a handler. Registration order is wrapping order: registered first means outermost, which means first on the way in and last on the way out.
  3. Panic recovery goes outermost, because a recovery only catches what it wraps, and the most likely source of an unexpected panic is another middleware.
  4. recover() works only inside a deferred function. That constraint, not style, is why the defer func() { ... }() shape exists.
  5. Anything a middleware wants to report about a request — duration, status, size — has to be recorded after next.ServeHTTP returns. The way out of the onion is where observability lives.

Chapter 5 — PostgreSQL and migrations

Your server can answer a request and shut down politely, and it forgets everything the moment it stops. This chapter gives it a memory: a real PostgreSQL database, started with one command, plus a way of changing that database’s shape over time that works identically on your laptop, on a colleague’s laptop, on the build robot, and in production. By the end you will have typed no database password twice and no long command more than once — a Makefile will hold them all.

What you’ll be able to do by the end

  • Start and stop a real PostgreSQL 17 database with one command, without installing PostgreSQL.
  • Explain what a migration is, why the number in its filename matters, and why an applied one is never edited.
  • Read a connection string like postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable segment by segment.
  • Create the tasks table from a versioned SQL file and prove, from inside the database, that the table refuses invalid data.
  • Write a Makefile target and say what .PHONY is for.

Time: ~45 minutes reading, ~30 minutes typing.

You need before starting: the server from Chapter 4 (A server that dies well: lifecycle and core middleware) still running and shutting down cleanly, plus Docker running. Prove both:

go run ./cmd/api
curl -i localhost:4000/v1/healthcheck
docker compose version

The first should print HTTP/1.1 200 OK; the second a line starting Docker Compose version v2. If Docker answers with anything ending in Is the docker daemon running?, start Docker Desktop and wait for the whale icon to settle before continuing. Nothing in this chapter works without it.

Tip

If you worked through SQL and databases in one sitting, delete its throwaway database now: docker rm -f sql-primer. It cannot collide with what we build here, but two Postgres containers with the same username and password is an excellent way to confuse yourself for twenty minutes.


1. The problem, in plain words

There are two problems here, and they get confused with each other constantly. Keep them apart.

Problem one: where does the database come from?

You need PostgreSQL running on your machine. The obvious route is to install it, the way you installed Go. That works on day one and rots afterwards. You end up on version 16 while production runs 17. A leftover table from a project you abandoned in March is still sitting there. A colleague joins, follows your notes, and hits an error you have never seen because their machine’s Postgres was installed by a different package manager, in a different place, with a different default encoding. The phrase for the end state is “works on my machine”, and it is not a joke — it is hours of somebody’s week, repeatedly.

Problem two: how does the database’s shape change over time?

A database is not only data. It is data plus a shape: which tables exist, what columns they have, what values those columns will accept. That shape changes constantly while a project is alive. This book changes it seven times.

Here is the scenario that makes the whole chapter necessary. It is worth reading slowly, because everything else follows from it.

You add a column to your local database by typing SQL at a prompt. Your code now uses that column. You commit the code. Your colleague pulls it, runs the program — and it crashes, because their database has no such column. So you tell them the SQL over chat. They run it, but they typo the default value. Now two databases with the same version of the code behave differently. Then the build robot runs the tests against a fresh empty database and everything fails, because nobody told the robot anything at all. And production? Production is the one where somebody runs the wrong statement at 11pm.

  the code               the database shape
  ─────────              ──────────────────
  in Git                 in somebody's head, or a chat message,
  reviewed               or a text file called schema_final_v2.sql
  versioned              applied by hand, differently, four times
  identical everywhere   different everywhere

The whole problem is that the left column is disciplined and the right column is not. A migration closes that gap: it puts the shape changes into numbered files that live in Git next to the code that depends on them, and it lets a tool — not a human at 11pm — apply them in order.

Why this exists

The database’s shape is part of your program, so it belongs in the same place as the rest of your program: version control, reviewed in a pull request, applied by a machine. Anything else means the answer to “what shape is the production database?” is “let me connect and look”, which is only ever asked during an incident.


2. New words in this chapter

  • image — the frozen template a container is started from. postgres:17-alpine is one.
  • container — a running, isolated copy of a packaged program, with its own filesystem and its own view of the network. The image is the mould; the container is the casting.
  • Docker Compose — a file describing several containers and the command that starts them together as one system. Ours is docker-compose.yml.
  • service — one named container in a Compose file. We define one today, called db.
  • YAML — the indentation-based text format Compose files are written in. Indentation is structure, so it is not decorative.
  • published port — a mapping that makes a port inside a container reachable from your machine. "5432:5432" means “host port 5432 goes to container port 5432”.
  • named volume — storage Docker manages outside the container, so data survives when the container is destroyed and recreated.
  • healthcheck — a command Docker runs repeatedly to decide whether a container is actually ready, not merely started.
  • pg_isready — a small program shipped with Postgres that answers “are you accepting connections?” and nothing else.
  • schema — the shape of a database: which tables exist, which columns they have, what is allowed in them.
  • migration — a numbered SQL file that makes one change to the schema, paired with a file that undoes it.
  • .up.sql / .down.sql — the two halves of a migration: the change, and its reversal.
  • golang-migrate — the tool this book uses to apply migrations in order.
  • schema_migrations — the small table golang-migrate creates to record which migrations this database has already run. Its memory.
  • dirty state — the flag migrate sets when a migration fails halfway, which stops everything until a human looks.
  • roll forward — fixing a bad schema change with a new migration rather than undoing the old one. What production rollbacks actually are.
  • DSN — “data source name”: one string containing everything needed to reach a database. A full postal address written on one line.
  • psql — Postgres’s own command-line client. You type SQL, it prints tables.
  • primary key — the column whose value uniquely identifies each row.
  • identity column — a column where Postgres assigns the values itself: 1, 2, 3, and so on.
  • UUID — a 128-bit random identifier, unique without any central counter, written as 9f1c8a4e-.... An alternative to counting, which we consider and reject.
  • index — an extra sorted structure the database keeps so it can find matching rows without reading every one of them.
  • CHECK constraint — a rule the database enforces on every write, so an invalid value cannot be stored by any code path at all.
  • NOT NULL — a rule saying this column may never hold NULL, the absence of a value.
  • DEFAULT — the value a column takes when an INSERT does not mention it.
  • timestamptz — a date-and-time that records its time zone. Always use it instead of plain timestamp.
  • Makefile — a file of named shortcuts for long commands, run as make <name>.
  • target — one of those names. Recipe — the indented command lines underneath it, which must begin with a real tab character.
  • .PHONY — tells make a target is a command to run, not a file to build.
  • direnv — an optional tool that automatically loads a folder’s .envrc when you cd into it.

3. The goal

A Postgres 17 instance running in Docker for development, a migrations/ directory managed by golang-migrate, and the first migration creating the tasks table — with a Makefile so no command ever needs remembering.


4. The thinking

4.1 Where to run development Postgres

Option What it means Verdict
Install it on your machine A system service, started at boot, upgraded by your package manager Works, then rots: version drift, leftover state, “works on my machine”
A shared development server One Postgres somewhere that everyone connects to Your colleague’s experiment drops your table; you cannot work offline
Docker Compose A file that describes the database; one command starts it Chosen. Every collaborator — including the build robot, including you on a new laptop — gets the identical database in one command

We start docker-compose.yml now and grow it all book long. By Chapter 25 (Docker: a 15 MB production image) the same file also runs the cache, the fake mail server, the metrics scraper and taskd itself.

4.2 What a migration actually is

“Migration” sounds grander than it is. Your database’s structure — its schema: tables, columns, indexes — changes over the life of a project, and those changes must happen identically on your laptop, your colleague’s, the build robot’s, and production.

A migration is a numbered SQL file that makes one such change (000001_create_tasks.up.sql), paired with a file that undoes it (000001_create_tasks.down.sql). A tool applies them in order and records in the database which ones have run, so migrate up is always safe to repeat — it only applies what is new. Your schema’s history becomes readable, reviewable Git history.

Think of it like

A migrations directory is your schema’s git log, except executable. Each file is one commit to the shape of your data, and the database remembers which commit it is on.

Remember this

migrate up is safe to run any number of times. It compares what the database says it has applied against what is in the folder, and runs only the difference.

4.3 Three ways to manage a schema

Path How it works Why not / why yes
(a) Hand-run psql and hope Someone types the SQL on each machine No record, no order, no review. This is the 11pm scenario from section 1
(b) ORM auto-migration A library inspects your Go types at startup and alters the database to match Schema changes you never reviewed, applied at application boot. A subscription to production incidents
© Versioned SQL files Numbered .up.sql/.down.sql pairs applied by a tool Chosen. Dumb, ordered, reviewable, and the history lives in Git next to the code that depends on it
New word

ORM auto-migration — an “object-relational mapper” is a library that writes your database queries for you from code. Some of them go further and rewrite your tables to match your code automatically when the program starts. The convenience is real. So is the day it decides your renamed field means “drop that column”.

We take path © with golang-migrate. goose is an equally good tool; migrate wins here for one sneaky reason revealed in Chapter 7 (sqlc: SQL in, type-safe Go out) — sqlc can read golang-migrate files directly as its schema source, so the migrations directory becomes the single source of truth for both the database and the generated Go code. One truth, two consumers.

4.4 Schema design for tasks, argued out loud

Primary key: bigint GENERATED ALWAYS AS IDENTITY, not UUID. Integers are half the size, index-friendly, and human-typeable in a debugger. The classic UUID argument — “sequential IDs let outsiders enumerate your data” — is real, but our fix is authorization (every query is scoped to a user from Chapter 12 (Ownership: making it multi-tenant) onward), not obfuscation. If you later expose IDs in public URLs and it itches, add a public_id uuid column; don’t make the primary key pay for it. (If you do want UUIDs: pgx v5 handles github.com/google/uuid natively; one sqlc override and you’re done.)

New word

enumeration — walking /v1/tasks/1, /v1/tasks/2, /v1/tasks/3 to discover what exists. Random IDs make guessing hard; they do not make the data private. Only a permission check does that, which is why the fix belongs in the handler and not in the primary key.

Statuses and priorities: text plus a CHECK constraint, not a Postgres ENUM. An ENUM is a custom type listing the allowed values. They are neat until you need to remove a value, at which point you are rewriting a type other tables depend on. A check constraint alters in one statement.

NOT NULL everywhere possible. Every nullable column becomes a pgtype wrestling match in sqlc-generated Go and an if in every consumer. notes defaults to ''; only due_at is genuinely optional, and it earns its null. (pgtype is the driver’s family of “value, plus a flag saying whether there was a value” wrappers. Chapter 7 shows what reading one costs.)

New word

NULL — not zero, and not the empty string. It means “no value at all here”. An empty box versus a box containing the word “empty”. Every nullable column forces the Go code that reads it to answer “and what if there is nothing?” — which is fine when the answer is interesting, and pure noise when it isn’t.

A version column from birth. It powers optimistic locking in Chapter 8 (CRUD done properly: JSON helpers, errors, validation), where two people editing the same task at the same time must not silently overwrite each other. Retrofitting it later means a migration plus touching every update path; adding it now costs one line.

No user_id yet. Users don’t exist until Chapter 10 (Users and passwords). Rather than pretend, we’ll evolve the schema with a real migration in Chapter 12 (Ownership: making it multi-tenant) — which is exactly how living systems change, and worth practising.


5. A picture of it

Two pictures. The first is the problem migrations solve, drawn.

   your laptop     colleague       CI robot      production
   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
   │ version 1│   │ version 1│   │ version 0│   │ version 1│
   └────┬─────┘   └────┬─────┘   └────┬─────┘   └────┬─────┘
        │              │              │              │
        └──────────────┴───────┬──────┴──────────────┘
                               │
              migrations/  000001_create_tasks.up.sql
              (in Git)     000002_create_users.up.sql
                               │
              each database runs only what it is missing
                               │
        ┌──────────────┬───────┴──────┬──────────────┐
   ┌────▼─────┐   ┌────▼─────┐   ┌────▼─────┐   ┌────▼─────┐
   │ version 2│   │ version 2│   │ version 2│   │ version 2│
   └──────────┘   └──────────┘   └──────────┘   └──────────┘
  1. Three databases are already at version 1; the freshly created CI database is at version 0.
  2. One command — migrate up — runs on each of them.
  3. The three at version 1 run only 000002. The empty one runs 000001 and then 000002.
  4. All four end at version 2, with byte-identical structure. Nobody typed any SQL.

The second picture is the Compose service you are about to write, with each part labelled by what it does and what breaks without it.

  services:
    db:                     ── the service's name; you refer to it as "db"
      image: ──────────────▶ which packaged Postgres to run (pinned to 17)
      environment: ────────▶ username, password, database name, used ONCE
                             on the very first boot to set the database up
      ports: ──────────────▶ makes the container's 5432 reachable from your
                             machine; without it `go run` cannot connect
      volumes: ────────────▶ where the data actually lives; without it, your
                             rows vanish when the container is recreated
      healthcheck: ────────▶ how Docker knows Postgres is ACCEPTING
                             CONNECTIONS, not merely "started"
  volumes:
    db-data: ──────────────▶ declares the storage the service asked for

6. The steps

Step 1 — Write the Compose file and start the database

Create this file in the project root, next to go.mod. Everything in it is explained underneath.

# docker-compose.yml
services:
  db:
    image: postgres:17-alpine        # small official image, pinned major version
    environment:                     # first-boot setup: user, password, database name
      POSTGRES_USER: taskd
      POSTGRES_PASSWORD: pa55word
      POSTGRES_DB: taskd
    ports:
      - "5432:5432"                  # expose to the host so `go run` can reach it
    volumes:
      - db-data:/var/lib/postgresql/data   # named volume: data survives restarts
    healthcheck:                     # how Docker knows the DB is READY, not just started
      test: ["CMD-SHELL", "pg_isready -U taskd -d taskd"]
      interval: 5s                   # check every 5s...
      timeout: 3s                    # ...each attempt gets 3s...
      retries: 10                    # ...unhealthy after 10 failures

volumes:
  db-data:                             # declares the named volume used above

What this file says, line by line

  • YAML uses indentation for structure, the way an outline does. db: is nested under services:, so db is a service. Use spaces, never tabs — a tab in a YAML file is an error, which is a fine joke given what Makefile demands two steps from now.
  • image: postgres:17-alpine — the packaged program to run. postgres is the official image; 17 pins the major version so an upgrade is a decision, not a surprise; alpine is a variant built on a small base, so the download is smaller.
  • environment: — values handed to the program when the container starts. These three are read by the Postgres image only on the very first boot, when it creates the database from nothing. Change POSTGRES_PASSWORD later and nothing happens, because the data directory already exists. That surprises everyone once.
  • ports: - "5432:5432" — the left number is the port on your machine, the right is the port inside the container. 5432 is Postgres’s conventional port. Without this line the database runs perfectly and go run ./cmd/api cannot see it at all.
  • volumes: - db-data:/var/lib/postgresql/data — the path on the right is where Postgres keeps its files inside the container; db-data is a name Docker manages storage under. Containers are disposable; this is how the data stops being disposable with them.
  • healthcheck: — a command Docker runs on a timer inside the container. pg_isready asks Postgres “are you accepting connections?”. CMD-SHELL means “run this string through a shell”. With interval: 5s, timeout: 3s and retries: 10, Docker checks every five seconds, gives each attempt three seconds, and marks the container unhealthy after ten consecutive failures.

The healthcheck earns its lines in Chapter 25 (Docker: a 15 MB production image), where other containers wait on condition: service_healthy. “Postgres has started” and “Postgres is accepting connections” are seconds apart, and racing into that gap is the classic Compose bug.

Now start it:

docker compose up -d db

up means “create and start”; -d means “detached” — run in the background and give me my prompt back; db names the one service to start.

What you should see: on the first run, progress lines while the postgres:17-alpine image downloads, then a short summary in which a network and a container are Created and then Started. The container’s name is derived from your folder, so it will look like taskd-db-1.

Check that it is not merely running but ready:

docker compose ps

The STATUS column reads Up ... (health: starting) for the first few seconds and then Up ... (healthy). That transition is the healthcheck doing its job. If it never becomes healthy, docker compose logs db prints what Postgres said.

Step 2 — Install the migrate CLI

migrate is a command-line program, not a Go library you import. Install a binary release from the project’s website, or let Go build it for you:

go install -tags 'postgres' \
  github.com/golang-migrate/migrate/v4/cmd/migrate@latest

go install downloads that program’s source, compiles it, and puts the finished binary in $(go env GOPATH)/bin. The -tags 'postgres' part asks for a build that includes the PostgreSQL driver — migrate supports many databases and only compiles in the ones you name. The backslash means “the command continues on the next line”.

What you should see: the first run prints a run of go: downloading ... lines and then stops, possibly for a minute — it is compiling a program. Prove it worked:

migrate -version

A version string such as v4.19.1.

Common mistake

You’ll see: zsh: command not found: migrate (or bash: migrate: command not found). It means: the install worked, but $(go env GOPATH)/bin is not on your PATH, so your shell cannot find the program it built. Fix: the two lines in Before you begin, §5.3 — append that directory to PATH in your shell’s startup file, then open a new terminal.

Step 3 — The .envrc, then the Makefile

The database password has to live somewhere, and that somewhere must not be Git. We put it in a file called .envrc, which Chapter 2 (The skeleton: a server that answers) already added to .gitignore.

# .envrc — machine-local variables. `include .envrc` in the Makefile loads it;
# if you use direnv, it also auto-exports these when you cd into the project.
export TASKD_DB_DSN='postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable'

Read that DSN once, left to right — you’ll be reading these for the rest of your career.

  postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable
  └────┬────┘└─┬─┘ └──┬───┘ └───┬───┘ └┬─┘ └─┬─┘ └──────┬──────┘
       │       │      │         │      │     │          │
       │       │      │         │      │     │          └──────── options
       │       │      │         │      │     └─────────────────── database name
       │       │      │         │      └───────────────────────── port
       │       │      │         └──────────────────────────────── host
       │       │      └────────────────────────────────────────── password
       │       └───────────────────────────────────────────────── username
       └───────────────────────────────────────────────────────── scheme

postgres:// is the scheme, taskd:pa55word the username and password, @localhost:5432 the host and port, /taskd the database name, and ?sslmode=disable an option — encryption off, because this is a container on your own machine talking to a program on the same machine. Production uses TLS, and Chapter 27 (Production checklist, and where to go next) says so again.

Note

This variable is not the one that configures your Go program. TASKD_DB_DSN has one underscore and exists for the migrate command only. Chapter 3 (Configuration and logging, the Nadh way) reads its settings through koanf, whose override for the same value is TASKD_DB__DSN with two underscores — two underscores become the dot in db.dsn. Setting the single-underscore one and expecting the server to notice is a quiet twenty-minute mistake. Your Go program takes its DSN from config.toml, which already has the same string in it.

Now the Makefile. From here on, it is the project’s front door.

# Makefile
include .envrc            # optional: export TASKD_DB_DSN here (gitignored)

.PHONY: run/api
run/api:
	go run ./cmd/api

.PHONY: db/psql
db/psql:
	docker compose exec db psql -U taskd -d taskd

.PHONY: db/migrations/new
db/migrations/new:
	migrate create -seq -ext sql -dir ./migrations $(name)

.PHONY: db/migrations/up
db/migrations/up:
	migrate -path ./migrations -database $(TASKD_DB_DSN) up

What this file says, line by line

  • Each target is a named shell command. make db/psql types the long docker compose exec db psql -U taskd -d taskd line for you, forever. The slashes in the names are not paths — they are only characters, chosen to group related targets readably.
  • include .envrc reads that file as if its contents were typed here, which is how $(TASKD_DB_DSN) gets a value.
  • .PHONY tells make these are commands, not files it should look for on disk. Without it, a target called test would do nothing on a day you happened to have a folder named test, because make would decide the file already exists and is up to date.
  • The command lines must start with a real tab character. This is make’s oldest and least forgiving rule.
  • $(name) is a make variable. make db/migrations/new name=create_tasks sets it on the command line, and make pastes the value in.
  • docker compose exec db psql -U taskd -d taskdexec runs a command inside an already-running container. psql is Postgres’s own client, -U the user, -d the database.
Common mistake

You’ll see: Makefile:5: *** missing separator. Stop. It means: the indented line at that line number begins with spaces, not a tab. Your editor almost certainly converted it for you. Fix: delete the leading whitespace and press Tab once. In VS Code, the status bar at the bottom right says Spaces: 4; click it and choose “Indent Using Tabs” for this file.

Note

Order matters here, and this edition changed it. The original edition printed the Makefile first and .envrc afterwards, and calls the include “optional”. include in make is not optional: with no .envrc on disk, every make command fails before running anything, with Makefile:2: .envrc: No such file or directory followed by make: *** No rule to make target '.envrc'. Stop. We therefore create .envrc first. The Makefile itself is unchanged — but if you ever clone this project onto a new machine, remember that .envrc is gitignored and will not be there. Writing -include .envrc, with a leading hyphen, makes make skip a missing file silently, at the cost of a confusing empty DSN later. Both choices are defensible; know which one you made.

Check it works:

make run/api

You should get the same startup log line as go run ./cmd/api produced. Stop it with Ctrl-C.

Step 4 — The first migration

Ask migrate to create the file pair:

make db/migrations/new name=create_tasks

What you should see: two lines, each the full path of a file it created — one ending 000001_create_tasks.up.sql and one ending 000001_create_tasks.down.sql. Both are empty.

The flags, from the Makefile: -seq numbers migrations sequentially (000001, 000002) rather than with a timestamp; -ext sql gives them the .sql extension; -dir ./migrations says where to put them.

Here is the table we are creating, before the SQL that creates it:

   tasks
   ┌────────────┬─────────────┬────────────────────────────────────┐
   │ column     │ type        │ rules                              │
   ├────────────┼─────────────┼────────────────────────────────────┤
   │ id         │ bigint      │ PRIMARY KEY, Postgres assigns it   │
   │ created_at │ timestamptz │ NOT NULL, DEFAULT now()            │
   │ updated_at │ timestamptz │ NOT NULL, DEFAULT now()            │
   │ title      │ text        │ NOT NULL — the only required input │
   │ notes      │ text        │ NOT NULL, DEFAULT ''  (never NULL) │
   │ status     │ text        │ NOT NULL, DEFAULT 'open'           │
   │            │             │   CHECK in (open,done,archived)    │
   │ priority   │ text        │ NOT NULL, DEFAULT 'none'           │
   │            │             │   CHECK in (none,low,medium,high)  │
   │ due_at     │ timestamptz │ nullable — the one honest NULL     │
   │ version    │ integer     │ NOT NULL, DEFAULT 1                │
   └────────────┴─────────────┴────────────────────────────────────┘
        ▲              ▲                       ▲
        │              │                       │
    what it is    what kind of      what the database itself
     called          value            refuses to store

Now fill in the up file:

-- migrations/000001_create_tasks.up.sql

CREATE TABLE tasks (
    -- IDENTITY: Postgres assigns 1, 2, 3... itself; GENERATED ALWAYS means
    -- clients can't insert their own ids (a whole bug class, deleted).
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- timestamptz = timestamp WITH time zone. now() stamps creation for free.
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now(),

    title       text        NOT NULL,
    notes       text        NOT NULL DEFAULT '',   -- empty string, never NULL

    -- The CHECK constraint makes invalid states unstorable: the database
    -- itself rejects status='banana', no matter which code path tries.
    status      text        NOT NULL DEFAULT 'open'
                CHECK (status IN ('open','done','archived')),
    priority    text        NOT NULL DEFAULT 'none'
                CHECK (priority IN ('none','low','medium','high')),

    due_at      timestamptz,                       -- the ONE honest NULL

    -- Optimistic-locking counter; ch. 8 explains the dance it powers.
    version     integer     NOT NULL DEFAULT 1
);

What this SQL says, line by line

  • CREATE TABLE tasks ( ... ); — define a new table and its columns. Each line inside is name type rules.
  • bigint — a whole number with room for about nine quintillion. GENERATED ALWAYS AS IDENTITY makes this an identity column: Postgres supplies the value itself, counting up. ALWAYS (rather than BY DEFAULT) means a client is forbidden from supplying its own — an entire class of “two things with id 7” bugs, deleted. PRIMARY KEY marks this as the column that identifies a row, which also creates an index on it.
  • timestamptz NOT NULL DEFAULT now() — a moment in time that knows its zone; may never be absent; if an insert doesn’t mention it, Postgres uses the current time. So created_at costs the application nothing to maintain. Note that updated_at is not maintained automatically — there is no trigger here, and every UPDATE in this book sets it explicitly.
  • text NOT NULL on title — the only column a caller must actually provide.
  • notes text NOT NULL DEFAULT '' — read this as the argument from section 4.4 made concrete. An absent note is the empty string, so no Go code ever has to ask “is this nothing, or is it nothing specific?”
  • CHECK (status IN ('open','done','archived')) — a rule stored with the table. Every insert and update is tested against it. Not “the code checks”; the database checks, so a script, a colleague at a psql prompt, and a bug in your handler are all equally unable to store status='banana'.
  • due_at timestamptz, with no NOT NULL — the single nullable column. “This task has no deadline” is genuinely different from “this task is due at midnight”, so the null carries meaning.
  • version integer NOT NULL DEFAULT 1 — a counter that nothing reads yet.
Why this exists

A CHECK constraint and a validation rule in Go are not duplicates of each other; they answer to different audiences. Go’s version produces a polite message for a human (“status must be one of open, done, archived” — Chapter 8 builds it). The database’s version is the last line of defence against every code path that has not been written yet. You want both.

And the down file, which undoes exactly what the up file did:

-- migrations/000001_create_tasks.down.sql
DROP TABLE IF EXISTS tasks;

IF EXISTS means “and don’t complain if it is already gone”, which makes the file safe to run against a database that only got halfway.

Step 5 — Apply it and look at what you made

make db/migrations/up

Make prints the command it is about to run, then runs it. migrate is quiet when it succeeds: you should get your prompt back with no line beginning error:. Run the same command again and it prints error: no change — that is migrate telling you there was nothing left to do, not a failure.

Now open a SQL prompt against the database:

make db/psql

You get Postgres’s banner and a prompt reading taskd=#. Type:

\dt
        List of relations
 Schema |       Name        | Type  | Owner
--------+-------------------+-------+-------
 public | schema_migrations | table | taskd
 public | tasks             | table | taskd
(2 rows)

There are two tables, and you only asked for one. schema_migrations is migrate’s bookkeeping — the database’s own memory of which migrations it has run. Leave it alone, but look at it once:

SELECT * FROM schema_migrations;
 version | dirty
---------+-------
       1 | f
(1 row)

One row: “I am at version 1, and nothing went wrong getting here”. That single row is why migrate up is safe to repeat and why four machines end up identical.

Then describe the table itself:

\d tasks
                                    Table "public.tasks"
   Column   |           Type           | Collation | Nullable |           Default
------------+--------------------------+-----------+----------+------------------------------
 id         | bigint                   |           | not null | generated always as identity
 created_at | timestamp with time zone |           | not null | now()
 updated_at | timestamp with time zone |           | not null | now()
 title      | text                     |           | not null |
 notes      | text                     |           | not null | ''::text
 status     | text                     |           | not null | 'open'::text
 priority   | text                     |           | not null | 'none'::text
 due_at     | timestamp with time zone |           |          |
 version    | integer                  |           | not null | 1
Indexes:
    "tasks_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "tasks_priority_check" CHECK (priority = ANY (ARRAY['none'::text, 'low'::text, 'medium'::text, 'high'::text]))
    "tasks_status_check" CHECK (status = ANY (ARRAY['open'::text, 'done'::text, 'archived'::text]))

Three things to notice. Postgres rewrote your CHECK (status IN (...)) into status = ANY (ARRAY[...]) — same meaning, its own words. due_at is the only row with an empty Nullable column. And PRIMARY KEY quietly created an index called tasks_pkey, which is what makes “find the task with id 4211” instant rather than a walk through every row.

Type \q to leave.


7. Checkpoint: prove it works

Four commands, from the project root, with the container running:

docker compose ps
make db/migrations/up
docker compose exec db psql -U taskd -d taskd -tAc "SELECT version, dirty FROM schema_migrations;"
docker compose exec db psql -U taskd -d taskd -c "INSERT INTO tasks (title, status) VALUES ('x','banana');"
Checkpoint

docker compose ps shows the db service with STATUS containing (healthy). The second command prints error: no change (there is nothing new to apply). The third prints exactly 1|f. The fourth fails on purpose, with ERROR: new row for relation "tasks" violates check constraint "tasks_status_check" — proof that the rule lives in the database and not in anybody’s good intentions.

The -tAc flags are worth keeping: -t drops the header and row count, -A drops the column padding, -c runs one statement and exits. Together they give output you can compare exactly.

If you got something else:

You saw Cause Fix
error: failed to open database: dial tcp ...:5432: connect: connection refused The container isn’t running, or isn’t ready yet docker compose up -d db, wait for (healthy) in docker compose ps, retry
Makefile:2: .envrc: No such file or directory The Makefile’s include cannot find .envrc Create .envrc as in Step 3, in the same folder as the Makefile
error: Dirty database version 1. Fix and force version. A migration failed partway through See the Pitfalls below, and Exercise 3
psql: error: connection to server ... failed from make db/psql You are outside the project folder, so Compose can’t find the file cd to the folder holding docker-compose.yml

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: a message ending Is the docker daemon running? (the socket path in the middle differs between machines). It means: the docker command is installed but the background service it talks to is not running. Fix: start Docker Desktop and wait until its icon stops animating. Every command in this chapter needs it.

Common mistake

You’ll see: a failure from docker compose up -d db ending in bind: address already in use. It means: something else on your machine already holds port 5432 — usually a Postgres you installed and forgot, or a container from another project. Fix: stop the other one, or change the left number in ports: - "5432:5432" to something free such as "5433:5432" and update the port in .envrc and config.toml to match. Change only the left number; the right one is inside the container and must stay 5432.

Common mistake

You’ll see: error: no change It means: every migration in the folder has already been applied. This is success wearing a frightening word. Fix: nothing. The exit status is non-zero, which matters only if you script it.

Common mistake

You’ll see: migrate create writes files numbered 000002 when you expected 000001. It means: the folder already had a migration — usually because you ran the command twice. Fix: delete the unwanted, still-empty pair. Files that have never been applied anywhere are the only migrations you are ever allowed to delete.

Common mistake

You’ll see: you changed POSTGRES_PASSWORD in docker-compose.yml, restarted, and the old password still works. It means: those environment: values are read only when the data directory is created. Yours already exists, in the db-data volume. Fix: in development, docker compose down -v deletes the volume, and the next up starts fresh. Read the warning in the Pitfalls before you type that.


9. Pitfalls

  • Editing an applied migration. Once a migration has run anywhere — a colleague’s machine, CI, production — it is immutable history. Wrong? Write a new migration that fixes it. Editing causes checksum-style drift where two databases claim the same version but differ, and the disagreement is invisible until something breaks in the one place you cannot experiment.

  • The “dirty” state. If a migration fails halfway, migrate marks the database dirty and refuses to continue until you manually fix it and run migrate ... force <version>. This is a feature — it forces a human to look — but it terrifies people the first time. In development, docker compose down -v and re-up is often the honest reset.

    Warning

    docker compose down -v deletes the named volume, and with it every row in the database. In development that is a clean slate. Typed against anything you care about, it is a data loss incident with no undo. The -v is the whole difference; docker compose down alone keeps the volume.

  • Down migrations are for development. Rolling back schema in production usually loses data — a down file that drops a column drops the data in it. Real production rollbacks are roll-forwards: a new up migration that corrects the mistake. Write down files anyway; they make local iteration pleasant, and every migration in this book ships with one.

  • timestamptz vs timestamp. Always timestamptz. Plain timestamp stores wall-clock time with no zone and will eventually corrupt someone’s Tuesday — typically the Tuesday your first overseas customer signs up, or the Sunday the clocks change.


10. Check yourself — quiz

  1. In one sentence: what is a migration, and what does the number in its filename buy you?
  2. What is in the schema_migrations table, and why does its existence make migrate up safe to run repeatedly?
  3. notes is NOT NULL DEFAULT '' but due_at is nullable. Argue for both choices in one sentence each.
  4. A colleague adds CHECK (status IN ('open','done','archived','snoozed')) by editing 000001_create_tasks.up.sql and re-running make db/migrations/up on their machine. What happens, and what should they have done?
  5. You delete the healthcheck: block. What still works today, and what breaks in Chapter 25?
  6. What would break if the primary key were bigint GENERATED BY DEFAULT AS IDENTITY instead of GENERATED ALWAYS?
  7. version integer NOT NULL DEFAULT 1 is read by no code in this chapter. Why write it now?
  8. You run docker compose down and then docker compose up -d db. Are your rows still there? What if you had typed docker compose down -v?
Answers
  1. A migration is one numbered SQL file that makes a single change to the database’s shape, paired with a file that undoes it. The number gives every machine the same order, which is what makes “apply everything that hasn’t run yet” a safe, repeatable instruction rather than a judgement call.

  2. Two columns: version (the highest migration applied) and dirty (whether the last attempt failed halfway). Because the database itself records where it is, migrate can compare that against the folder and run only the difference — so a second migrate up applies nothing and says error: no change.

  3. notes is never null because “no notes” and “empty notes” are the same thing to a human, and making them the same in the database removes a null check from every consumer forever. due_at is nullable because “no deadline” is genuinely different from any particular time — there is no honest value to default it to, so the null carries real meaning.

  4. On their machine, nothing at all: schema_migrations says version 1 is applied, so migrate skips the file. Their database keeps the old constraint while the file in Git claims the new one, and anyone who sets up a database from scratch gets the new one. Two databases, one version number, different shapes. They should have written 000002_add_snoozed_status.up.sql with an ALTER TABLE that drops and recreates the constraint.

  5. Everything in this chapter still works, because you are typing commands by hand and can see when the database is ready. What breaks later is Chapter 25’s depends_on: db: condition: service_healthy — without a healthcheck, Docker only knows the container has started, so the API container races into the gap between “process launched” and “accepting connections” and dies on its first query.

  6. Nothing would break immediately, and that is the problem. BY DEFAULT lets a client supply its own id, so an insert with an explicit id succeeds while the internal counter stays where it was. Later, the counter reaches that value and the next automatic insert collides with the hand-picked one. ALWAYS refuses the explicit id outright, and the class of bug never begins.

  7. Because adding it later costs a migration plus an edit to every path that updates a task, and adding it now costs one line. It is the counter that makes Chapter 8’s optimistic locking possible: an update only succeeds if the version is still the one you read, so the second of two simultaneous editors is told to retry rather than silently overwriting the first.

  8. Yes, they are still there. down stops and removes the container; the rows live in the db-data volume, which down leaves alone. down -v also deletes that volume, so the next up creates an empty database — and schema_migrations is gone too, so make db/migrations/up will run 000001 again from scratch.


11. Practice

Exercise 1 — Read the schema like a lawyer

Before running anything, predict the outcome of each statement below against your tasks table. Then run them and reconcile every difference. Get them at make db/psql.

INSERT INTO tasks (id, title) VALUES (99, 'pick my own id');
INSERT INTO tasks (title, status) VALUES ('x', 'banana');
INSERT INTO tasks (title) VALUES ('y');
SELECT notes, priority, version, due_at FROM tasks WHERE title = 'y';
UPDATE tasks SET version = version + 1 WHERE title = 'y';
SELECT updated_at, created_at FROM tasks WHERE title = 'y';
Solution
Statement Result Why
Insert with explicit id Fails: ERROR: cannot insert a non-DEFAULT value into column "id" GENERATED ALWAYS means Postgres owns that column
status = 'banana' Fails the tasks_status_check constraint The rule lives in the table, not in the caller
INSERT ... (title) VALUES ('y') Succeeds Every other column either has a default or is nullable
The SELECT notes is empty, priority is none, version is 1, due_at is empty Three defaults and one honest NULL. In psql, an empty string and a NULL both print as blank — SELECT due_at IS NULL FROM tasks WHERE title='y'; tells them apart
The UPDATE Succeeds, and updated_at does not change There is no trigger on this table. Every updated_at in this book is set by the application, in UpdateTask’s SET ... updated_at = now()

The last row is the one worth getting wrong here rather than in production. The transferable habit: before writing any Go against a table, read its definition and write down what it will refuse. That list is your error-handling checklist.

Clean up with DELETE FROM tasks; when you’re done.

Exercise 2 — Write a migration, apply it, roll it back

Write 000002_task_indexes: an index on status, and a constraint forbidding empty titles. Write a working down file. Apply it, prove it, roll it back, prove that too.

Solution
make db/migrations/new name=task_indexes
-- migrations/000002_task_indexes.up.sql
CREATE INDEX idx_tasks_status ON tasks (status);
ALTER TABLE tasks ADD CONSTRAINT tasks_title_not_empty CHECK (title <> '');
-- migrations/000002_task_indexes.down.sql
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_title_not_empty;
DROP INDEX IF EXISTS idx_tasks_status;

Two habits are being drilled. The down file undoes the up file in reverse order, and IF EXISTS makes it safe against a database that only got halfway. Naming the constraint explicitly is what lets the down file drop it by name.

Verify:

make db/migrations/up
docker compose exec db psql -U taskd -d taskd -tAc \
  "SELECT count(*) FROM pg_indexes WHERE indexname='idx_tasks_status';"
# 1
docker compose exec db psql -U taskd -d taskd -c \
  "INSERT INTO tasks (title) VALUES ('');"
# ERROR:  new row for relation "tasks" violates check constraint "tasks_title_not_empty"

migrate -path ./migrations -database $TASKD_DB_DSN down 1
docker compose exec db psql -U taskd -d taskd -tAc \
  "SELECT count(*) FROM pg_indexes WHERE indexname='idx_tasks_status';"
# 0

(down 1 means “undo one migration”. There is no Makefile target for it on purpose — see the FAQ.)

Then delete both files and re-run make db/migrations/up, so Chapter 10’s 000002_create_users gets the number the rest of the book expects.

Exercise 3 — Break a migration on purpose and recover

Cause the dirty state deliberately, then get out of it without deleting your data.

make db/psql
INSERT INTO tasks (title) VALUES ('a row that blocks you');

Then create 000002_broken_thing with ALTER TABLE tasks ADD COLUMN colour text NOT NULL; in the up file and ALTER TABLE tasks DROP COLUMN colour; in the down file, and run make db/migrations/up twice. Explain both error messages, then recover.

Solution

The first failure comes from Postgres: adding a NOT NULL column with no default to a table that already has rows would leave those rows holding nothing in a column that forbids nothing. Its complaint names the column and the table.

The second failure comes from migrate:

error: Dirty database version 2. Fix and force version.

Migrate recorded “I am starting version 2” before running it. The statement failed, the record stayed, and migrate now refuses to touch anything until a human intervenes.

Recovery:

docker compose exec db psql -U taskd -d taskd -tAc \
  "SELECT version, dirty FROM schema_migrations;"
# 2|t

# tell migrate: version 1 is the truth, and it is clean
migrate -path ./migrations -database $TASKD_DB_DSN force 1

# now fix the migration so it can actually run
#   ALTER TABLE tasks ADD COLUMN colour text NOT NULL DEFAULT 'none';

make db/migrations/up
docker compose exec db psql -U taskd -d taskd -tAc \
  "SELECT version, dirty FROM schema_migrations;"
# 2|f

The production-grade version of the same lesson: adding a NOT NULL column to a populated table is always a three-step dance — add it nullable, backfill every row, then set NOT NULL. Chapter 12 (Ownership: making it multi-tenant) does exactly that for user_id, and takes a shortcut only because development rows are disposable.

Roll back and delete the experiment when you’re done, so the numbering stays clean for Chapter 10.


12. FAQ

Why Docker instead of installing Postgres properly? Because “properly” means a different thing on every machine. The Compose file is a written, reviewable, version-pinned description of the database, and it produces the same result on your laptop, a colleague’s, and the build robot. You also get to delete the whole thing with one command when the project ends, which an installed service does not offer.

What happens to my data when I stop the container? Nothing — docker compose down removes the container, not the volume, so the rows survive. Only docker compose down -v destroys them. Since this is development data you can recreate in seconds, that command is a legitimate reset button here and a career-defining mistake anywhere else.

Why not let the application create its own tables at startup? Three reasons. Nobody reviews a schema change that no human wrote down. Two instances starting at once will both try, and the losing one gets an error nobody planned for. And the moment a change needs data moved rather than merely a column added, “the ORM works it out” stops being true and the tool has no vocabulary for what you actually need.

Can I use a GUI like TablePlus, DBeaver or pgAdmin? Yes, and they are good for browsing. Point them at localhost:5432, user taskd, password pa55word, database taskd — the same four facts in the DSN. Use them to look; keep every change to the shape of the database in a migration file, or you have quietly gone back to path (a).

Why is there no make db/migrations/down? Because a one-word command that drops tables is a trap on the day you type it in the wrong terminal. Down migrations are a development convenience, and the friction of typing the full migrate -path ... down 1 line is deliberate. Appendix B’s complete Makefile keeps it out for the same reason.

This is a lot of ceremony for one table. Is it worth it? Today, no. The tasks table would take thirty seconds to type into psql. The ceremony pays for itself the first time somebody else needs the same database — which, counting the build robot in Chapter 26 (CI/CD: the robot that says no) and the production server in Chapter 27, is four copies before this book ends.


13. Where we are

The schema exists and is version-controlled. Go still can’t talk to it — Chapter 6 (Connecting with pgx/v5) opens the connection.

The repository as it now stands (+ marks what this chapter added):

taskd/
├── cmd/api/
│   ├── main.go
│   ├── routes.go
│   ├── healthcheck.go
│   ├── errors.go
│   ├── server.go
│   ├── middleware.go
│   └── config.go
├── internal/
├── migrations/
│   ├── 000001_create_tasks.up.sql     +  CREATE TABLE tasks
│   └── 000001_create_tasks.down.sql   +  DROP TABLE IF EXISTS tasks
├── sql/queries/
├── bin/
├── config.toml
├── docker-compose.yml                 +  the db service, volume, healthcheck
├── Makefile                           +  run/api, db/psql, db/migrations/*
├── .envrc                             +  TASKD_DB_DSN (gitignored)
├── go.mod
├── go.sum
└── .gitignore

What works end to end: docker compose up -d db gives you a healthy PostgreSQL 17; make db/migrations/up creates the tasks table on any machine that has the repository; make db/psql gets you a SQL prompt; and the database itself refuses to store an invalid status.

What is still fake: the Go program does not know the database exists. config.toml has held a db.dsn since Chapter 3 and nothing reads it yet. There are no users, no ownership, and no queries — the tasks table is real and empty, and the only way to put a row in it is by hand.

For your notes — copy these into learnings/ch05.md in your own words:

  1. A migration is one numbered SQL file plus its reversal. The number is the contract: it makes “apply what’s missing” identical on four machines. schema_migrations is the database’s memory of where it got to.
  2. An applied migration is immutable history. Wrong schema? Write another migration. Editing an old one gives two databases the same version number and different shapes, and nothing warns you.
  3. Put the rule in the database when the rule is about what may exist at all. CHECK, NOT NULL and GENERATED ALWAYS cannot be bypassed by a code path you haven’t written yet; Go validation only produces a nicer message.
  4. Read a DSN left to right: scheme, user, password, host, port, database, options. You will read them for the rest of your career.
  5. The Makefile is the project’s front door. A command worth typing twice is a command worth naming once — and its recipe lines must start with a real tab.

Chapter 6 — Connecting with pgx/v5

Chapter 5 left you with two things that have never met: a PostgreSQL database with a tasks table in it, and a Go program that has never heard of PostgreSQL. This chapter introduces them. Not with one connection opened per query — with a small set of connections opened once at startup, lent out to whoever needs one, and closed politely on the way out. That set is called a connection pool, and by the end of the chapter yours will be sized from config, proved alive with a round trip to the database, hung on the application struct, and wired into the healthcheck so that a broken database makes your server say so.

What you’ll be able to do by the end

  • Explain what a connection pool is, what it saves, and why every serious server has one.
  • Boot taskd against Postgres and see the log line that proves the pool connected.
  • Make a dead database fail your boot in five seconds with an error you can read, instead of a hang.
  • Do the arithmetic that decides how many connections your app is allowed to open.
  • Ask GET /v1/healthcheck whether the database is reachable, and watch the answer change when you stop the container.

Time: ~35 minutes reading, ~20 minutes typing.

You need before starting: Chapter 5 (PostgreSQL and migrations) finished — the Postgres container running and migration 000001_create_tasks applied — and Chapter 4’s (A server that dies well) server still booting. Prove both, in this order:

docker compose up -d db
make db/psql

At the taskd=# prompt, type \dt and press Enter. You should see a table listing that includes tasks and schema_migrations. Type \q to leave. Then:

go run ./cmd/api

You should see a line containing msg="starting server" addr=:4000 env=development. Press Ctrl-C to stop it. If either of those failed, fix Chapter 5 first — everything below assumes a live database on port 5432.


1. The problem, in plain words

Your program needs to ask Postgres questions. Postgres is a separate program, possibly on a separate machine. So “asking a question” means opening a network connection to it, and that turns out to cost much more than beginners expect.

Here is what actually happens the first time your program says hello to Postgres:

  1. A TCP handshake. Three small messages travel between the two machines before a single byte of your data moves (How the web actually works derives this). That is one network round trip, minimum.
  2. Possibly a TLS handshake. In production the connection is encrypted, which costs another round trip or two and some cryptography.
  3. Authentication. Postgres asks who you are; your program proves it with the password from the DSN. More round trips.
  4. Postgres forks a backend process. This is the expensive one, and it is peculiar to Postgres: for every connection, the server starts a whole new operating-system process dedicated to that one client, with its own memory.
New word

process — one running program as the operating system sees it, with its own memory. Starting one is not free: the operating system has to allocate and set it up.

Add those four up and you get milliseconds. Meanwhile, the query you actually wanted — “give me task 7” against an indexed column on a local database — completes in well under a millisecond. Setting up the phone call costs many times more than the sentence you called to say.

Now imagine doing all four steps on every single HTTP request, and throwing the connection away afterwards. Every user-visible response carries the setup cost. Postgres spends its day forking and killing processes instead of answering questions. Under any real traffic, the server falls over long before the database runs out of ideas.

There is a second force pushing in the same direction. Go’s net/http runs every request in its own goroutine — a piece of work running alongside all the others, started for you whether you asked or not (Go in one sitting, section 11, derives this).

Note

The original edition credits this fact to “ch. 2’s primer”. It is actually in the Go crash course, which in this edition is the warm-up chapter Go in one sitting. Chapter 2 never mentions goroutines. Corrected here so you look in the right place.

So at any instant, dozens of handlers may want the database at the same time. Whatever we build has to be concurrency-safe: usable by many goroutines simultaneously without corrupting itself.

New word

concurrency-safe (also “thread-safe”) — a thing that several goroutines can use at the same time without breaking it or each other. Most values in Go are not; the ones that are say so in their documentation.

Put those two forces together and the answer designs itself. Open a handful of connections once, at startup. Keep them. When a handler needs the database, it borrows one, runs its query, and gives it straight back. That is a connection pool, and it is one of the few pieces of infrastructure that every server-side language ends up inventing.

Why this exists

A pool exists because connections are expensive to create and cheap to keep. It turns a per-request cost into a per-process cost. That is the whole idea; the rest of this chapter is arithmetic and error handling.


2. New words in this chapter

  • driver — the library that knows how to speak a database’s wire protocol from your language. For Postgres in Go, that is pgx.
  • connection — one open network link to the database, with an authenticated session and (in Postgres) a dedicated server process behind it.
  • connection pool — a small set of database connections opened once and lent out to requests, because opening one is expensive.
  • acquire / release — borrowing a connection from the pool and giving it back. In this chapter the pool does both for you.
  • idle connection — one that is open but not currently lent out.
  • pool sizing — choosing how many connections the pool may open. The chapter’s main argument.
  • max_connections — Postgres’s own hard ceiling on simultaneous connections, counted across every app instance, tool and backup job. Default: 100.
  • latency — how long one request takes from start to answer.
  • process — one running program as the operating system sees it, with its own memory.
  • concurrency-safe — usable by many goroutines at once without breaking.
  • ping — one trivial round trip to the database, used to prove the connection really works.
  • lazy (of a connection) — not actually opened until something needs it. Creating the pool can therefore “succeed” against a database that is switched off.
  • context — a value carried down through function calls that answers “should I still be doing this?”. It carries a cancellation signal, and later (Chapter 11, Stateful tokens) values too.
  • cancellation — telling everything that shares a context to stop and return.
  • deadline / timeout — a cancellation scheduled for a particular moment, or after a duration.
  • DSN (recap from Chapter 5) — one string containing everything needed to reach a database: scheme, user, password, host, port, database name, options.
  • NAT — network address translation, the router layer between your server and the internet. It quietly forgets connections that have been idle for a while, which is why we recycle ours.
  • liveness / readiness probe — automatic checks a deployment system makes against your service: is it alive, and is it ready for traffic? Both point at a healthcheck endpoint.
  • orchestrator — software that decides which containers run on which machines (Kubernetes, Nomad). It is the thing making those probes.
  • prepared statement — a query the database parses once and then reuses, tied to one connection.
  • PgBouncer — a separate connection-pooling proxy some teams put in front of Postgres. We do not use it; the chapter’s last pitfall explains why you should still know the word.

3. The goal

A pgxpool.Pool created at startup with explicit sizing, verified with a ping, closed on shutdown, and stored on the application struct. Plus the healthcheck upgraded to actually check something.


4. The thinking

4.1 Why a pool at all

Opening a Postgres connection is expensive — a TCP handshake, TLS, authentication, and a forked server process on the Postgres side. Doing that per request would dominate your latency. A connection pool opens a handful of connections once and lends them out: a query borrows one, runs, and returns it. And recall from the crash course (Go in one sitting) that Go serves every request in its own goroutine — so at any moment dozens of handlers may want the database simultaneously, which is exactly what a concurrent-safe pool arbitrates.

4.2 pgx has two modes; servers use one of them

Mode What it is Use it for
*pgx.Conn A single connection. Not concurrency-safe: two goroutines using one at the same time corrupts the conversation. One-off scripts, migrations, a CLI tool that does one thing and exits.
*pgxpool.Pool A managed set of *pgx.Conn, handed out one at a time. Concurrency-safe by design. Servers.

Servers use the pool, no debate. A web server is a machine for doing many things at once; a single connection is a machine for doing one thing at a time.

Think of it like

pgx.Conn is one phone on one desk. pgxpool.Pool is a switchboard with twenty-five lines and a receptionist who will not let two people speak into the same handset.

4.3 The debate worth having: how big?

The debate worth having is pool sizing. pgx’s default is 4 connections, or one per CPU core if your machine has more than four — usually too few for an API under load. But cranking it to 200 is worse. Postgres forks a process per connection, and total connections across all app instances must stay under Postgres’ max_connections (default 100) with headroom for psql, migrations and backups.

Do the sum on paper, with real numbers. Suppose you run three copies of taskd behind a load balancer, each with a pool of 25:

Who wants a connection How many
3 API instances × 25 75
migrate running during a deploy 1
your psql session while debugging 1
the nightly pg_dump backup 1
a metrics exporter 2
Postgres’s own superuser reserve (superuser_reserved_connections, default 3) 3
total 83 of 100

Seventeen spare. That is a healthy budget. Now change one number — pool size 40 instead of 25 — and the app instances alone want 120. The connections past the ceiling do not queue politely; Postgres refuses them, and the error arrives during your deploy, when the old instances have not exited yet and are still holding their share.

Warning

Pool size is not a per-instance decision. It is instances × pool size + everything else < your Postgres ceiling. The day you scale from three instances to six, the sum changes and nobody re-does it. Write the arithmetic in your runbook, not in your head.

Rule of thumb: start at 25 per instance, then let the pgxpool metrics we export in Chapter 18 (Prometheus: metrics that answer questions) tell you the truth (AcquireCount climbing with waits → too small; hundreds of idle conns → too big). Capacity planning by measurement, not vibes.

In plain terms: AcquireCount counts how many times a connection has been borrowed, and the pool separately counts the borrowings that had to wait. Waits climbing while traffic is flat means the pool is too small; connections idle all day means it is too big and you are holding slots other things need.

Remember this

A pool that is too small shows up as waiting. A pool that is too big shows up as a database that falls over. The first is visible in your metrics; the second is visible in your incident channel.

4.4 MaxConnIdleTime, and the connections that die without telling you

MaxConnIdleTime matters more than people think: routers and cloud NAT quietly kill idle TCP connections; recycling idle conns after ~15 m avoids “connection reset by peer” spikes after quiet periods.

Here is the failure in slow motion. Your pool opens 25 connections during the morning rush. At 3 a.m. traffic drops to nothing and 24 of them sit idle. Somewhere between your server and the database, a router decides a connection nobody has used for a few minutes is dead and drops its record of it — without telling either end. Your pool still believes it holds 25 healthy connections. At 7 a.m. traffic returns, the pool hands out one of those corpses, and the query fails with a network error that looks like the database went down. It did not. The connection did, hours ago, silently.

Recycling idle connections on our own schedule means we always find out on our terms. pgx’s default is 30 minutes; our config says 15, which is stricter on purpose.

New word

NAT (network address translation) — the layer in routers and cloud networks that rewrites addresses so many machines can share one public address. It has to remember every connection passing through it, so it forgets the ones that look abandoned.


5. A picture of it

Two pictures. The first is the argument of section 4.1, drawn as two timelines: where the time goes with and without a pool.

  WITHOUT a pool — every request pays the setup cost
  ──────────────────────────────────────────────────
  req ─▶ TCP handshake ─▶ auth ─▶ Postgres forks a backend ─▶ QUERY ─▶ close
         └────────────── milliseconds, every time ────────┘   └ <1ms ┘

  WITH a pool — the setup was paid once, at boot
  ──────────────────────────────────────────────
  req ─▶ borrow an already-open connection ─▶ QUERY ─▶ give it back
         └────── microseconds ────────────┘   └ <1ms ┘

The second is the pool itself, mid-traffic. This is the mental model to keep: a lending desk with a fixed number of items and a queue when they run out.

   goroutines (one per request)            the pool (MaxConns = 25)
   ────────────────────────────            ────────────────────────
   req 1 ── holds conn #3  ─────┐         ┌──────────────────────────┐
   req 2 ── holds conn #7  ─────┼────────▶│  #1 #2 #3 ......... #25  │
   req 3 ── holds conn #12 ─────┘         │   3 lent out             │
                                          │  22 idle, ready          │
   req 4 ── waiting for a free one ──────▶│  ▲                       │
   req 5 ── waiting ─────────────────────▶│  │ the queue             │
                                          └──┼───────────────────────┘
                                             │
        every wait in that queue increments the pool's own counter,
        which Chapter 18 publishes as
        taskd_pgxpool_empty_acquire_count_total

Walking through it:

  1. Three requests are mid-query, each holding one connection for as long as its query runs.
  2. Twenty-two connections are idle — open, authenticated, doing nothing, ready in microseconds.
  3. Requests 4 and 5 arrived when nothing was free. They wait; they are not refused.
  4. Waiting is not an error, but a queue that is never empty is the symptom of an undersized pool. That is the loop Chapter 18 closes: the counter above is how you find out without guessing.
  5. As soon as request 1 finishes, its connection goes back on the shelf and request 4 gets it.

6. The steps

Step 1 — Install the driver

go get github.com/jackc/pgx/v5

go get downloads a module, records it in go.mod, and writes checksums into go.sum. The pool lives in the sub-package github.com/jackc/pgx/v5/pgxpool, which is part of the same module — this one command brings both.

What you should see: a few lines beginning go: downloading and then go: added github.com/jackc/pgx/v5 v5.7.2 (a newer patch version is fine), plus go: added lines for three packages you did not ask for — jackc/pgpassfile, jackc/pgservicefile and jackc/puddle/v2, pgx’s own dependencies. (puddle is the generic resource pool pgxpool is built on.) Go marks them // indirect in go.mod: “we did not ask for this; something we did ask for needs it”.

The settings the pool will read already exist. Chapter 3 (Configuration and logging) put them in config.toml; there is nothing to add here:

# config.toml — already present from Chapter 3, shown as a reminder. No change.
[db]
dsn               = "postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable"
max_conns         = 25
max_idle_time     = "15m"

Reading the DSN left to right, one more time: postgres:// is the scheme, taskd:pa55word the user and password, @localhost:5432 the host and port, /taskd the database name, and ?sslmode=disable an option that is fine for a container on your own machine and wrong in production.

Step 2 — A dedicated constructor

Opening the pool is fiddly enough — four steps, three of which can fail — that it gets its own file and its own function. main will call it and care only whether it worked.

// cmd/api/db.go — new file
package main

import (
    "context"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

func openDB(cfg config) (*pgxpool.Pool, error) {
    // Parse the DSN string into a structured config we can adjust.
    poolCfg, err := pgxpool.ParseConfig(cfg.db.dsn)
    if err != nil {
        return nil, err
    }

    // The two sizing knobs argued for above — from OUR config, so
    // production can tune them without a rebuild (TASKD_DB__MAX_CONNS).
    poolCfg.MaxConns = cfg.db.maxConns
    poolCfg.MaxConnIdleTime = cfg.db.maxIdleTime

    // A context is Go's cancellation mechanism: this one self-cancels
    // after 5s, and every operation given it stops when it does. Result:
    // a down database fails the boot in 5s with a clear error — not a hang.
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
    if err != nil {
        return nil, err
    }

    // The pool connects lazily, so creation can "succeed" against a dead
    // DB. Ping forces one real round-trip: fail HERE, loudly, or not at all.
    if err := pool.Ping(ctx); err != nil {
        pool.Close()
        return nil, err
    }
    return pool, nil
}

What this code says, line by line

  • func openDB(cfg config) (*pgxpool.Pool, error) — takes the whole config, returns two values: a pointer to a pool, and an error. Returning (value, error) is Go’s universal shape for “this might not work”; the caller must deal with the second value.
  • pgxpool.ParseConfig(cfg.db.dsn) — turns the DSN string into a struct with named fields we can edit. It does not touch the network. It fails only if the string itself is malformed.
  • poolCfg.MaxConns = cfg.db.maxConns — the ceiling from section 4.3. Note where the number comes from: our config, not a constant in the source. That is what makes TASKD_DB__MAX_CONNS=10 work on a running deployment without a rebuild (Chapter 3’s env-override rule, doing its job).
  • poolCfg.MaxConnIdleTime = cfg.db.maxIdleTime — the "15m" from config.toml, already parsed into a time.Duration by koanf.
  • context.WithTimeout(context.Background(), 5*time.Second) — this is the chapter’s one genuinely new Go idea, and it gets its own callout below. context.Background() is the empty root context, used when nothing above you has one — which is true in main and in a function main calls.
  • defer cancel()defer schedules a call for when the surrounding function returns, whatever route it takes. cancel releases the timer behind the context. Calling it is not optional; see the mistakes section for the exact complaint go vet makes when you forget.
  • pgxpool.NewWithConfig(ctx, poolCfg) — builds the pool. Read the next bullet before assuming this proves anything.
  • if err := pool.Ping(ctx); err != nil { — an if with an initialiser: run pool.Ping(ctx), put the result in err, then test it. err exists only inside this if, which keeps it from colliding with the err above.
  • pool.Close() before return nil, err — if the ping fails we hand back an error, so nobody will ever close this pool for us. Clean up before walking away.
New word

context — a value carried down through function calls that answers one question: “should I still be doing this?”. Every function in Go that talks to a network takes one as its first argument. context.WithTimeout(parent, d) makes a child context that cancels itself after d, and returns it along with a cancel function you must always call.

Think of it like

A context is the “stop everything” whistle on a building site. Whoever holds it can blow it; every worker who was handed it hears it and downs tools. WithTimeout is a whistle on a timer.

The lazy-connection trap. pgxpool.NewWithConfig returning a nil error feels like proof that the database is reachable. It is not. The pool is lazy: it validates the config, sets up its bookkeeping, and returns without opening a single connection. The first connection is opened when somebody first asks for one. So a program that stops at NewWithConfig boots happily against a database that is switched off, and reports the problem later, on some user’s request, in a handler far away from the cause.

Ping is the fix. It borrows a connection — which forces a real connect — and does one tiny round trip to prove the far end answers.

New word

ping — one trivial round trip whose only purpose is to prove the connection works. Tapping the microphone before the speech.

Here is the whole function as a picture, with the five-second ceiling drawn over the top:

  openDB(cfg)
    │
    ├─▶ pgxpool.ParseConfig(dsn)      no network — string parsing only
    │        │ malformed DSN ─────────────────────▶ return nil, err
    │        ▼ ok
    ├─▶ set MaxConns, MaxConnIdleTime from config
    │        │
    │   ┌────┴──────────────────────────────────────────────────┐
    │   │ ctx: the 5-second ceiling starts HERE and covers      │
    │   │ everything below it                                   │
    │   └────┬──────────────────────────────────────────────────┘
    │        ▼
    ├─▶ pgxpool.NewWithConfig(ctx, poolCfg)
    │        │ succeeds WITHOUT touching the database  ◀── lazy
    │        ▼
    ├─▶ pool.Ping(ctx)
    │        │ no answer within the ceiling ─▶ Close, return nil, err
    │        ▼ answered
    └─▶ return pool, nil ─▶ main logs "database connection pool
                             established"

Step 3 — Wire it into main and the application struct

Two changes to main.go: a new field on the struct, and a new construction block in main. The original edition shows them as fragments; here is the complete file so there is no guessing about where they go.

// cmd/api/main.go — complete listing after this chapter
package main

import (
    "flag"
    "fmt"
    "log/slog"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

const version = "0.1.0"

type application struct {
    config config
    logger *slog.Logger
    db     *pgxpool.Pool
}

func main() {
    configPath := flag.String("config", "config.toml", "path to config file")
    flag.Parse()

    cfg, err := loadConfig(*configPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    logger := newLogger(cfg)

    // The database is REQUIRED: no pool, no service. Refuse to boot.
    db, err := openDB(cfg)
    if err != nil {
        logger.Error("cannot connect to database", "error", err)
        os.Exit(1)
    }
    defer db.Close()
    logger.Info("database connection pool established")

    app := &application{config: cfg, logger: logger, db: db}

    // serve() blocks until shutdown. A nil error means a clean,
    // graceful exit; anything else is fatal and worth a loud log.
    err = app.serve()
    if err != nil {
        logger.Error("server error", "error", err)
        os.Exit(1)
    }
}

// newLogger picks the log format and level from config:
// human-readable text in dev, machine-parseable JSON in production.
func newLogger(cfg config) *slog.Logger {
    var lvl slog.Level
    _ = lvl.UnmarshalText([]byte(cfg.logLevel)) // bad value → info

    opts := &slog.HandlerOptions{Level: lvl}
    if cfg.env == "production" {
        return slog.New(slog.NewJSONHandler(os.Stdout, opts))
    }
    return slog.New(slog.NewTextHandler(os.Stdout, opts))
}

What changed, and why

  • db *pgxpool.Pool on the struct — the pool joins the config and the logger on the pegboard. Every handler is a method on *application, so every handler can now reach the database as app.db, with nothing threaded through arguments.
  • import "github.com/jackc/pgx/v5/pgxpool" — needed by the struct field’s type. Forget it and the build stops with undefined: pgxpool.
  • db, err := openDB(cfg) — note the position: after the logger (so failures can be logged properly) and before app is built (so the struct can be handed the finished pool).
  • os.Exit(1) on failure — the 5-second connect timeout means a down database fails the boot in 5 s with a clear message, not a hang that makes you suspect everything else. A service with no database cannot serve; pretending otherwise moves the failure to a worse place.
  • defer db.Close() — scheduled here, executed when main returns, which is after app.serve() has finished draining. Section 9’s first pitfall is entirely about why that ordering is the correct one.
  • db: db in the struct literal — the field on the left, the local variable on the right.
Note

A name collision worth knowing about now. Chapter 7 (sqlc: SQL in, type-safe Go out) creates a package called db and imports it into this file. A local variable named db would then hide the package, and db.New(...) would stop compiling. Chapter 7 renames this one local variable to pool; the struct field stays db forever. If you would rather not edit it later, you can write pool, err := openDB(cfg) / defer pool.Close() / db: pool today — the behaviour is identical, and it is the form Appendix E’s final main.go settles on.

Note

An honest caveat about defer and os.Exit. os.Exit stops the program immediately and does not run deferred functions. So on the failure path — app.serve() returning an error — db.Close() never runs. Nothing leaks: the operating system closes every socket the process held when it dies. It only means the pool skips its polite goodbye on the way to a crash, which is the right trade.

Step 4 — Run it

docker compose up -d db
go run ./cmd/api

What you should see — two INFO lines, in this order:

time=2026-... level=INFO msg="database connection pool established"
time=2026-... level=INFO msg="starting server" addr=:4000 env=development

The order is the proof: the pool is established before the server starts accepting traffic. There is no window in which taskd answers a request without a database behind it.

Now the failure path, which is worth seeing on purpose. Stop the container and start the app again:

docker compose stop db
go run ./cmd/api
time=2026-... level=ERROR msg="cannot connect to database"
  error="failed to connect to `user=taskd database=taskd`:
  \n\t[::1]:5432 (localhost): dial error: dial tcp [::1]:5432:
  connect: connection refused
  \n\t127.0.0.1:5432 (localhost): dial error: dial tcp
  127.0.0.1:5432: connect: connection refused"
exit status 1

(That is one long line, wrapped here to fit the page. The \n\t sequences are literal characters inside the quoted error value, which is why it looks odd.) Read it as: pgx tried localhost both ways it can be resolved — the IPv6 address [::1] and the IPv4 address 127.0.0.1 — and something on the far end actively refused both. That is what “nothing is listening on that port” looks like.

Note how fast that was: well under a second, not five. Connection refused is an answer. The 5-second ceiling is for the other case — a host that says nothing at all, which is what a firewall, a wrong IP address or a wedged database looks like. Exercise 1 makes you feel the difference.

Start the database again before continuing:

docker compose up -d db

Step 5 — Make the healthcheck honest

Right now GET /v1/healthcheck reports "status":"available" whatever is happening. It proves the Go process is alive and nothing else. If Postgres is on fire, the healthcheck says everything is fine, and every uptime monitor you own agrees with it.

A healthcheck should fail when the thing it stands for has failed. Ours needs to touch the database.

// cmd/api/healthcheck.go — replaces the whole file
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    dbStatus := "up"
    if err := app.db.Ping(ctx); err != nil {
        dbStatus = "down"
    }
    // (still Fprintf; ch. 8 fixes the JSON crime)
    w.Header().Set("Content-Type", "application/json")
    fmt.Fprintf(w,
        `{"status":"available","database":%q,"environment":%q,"version":%q}`,
        dbStatus, app.config.env, version)
}
Note

The three response lines at the end are printed here for the first time. The original edition stops this listing at a literal ... and leaves the body to Chapter 8, which never reprints the handler — so the finished code appeared nowhere. What you see above is Chapter 2’s payload with the database field added, keeping the Fprintf exactly as the original left it. Chapter 8 (CRUD done properly) does replace the whole body with app.writeJSON(...), and adds the 503 branch discussed below.

What this code says, line by line

  • context.WithTimeout(r.Context(), 2*time.Second) — the parent is the request’s own context, not context.Background(). That single choice is why a client who hangs up mid-request stops costing you a database connection.
  • defer cancel() — same rule as in openDB. Always.
  • dbStatus := "up" then flipped to "down" — an optimistic default corrected by evidence.
  • if err := app.db.Ping(ctx); err != nilapp.db is the pool we hung on the struct in Step 3. Ping borrows a connection and does one round trip, bounded by ctx.
  • We record the failure but do not return early. The handler still answers, with a body that tells the truth about which part is broken.
  • %q prints a string wrapped in double quotes, which is what makes this hand-built JSON valid.

Here is the context relationship, because “a child of the request’s context” is the phrase you will read a hundred more times in this book:

   r.Context()          cancelled the moment the client disconnects
        │
        └── ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
                 cancelled when EITHER two seconds pass
                 OR the parent is cancelled — whichever happens first

Two seconds is deliberate and short. A healthcheck that hangs for thirty seconds is worse than one that fails: probes time out, the orchestrator cannot tell “slow” from “dead”, and everything queues behind an answer nobody is waiting for any more.

New word

liveness / readiness probe — automatic checks a deployment system runs against your service. Liveness: is the process alive, or should it be restarted? Readiness: is it able to serve, or should traffic go elsewhere for now? Both are usually an HTTP GET against exactly this endpoint.

New word

orchestrator — software that decides which containers run on which machines (Kubernetes, Nomad). It is the thing making those probes, several times a minute, forever.

Later, orchestrators point their liveness/readiness probes here.

Note

The handler still returns 200 OK even when dbStatus is "down", because writing a different status code with Fprintf means adding a w.WriteHeader call, and Chapter 8’s writeJSON helper is where status codes get handled properly. The correct behaviour — 503 Service Unavailable when the database is down — arrives there. Exercise 3 lets you build it now, in five lines, if you would rather not wait.

Step 6 — See it work

Restart the server and ask it:

go run ./cmd/api

In a second terminal:

curl -s localhost:4000/v1/healthcheck
{"status":"available","database":"up","environment":"development","version":"0.1.0"}

That "database":"up" is a real round trip to Postgres, made while you waited. The next section proves it by taking Postgres away.


7. Checkpoint: prove it works

With the server running in one terminal, run these in another, in order:

curl -s localhost:4000/v1/healthcheck
docker compose stop db
curl -s localhost:4000/v1/healthcheck
docker compose up -d db
sleep 3
curl -s localhost:4000/v1/healthcheck
Checkpoint

The first prints "database":"up". The second prints the same JSON with "database":"down" — and the server is still running, still answering, still logging one request line per curl. The third prints "database":"up" again, with no restart of your Go program. The pool healed itself.

That last point is the one worth pausing on. Nothing in your code retried anything. The pool discarded the connections that broke when Postgres died and opened fresh ones when they were next needed. This is the behaviour you are relying on every time your database is restarted for an upgrade.

If you got something else:

You saw Cause Fix
curl: (7) Failed to connect to localhost port 4000 Your Go server is not running — most likely it exited because the database was down when it booted Look at the server terminal for a level=ERROR msg="cannot connect to database" line, then docker compose up -d db and start it again
"database":"down" when the container is up The container is running but Postgres inside it is not ready yet, or the DSN points somewhere else docker compose ps should show the db service as healthy; check [db] dsn in config.toml matches the port mapping in docker-compose.yml
The healthcheck hangs for many seconds The two-second timeout is missing, or context.Background() was used instead of r.Context() Compare Step 5 line by line
{"status":"available","environment":... with no database field The old Chapter 2 healthcheck is still in place healthcheck.go replaces the whole file, not part of it

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: level=ERROR msg="cannot connect to database" error="failed to connect to \user=taskd database=taskd`: … dial tcp 127.0.0.1:5432: connect: connection refused"and the program exits with status 1. **It means:** nothing is listening on port 5432. The database container is stopped. **Fix:**docker compose up -d db`, wait a second or two, run again.

Common mistake

You’ll see: error="context deadline exceeded" after a five-second pause. It means: the host in your DSN never answered at all — not even to refuse. A wrong IP address, a firewall, or a database that is up but wedged. Fix: check the host and port in [db] dsn. docker compose ps tells you what is actually published on 5432.

Common mistake

You’ll see: error="cannot parse `postgres//taskd@localhost/taskd`: failed to parse as keyword/value (invalid keyword/value)" It means: pgxpool.ParseConfig rejected the DSN string itself. Nearly always a missing : in postgres://, or a stray space. Fix: compare the DSN against the one in Step 1, character by character.

Common mistake

You’ll see: error="MaxSize must be >= 1" It means: MaxConns arrived as 0. Since it comes from config, a missing or misspelled max_conns key gives you the zero value of an int32, which is 0, and the pool refuses. Fix: check the [db] block in config.toml — the key is max_conns, under [db], not maxconns and not at the top level.

Common mistake

You’ll see: from go vet, a line naming db.go and ending the cancel function is not used on all paths (possible context leak) — or, if you wrote ctx, _ := instead, the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak. It means: you created a context with a timeout and did not arrange to call its cancel. The timer behind it stays alive until it fires, holding memory. Fix: defer cancel() on the line after. Every time. go vet ./... catches this shape for free, which is one reason Chapter 26 (CI/CD) runs it on every push.

Two more, from a live database rather than a missing one:

  • Wrong password in the DSN. The error comes back from Postgres, not from Go, and contains password authentication failed for user "taskd" along with the SQLSTATE code 28P01. SQLSTATE is the five-character error code every Postgres error carries; it is the part worth searching for, because it does not change with the server’s language settings.
  • A pool that is too big. Push max_conns past what the server allows across all clients and Postgres starts refusing new connections with sorry, too many clients already and SQLSTATE 53300. Redo the arithmetic in section 4.3 rather than raising max_connections.

And one that produces no error at all, which makes it the dangerous one:

Warning

Passing context.Background() inside a handler instead of r.Context() compiles, runs, and looks correct. What it silently gives up is cancellation: when the client hangs up, the query carries on to the end, holding a pool connection to compute an answer nobody will read. Under load, that is how a pool of 25 becomes a pool of 0. Every database call in this book takes r.Context() or a bounded child of it — no exceptions until Chapter 21 (Background work and email), where work deliberately outlives the request and builds a fresh context on purpose.


9. Pitfalls

  • defer db.Close() vs graceful shutdown ordering. defer in main runs after app.serve() returns — i.e., after the HTTP drain completes. That ordering (stop taking requests → finish requests → close pool) is exactly right; don’t “improve” it by closing the pool in the signal handler, which yanks the DB out from under in-flight handlers.

      RIGHT — the pool closes after the drain
      ┌────────────────────────────────────────────────────────────┐
      │ main()                                                     │
      │   openDB() ─────────────────────────▶ pool open            │
      │   defer db.Close()   (scheduled, not run)                  │
      │   app.serve() ┐                                            │
      │               │ SIGTERM → stop accepting new connections   │
      │               │ in-flight handlers finish their queries    │
      │               ┘ returns nil                                │
      │   deferred db.Close() runs HERE ────▶ pool closed          │
      └────────────────────────────────────────────────────────────┘
    
      WRONG — closing inside the signal handler
      ┌────────────────────────────────────────────────────────────┐
      │   SIGTERM ──▶ db.Close() ──▶ 12 handlers still mid-query   │
      │               every one fails; those clients get a 500     │
      │               on a deploy that was supposed to be graceful │
      └────────────────────────────────────────────────────────────┘
    
  • Contexts. Every query in this book takes r.Context() (or a bounded child). A client that disconnects cancels its own queries; a wedge in Postgres can’t wedge goroutines forever. Passing context.Background() from handlers forfeits both.

  • PgBouncer foresight. If you ever front Postgres with PgBouncer in transaction mode, pgx’s default prepared-statement cache breaks (statements are per-connection). The fix is default_query_exec_mode=simple_protocol in the DSN. You don’t need it today; you need to have heard of it before the incident.

    In plainer terms: PgBouncer is a small proxy that sits between your app and Postgres and does pooling on the database’s side, so that fifty app instances can share thirty real connections. In transaction mode it re-assigns a real connection to a different client after every transaction — which is efficient, and which means the connection you prepared a statement on may not be the one you run it on. pgx caches prepared statements (queries the database has parsed once and can reuse) per connection, so the two features collide. Nothing here needs PgBouncer; the word is in this chapter so that when a colleague says it during an incident, you know which end of the system they are talking about.


10. Check yourself — quiz

  1. What, exactly, does a connection pool save? Name the work that stops happening per request.
  2. pgxpool.NewWithConfig returned a nil error. Why is Ping still necessary?
  3. You run four instances of taskd, each with MaxConns = 25, against a Postgres with the default max_connections. What goes wrong, and when will you first notice?
  4. What does MaxConnIdleTime protect you from? Why does the problem it solves appear after a quiet period rather than during traffic?
  5. The database container is stopped and you start the app. Do you wait five seconds for the error? What situation does take the full five seconds?
  6. In the healthcheck, what would change if the timeout’s parent were context.Background() instead of r.Context()?
  7. defer db.Close() is written in main before app.serve() is called. When does it actually run, and why would closing the pool inside the signal-handling goroutine be a bug?
  8. The healthcheck sets dbStatus = "down" and still replies 200 OK. Why does the book leave it that way today, and where is it fixed?
Answers
  1. The setup work: a TCP handshake, possibly a TLS handshake, authentication, and Postgres forking a dedicated backend process for that connection. With a pool that happens a fixed number of times at startup instead of once per request. What remains per request is borrowing an already-open connection, which is a local operation costing microseconds.

  2. Because the pool is lazy: NewWithConfig validates configuration and sets up bookkeeping without opening a connection, so it succeeds against a database that is switched off. Ping forces one real round trip, which converts a silent future failure into a loud boot failure.

  3. Four instances want 100 connections between them, which is the entire default ceiling — before psql, migrations, backups, or the three connections Postgres reserves for superusers. You will most likely notice during a deploy, when new instances start while old ones still hold their share, and connections are refused with sorry, too many clients already (SQLSTATE 53300). The fix is arithmetic: instances × pool size + tooling + reserve must fit under the ceiling.

  4. It protects you from connections that have been killed by something in the middle of the network — a NAT device or firewall that drops its record of a connection nobody has used for a while — and told neither end. The pool believes it holds a healthy connection; the next query on it fails. It shows up after quiet periods precisely because the middle boxes only forget connections that have been idle. Recycling ours after 15 minutes means we replace them before anyone else does.

  5. No — you get the error almost immediately. “Connection refused” is an answer: something on the other end actively said no. The five-second ceiling exists for the case where nothing answers at all — a wrong IP address, a dropped-packet firewall, or a database that is up but not responding. Then the context’s deadline fires and you get context deadline exceeded.

  6. Two seconds would still be the maximum, so nothing visible would change on a good day. What you would lose is the link to the caller: with r.Context(), a client that disconnects cancels the ping immediately and the pool connection is returned early. With context.Background() the ping runs to completion regardless. It is a small loss here and a large one on a slow query, which is why the habit matters more than this particular handler.

  7. It runs when main returns, which is after app.serve() has returned — and serve only returns once the graceful shutdown has stopped accepting new connections and drained the in-flight ones. So the order is: stop accepting, finish current requests, then close the pool. Closing it inside the signal handler would reverse that: the pool would disappear while handlers were still using it, turning a clean deploy into a burst of 500s. (Note the one gap: if serve returns an error, os.Exit(1) skips deferred functions entirely — the operating system cleans up instead.)

  8. Because writing a non-200 status through fmt.Fprintf means adding a bare w.WriteHeader call, and status codes are one of the things Chapter 8’s writeJSON helper exists to get right. The book commits the JSON crime once, in one handler, so the helper arrives as the fix to a problem you have felt. Chapter 8 (CRUD done properly) replaces the body and adds the 503 branch; Exercise 3 below builds it early.


11. Practice

Exercise 1 — Two ways for a database to be missing

Find out what your app does when the database is unreachable, in the two distinct ways that can happen, and time both. Then change the five-second ceiling to thirty and feel the difference.

Solution

Case A — refused. The container is stopped, so the port is closed:

docker compose stop db
time go run ./cmd/api

The error names both addresses localhost resolves to and says connect: connection refused for each, then exit status 1. The elapsed time is a fraction of a second — the machine answered immediately, with a refusal.

Case B — silence. Point the DSN at an address that swallows packets without replying. Chapter 3’s environment override means you do not have to edit any file:

export TASKD_DB__DSN='postgres://taskd:pa55word@10.255.255.1:5432/taskd?sslmode=disable'
time go run ./cmd/api
unset TASKD_DB__DSN

10.255.255.1 is a private address that is almost never routed, so packets to it vanish rather than being refused. (If your network happens to answer it, any address that silently drops traffic will do.) This one takes almost exactly five seconds and ends with:

level=ERROR msg="cannot connect to database" error="context deadline exceeded"

That is your context.WithTimeout doing the only job it has. Without it, the operating system’s own TCP timeout would apply instead — which on many systems is over a minute, and looks to you like a program that has hung.

Now widen the ceiling. In cmd/api/db.go, change 5*time.Second to 30*time.Second and rerun case B. You now wait half a minute for the same error. Put the 5 back.

The lesson is not that five is a magic number. It is that you chose the number, so the failure happens on a schedule you set. Boot timeouts should be short enough that a human watching a deploy sees the answer before they start doubting themselves.

Exercise 2 — Feel the queue

Prove that pool size is a real limit, not a suggestion, by making the pool tiny and watching requests line up. This needs a temporary endpoint that holds a connection for a measurable time.

Solution

Add a temporary route inside the /v1 group. pg_sleep(2) is a Postgres function that does nothing for two seconds; running it holds one pool connection for that whole time.

// cmd/api/routes.go — add inside r.Route("/v1", ...)
// TEMPORARY: delete after this exercise.
r.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
    _, err := app.db.Exec(r.Context(), "SELECT pg_sleep(2)")
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    fmt.Fprintln(w, "slept")
})

routes.go now needs "fmt" in its import block. pool.Exec(ctx, sql) runs a statement and discards any rows; Chapter 7 replaces raw calls like this with generated, typed functions.

Run with a pool of exactly one connection, using the environment override so nothing is edited:

TASKD_DB__MAX_CONNS=1 go run ./cmd/api

In a second terminal, fire four requests at once and print how long each took:

for i in 1 2 3 4; do
  curl -s -o /dev/null -w "req $i: %{time_total}s\n" localhost:4000/v1/slow &
done
wait

With one connection, the four requests cannot overlap: each waits for the previous to give the connection back, so the reported times step up by roughly two seconds each — about 2, 4, 6 and 8 seconds, in whatever order they finish. Now stop the server and run it normally (go run ./cmd/api, pool of 25) and repeat: all four report about two seconds, because all four ran at once.

Do not raise the request count to ten while max_conns is 1. The tenth would need about twenty seconds, and WriteTimeout: 10 * time.Second from Chapter 2 would cut it off first — which is itself worth knowing: a pool queue does not only make requests slow, it makes them fail at a limit set somewhere else entirely.

Delete the /slow route and the fmt import when you are done.

Exercise 3 — Make the healthcheck fail properly

A healthcheck that returns 200 OK while reporting "database":"down" is only half honest: an uptime monitor and an orchestrator both read the status code, not the body. Make it answer 503 Service Unavailable when the ping fails, and prove it.

Solution
// cmd/api/healthcheck.go — replaces the whole file
package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    dbStatus := "up"
    if err := app.db.Ping(ctx); err != nil {
        dbStatus = "down"
    }

    // A monitor reads the status code, not the body. Say it out loud.
    status := http.StatusOK
    if dbStatus == "down" {
        status = http.StatusServiceUnavailable
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status) // must come AFTER headers, BEFORE the body
    fmt.Fprintf(w,
        `{"status":"available","database":%q,"environment":%q,"version":%q}`,
        dbStatus, app.config.env, version)
}

Verify both directions. Start the server in one terminal (go run ./cmd/api) and run these in another:

curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck
# 200
docker compose stop db
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck
# 503
docker compose up -d db

Two details worth naming. w.WriteHeader must be called after every w.Header().Set and before the first byte of body — headers travel down the wire first, so once the body starts, the status is already sent. And 503 is the correct code rather than 500: it means “I cannot serve right now”, which is what a load balancer needs to hear to send traffic elsewhere, whereas 500 means “your request broke something”.

This is exactly the shape Chapter 8 arrives at, written with writeJSON instead of Fprintf. If you do this exercise, keep it — Chapter 8’s version is a rewrite of the same behaviour.


12. FAQ

What is the difference between pgx.Conn and pgxpool.Pool, really? pgx.Conn is one connection and one conversation. Two goroutines talking into it at the same time interleave their messages and corrupt both. pgxpool.Pool owns a set of pgx.Conn values and hands one to a goroutine at a time, taking it back afterwards. Use pgx.Conn for a script that does one thing and exits; use the pool for anything that serves requests.

How many connections should I actually use? Start at 25 per instance because it is a reasonable default, not because it is correct for you. The number that is correct for you comes from two measurements you cannot take yet: how often requests wait for a connection, and how many connections sit idle. Chapter 18 exports both. Until then, keep instances × MaxConns + tooling comfortably under Postgres’s max_connections and move on.

Why not use database/sql, the standard library’s database package? database/sql is a generic interface across many databases, with its own pool built in. It works, and pgx can be driven through it. We use pgx directly because the generic layer costs us Postgres’s specific types and features, and because Chapter 7’s code generator emits pgx-native code — the pool we build here is handed straight to it. Choosing the smaller, more specific tool is the same instinct that picked plain SQL over an ORM.

What is a context, really? It feels like an argument I am carrying around for no reason. It is a cancellation signal with a family tree. Every context can have children; cancelling a parent cancels all its descendants. When a request arrives, Go creates a context for it and cancels that context the moment the client disconnects. By passing it down into every database call, you connect “the caller went away” to “stop the work”, automatically, through however many layers of function call sit in between. It looks like ceremony until the first time your database is saved from doing thousands of queries nobody is waiting for.

Does the pool recover if Postgres restarts? Yes, and you proved it in the checkpoint. Connections that break are discarded rather than reused, and new ones are opened when they are next needed. Requests that were mid-query when the database went down do fail — there is nothing to be done about that — and the first requests afterwards pay the connection setup cost again. What does not happen is your Go process needing a restart.

What is PgBouncer, and do I need it? It is a proxy that pools connections in front of Postgres, so many app instances can share a smaller number of real database connections. You need it when the arithmetic in section 4.3 stops working — typically many instances, or a serverless setup where instances appear and vanish. You do not need it now, and adding it changes how prepared statements behave (see the last pitfall). Know the word; reach for it when the sum says you must.


13. Where we are

The app holds a healthy, sized, self-checking pool. Now: queries without tears.

The repository as it now stands (+ marks files added this chapter, ~ files changed):

taskd/
├── cmd/api/
│   ├── main.go            ~  pool constructed, db field, defer Close
│   ├── db.go              +  openDB: parse, size, connect, ping
│   ├── healthcheck.go     ~  now pings the database
│   ├── server.go             graceful shutdown (ch. 4)
│   ├── routes.go             the router, middleware, /v1 group
│   ├── middleware.go         recoverPanic, logRequest (ch. 4)
│   ├── config.go             koanf loader (ch. 3)
│   └── errors.go             TEMPORARY stub, replaced in ch. 8
├── internal/data/            still empty (ch. 8)
├── internal/db/              still empty (ch. 7)
├── internal/validator/       still empty (ch. 8)
├── internal/cache/           still empty (ch. 13)
├── migrations/
│   ├── 000001_create_tasks.up.sql
│   └── 000001_create_tasks.down.sql
├── sql/queries/              still empty (ch. 7)
├── bin/                      build output, gitignored
├── config.toml               [db] block now actually used
├── docker-compose.yml        the Postgres service
├── Makefile
├── .envrc                    TASKD_DB_DSN for migrate (gitignored)
├── .gitignore
├── go.mod                 ~  pgx/v5 added
└── go.sum                 ~

What works end to end: docker compose up -d db then go run ./cmd/api boots a server that holds 25 pooled connections to Postgres, refuses to start at all if the database is unreachable, and answers GET /v1/healthcheck with a live verdict on the database. Stopping the container flips that verdict to "down" without killing the server; starting it flips it back with no restart.

What is still fake: nothing reads or writes a single row. The tasks table has existed since Chapter 5 and remains empty, because no Go code knows how to query it — that is Chapter 7 (sqlc: SQL in, type-safe Go out), which turns SQL files into typed Go functions built on this pool. The healthcheck’s JSON is still assembled with Fprintf and still returns 200 when the database is down (Chapter 8). And the pool’s own statistics are invisible until Chapter 18.

For your notes — copy these into learnings/ch06.md in your own words:

  1. A connection pool converts a per-request cost (handshake, authentication, a forked Postgres process) into a per-process cost paid once at boot. That is the entire justification.
  2. Pool size is an arithmetic problem across the whole system, not a per-instance preference: instances × MaxConns + tooling + reserve must fit under Postgres’s max_connections.
  3. Creating a pool proves nothing, because connections are lazy. Ping is what converts “probably fine” into “verified now”, and it is why a broken database fails your boot instead of a user’s request.
  4. A context is a cancellation signal you pass downward. context.Background() in main, r.Context() in a handler, always with defer cancel() when there is a timeout.
  5. Close the pool after the server has drained — which is what defer in main gives you free. Closing it in the signal handler pulls the database out from under requests that are still running.

Chapter 7 — sqlc: SQL in, type-safe Go out

You have a database with a tasks table in it (Chapter 5) and a Go program that holds an open connection to that database (Chapter 6). Between those two facts sits a gap nobody has filled: the Go code cannot yet ask the database anything. This chapter fills the gap, and the way it fills it is unusual. You will not write the Go code that runs queries. You will write plain SQL in .sql files, run one command, and a tool will write that Go code for you — correctly, every time, regenerated whenever the SQL or the table changes.

What you’ll be able to do by the end

  • Explain the difference between a tool you run at your desk and a library your server imports, and say which one sqlc is.
  • Write a SQL query in a file, run sqlc generate, and call the resulting Go function.
  • Read internal/db/tasks.sql.go — code you did not write — and explain every line of it.
  • Predict what breaks, and where, when you rename a database column.
  • Recognise the two configuration mistakes that make this chapter’s tool refuse to run, and fix both from the error message alone.

Time: ~45 minutes reading, ~25 minutes typing.

You need before starting: a working Chapter 6 (Connecting with pgx/v5) — the connection pool on the application struct. Prove it in two moves. First, the database is up and migrated:

docker compose up -d db
make db/migrations/up

If make db/migrations/up prints error: no change, that is success — it means every migration has already been applied, as Chapter 5 (PostgreSQL and migrations) warned. Then start the server and, from a second terminal, ask it whether it is alive:

go run ./cmd/api
curl -i localhost:4000/v1/healthcheck

The first line back must be HTTP/1.1 200 OK. If it is not, stop here and fix Chapter 6 — every step below assumes a live pool.


1. The problem, in plain words

A database speaks SQL. Go speaks Go. Something has to sit in the middle and translate, in both directions: your Go values go out as query parameters, and the database’s answer comes back as Go values. That translation is where a surprising number of production bugs are born, so it is worth looking closely at what it actually involves.

Here is the shape of it, written by hand, the way you would without any tool at all:

// Illustration only — do not type this into the project.
row := pool.QueryRow(ctx,
    `SELECT id, created_at, updated_at, title, notes,
            status, priority, due_at, version
     FROM tasks WHERE id = $1`, id)

var t Task
err := row.Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt, &t.Title, &t.Notes,
                &t.Status, &t.Priority, &t.DueAt, &t.Version)

Scan fills your Go variables from the columns of the row, in the order the columns appear in the SELECT. Not by name. By position. The first column goes into the first argument, the second into the second, and so on down the line.

Now consider what happens if you swap two of those arguments:

// notes and title swapped:
row.Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt, &t.Notes, &t.Title, ...)

Both title and notes are text in the database and string in Go. The types agree. The compiler — the program that turns your Go source into a runnable binary, and the thing that catches your mistakes before your users do — sees nothing wrong, because nothing is wrong at the level it inspects. The program builds. It runs. It serves every task with its title and its notes swapped, until a human notices, which might be a while.

That is failure mode number one: the correspondence between a query and the code that reads its result is invisible to the compiler.

Failure mode number two arrives later. Someone renames a column — notes becomes body — in a new migration. The migration runs. The Go code still says notes, still compiles, and now fails at runtime, in production, with column "notes" does not exist. The build was green the whole way.

Why this exists

Both failures come from the same root: the SQL lives in one language and the Go lives in another, and nothing checks that the two still agree. Every approach to database access is, underneath, a different answer to “who checks?” — you, a library at runtime, or a tool before you ship.

There are three ways out of this, and the book’s choice was made back in Chapter 1 (Introduction: what we’re building and why this shape). This chapter is where it becomes real.


2. New words in this chapter

  • code generation — a program that writes source code for you, which you then compile along with your own. The output is ordinary code you can open and read.
  • build time vs run time — build time is when your code is turned into a binary, on your machine or in CI. Run time is when that binary is running and serving users. A build-time tool is never present at run time.
  • type-safe — mistakes about what kind of value goes where are caught when you build, not when a user hits the bug.
  • generated package — a folder of Go files written by a tool, not by hand. Ours is internal/db. It is committed to Git and never edited.
  • magic comment — the comment above each query that tells sqlc the Go function’s name and how many rows it returns: -- name: CreateTask :one.
  • return mode — the :one / :many / :exec / :execrows part of a magic comment, which decides the generated function’s return type.
  • bind parameter — a numbered placeholder in SQL ($1, $2) that a value is safely substituted into by the database, never by pasting strings together.
  • SQL injection — an attack where user input is glued into a query’s text and is read as extra SQL rather than as data. Bind parameters are the defence.
  • RETURNING — a Postgres clause that asks an INSERT or UPDATE to hand back the row it just wrote, defaults and generated ids included.
  • nullable — a column that is allowed to hold NULL, meaning “no value here”, which is not the same as zero or empty string. Only due_at is nullable in our table.
  • struct tag — a short string in backticks attached to a struct field, e.g. `json:"due_at"`. It is metadata: the field’s name in Go stays DueAt, and libraries that care — here encoding/json — read the tag to learn what to call it on the wire.
  • DBTX — the generated interface describing “anything you can run SQL against”. Both the connection pool and a transaction satisfy it.
  • transaction — a group of database statements that all take effect or none do. Chapter 5’s primer showed one; the generated code is built to run inside one.
  • sentinel error — a specific, named error value you compare against rather than parse. pgx.ErrNoRows is the one that matters here.
  • YAML — a plain-text configuration format built out of indentation, key: value lines and - list items. sqlc.yaml is written in it.
  • go install — downloads a Go program, builds it, and puts the resulting binary in $(go env GOPATH)/bin so you can run it as a command. Different from go get, which adds a library to this project’s dependency list.
  • lint — automated nagging: a tool that flags code which compiles but is suspicious. sqlc vet lints your SQL.
  • sqlc diff — the check that asks “is the committed generated code exactly what sqlc generate would produce right now?” and fails if not.
  • sqlc.embed — a helper for queries that select a whole table’s columns plus extras; Chapter 9 (Listing at scale) needs it. Named here so the word is not a surprise later.

3. The goal

sqlc configured to read our migrations as the schema, compile sql/queries/*.sql into a generated internal/db package for pgx/v5, and a first end-to-end proof: insert a task from Go through generated code.


4. The thinking

The three ways to talk to a database

Chapter 1 made this decision in the abstract. Now that you have seen a Scan call, it is concrete.

Hand-written scanning. The code above. Total control, zero surprises about what SQL runs. And this failure mode, which is not hypothetical — it is the single most common data-layer bug there is:

row.Scan(&t.ID, &t.CreatedAt, &t.Title, &t.Notes, ...) // reorder one → runtime bug

An ORM — an “object-relational mapper”, a library that writes your SQL for you from Go code (db.Where("status = ?", "open").Find(&tasks)). You never write a query, so you never mis-scan one. The price is the mirror image of the first problem: you review Go, but the database receives SQL you have never seen. When a query is slow at three in the morning, the thing you have to fix is a thing you did not write and cannot directly read.

Code generation. You write the SQL. A tool writes the scanning. The tool reads your real schema, so mismatches between the two become compile errors rather than 3 a.m. pages.

You write the SQL? You write the scan? Mismatch found…
Hand-written yes yes at run time, by a user
ORM no no at run time, in SQL you never saw
Query builder half no at run time
sqlc yes no when you build

sqlc removes both failure modes: you write the SQL, it writes the scanning, and mismatches are compile errors. That last clause is what “type-safe” means in this chapter’s title — not that Go has types, which it always did, but that the correspondence between two languages is now something the compiler checks. When you rename a column in a migration, sqlc generate breaks the build at every affected call site — the schema change refactors your Go for you. Once you’ve felt that, going back is hard.

Remember this

The value of sqlc is not that it saves typing. It is that it moves an entire class of bug from “found by a user” to “found by the compiler”.

Four decisions to make once

1. The schema source is migrations/. sqlc needs to know the shape of your tables before it can check a query against them. It could read a separate schema.sql file — but then you would have two descriptions of the same tables, and the day they disagree is the day you lose an afternoon. sqlc parses golang-migrate files natively, applying .up.sql in order and ignoring .down.sql. No duplicated schema.sql to drift. This is the “sneaky reason” Chapter 5 (PostgreSQL and migrations) promised when it chose golang-migrate over goose: one truth, two consumers — the database and the code generator both read the same files.

2. Generated code goes to internal/db, is never edited, and is always committed. Committing generated code is mildly controversial; we do it so go build works without sqlc installed, and CI (Chapter 26, CI/CD) runs sqlc diff to guarantee it never goes stale.

New word

generated code in Git — most of what is in a .gitignore is build output: things rebuilt from source on demand. Generated Go is different, because it is input to the Go compiler. If it is not in the repository, nobody can build the project without first installing sqlc. Committing it costs some diff noise and buys a one-command build for everyone, forever.

3. Timestamp mapping. By default, sqlc’s pgx/v5 driver maps every timestamptz column — nullable or not — to a pgtype.Timestamptz, a two-field struct of {Time, Valid}. That is a correct and honest representation of a database value that might be absent, and it is noise in the ninety percent of cases where it cannot be absent. We override the mapping in both directions: a NOT NULL timestamptz becomes a plain time.Time, and a nullable one becomes *time.Time, where nil means NULL and JSON-serializes as null for free.

Step 2 shows exactly what each override does and what breaks without it. This is the single most important detail in the chapter.

4. Where do generated calls get used — wrapped in repositories, or directly? This is the one place the book’s two influences disagree, so it is worth stating plainly what each would do. Alex Edwards (whose book Let’s Go Further gives this project its folder layout) would wrap each generated call in a hand-written method: TaskModel.Insert(...), which then calls the generated function. Kailash Nadh (author of listmonk and koanf, whose instincts give this project its distrust of ceremony) would call the generated function directly from the handler.

Wrapping every generated function in a hand-written twin is ceremony that mostly forwards arguments. Our rule: handlers call app.q (the sqlc Queries) directly; internal/data exists only for logic that isn’t a query — password hashing, token generation, filter math, plan tables. Abstraction where behavior lives, none where it doesn’t.

The daily loop

Know the loop before you configure it. There are three steps and you will run them hundreds of times:

  1. Write plain SQL in sql/queries/*.sql, with a magic comment naming each query.
  2. Run sqlc generate.
  3. Call the resulting typed Go function — app.q.CreateTask(ctx, params) — from a handler.

sqlc is a compiler you run at your desk, not a library your server imports. At run time there is only pgx and the boring generated code.

Important

That sentence is the whole mental model, so test it: nothing you build in this chapter adds a line to your program’s dependency list. Appendix D lists sqlc under build-time tools (never imported at runtime), alongside migrate. Your production server never has sqlc on it.


5. A picture of it

Two pictures. The first is the loop, with the migrations directory feeding in as the schema source.

                       ┌──────────────────────┐
                       │ migrations/*.up.sql  │  the shape of the tables
                       └──────────┬───────────┘
                                  │  read as text, never run
                                  ▼
   ┌──────────────────┐      ┌─────────┐      ┌────────────────────────┐
   │ sql/queries/     │─────▶│  sqlc   │─────▶│ internal/db/           │
   │   tasks.sql      │      │ generate│      │   models.go            │
   │  (you write it)  │      └─────────┘      │   tasks.sql.go         │
   └──────────────────┘   runs at YOUR desk,  │   db.go                │
                          never in production └───────────┬────────────┘
                                                          │ compiled in
                                                          ▼
                                              ┌────────────────────────┐
                                              │ app.q.CreateTask(ctx,p)│
                                              │  called by a handler   │
                                              └────────────────────────┘

Walking it: (1) you edit a .sql file; (2) sqlc generate reads both that file and the migrations, checks every column and every parameter against the real table shapes, and writes Go; (3) the generated Go is compiled into your binary along with everything you wrote by hand; (4) at run time, that code uses the pgx pool from Chapter 6. sqlc itself is nowhere in step 4.

The second picture is the one to keep in your head while writing queries: how a single comment line decides the shape of the Go you get.

  -- name: CreateTask :one
         │           │
         │           └────────▶ return mode: exactly one row
         │                      → func returns (Task, error)
         └────────────────────▶ Go function name
                                → func (q *Queries) CreateTask(...)

  INSERT INTO tasks (title, notes, priority, due_at)
  VALUES ($1,   $2,    $3,       $4)
          │     │      │         │
          ▼     ▼      ▼         ▼
  type CreateTaskParams struct {        ← one field per $n, in order
      Title    string
      Notes    string
      Priority string
      DueAt    *time.Time
  }

  RETURNING *   ─────────────▶ the returned Task struct is filled from
                               the row Postgres just wrote

6. The steps

Step 1 — Install the compiler

sqlc is a program, not a library. You install it once on your machine, the same way you installed migrate in Chapter 5.

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

What this command says. go install downloads that Go program’s source, builds it, and drops the finished binary into $(go env GOPATH)/bin — on most machines ~/go/bin. The @latest picks the newest released version. Note it is go install, not go get: go get adds a library to this project’s go.mod, which is not what we want. sqlc must never appear in go.mod.

Check it landed:

sqlc version

What you should see: a version line beginning with v1. — this book was written against v1.31.1, and anything from v1.27 up behaves the same for our purposes.

Common mistake

You’ll see: zsh: command not found: sqlc (or bash: sqlc: command not found) It means: the binary was built, but the folder it went into is not on your PATH — the list of directories your shell searches for commands (Before you begin, §on PATH). Fix: run go env GOPATH to see the folder, then add its bin subfolder to your PATH by putting export PATH=$PATH:$(go env GOPATH)/bin in your ~/.zshrc or ~/.bashrc, and opening a new terminal. migrate from Chapter 5 lives in the same folder, so fixing this fixes both.

Step 2 — Configuration

sqlc reads one file, sqlc.yaml, from the root of the project. Create it next to go.mod.

# sqlc.yaml — new file, at the project root
version: "2"
sql:
  - engine: "postgresql"
    schema: "migrations"        # read table shapes FROM our migrations (one truth)
    queries: "sql/queries"      # compile every .sql file in here
    gen:
      go:
        package: "db"           # generated package name → import ".../internal/db"
        out: "internal/db"      # where generated files land (committed, never edited)
        sql_package: "pgx/v5"   # emit pgx-native code, not database/sql
        emit_json_tags: true    # structs get `json:"created_at"` tags for free
        emit_pointers_for_null_types: true   # nullable column → pointer, nil = NULL
        overrides:
          # pgx maps EVERY timestamptz to pgtype.Timestamptz, not just nullable
          # ones. We want plain time.Time so handlers can assign time values
          # directly. Without this line, later chapters do not compile.
          - db_type: "timestamptz"
            go_type: "time.Time"
          # Nullable timestamptz would otherwise be pgtype.Timestamptz{Time, Valid}.
          # This maps it to *time.Time: nil means NULL, and it JSON-encodes as null.
          - db_type: "timestamptz"
            nullable: true
            go_type:
              type: "time.Time"
              pointer: true

What this file says, line by line.

  • version: "2" — the config format version, not sqlc’s version. Always "2".
  • sql: — a list (each - starts an entry) of “here is one database and what to do with it”. We have exactly one.
  • engine: "postgresql" — parse the SQL as Postgres dialect, so RETURNING * and timestamptz are understood.
  • schema: "migrations" — decision 1 from The thinking, in one line. Point it at a folder and sqlc reads the .up.sql files in numeric order to learn the tables. It does not connect to a database and does not run them.
  • queries: "sql/queries" — the folder of queries to compile. Every .sql file in it.
  • gen: go: — generate Go (sqlc can also emit Kotlin and Python; we do not).
  • package: "db" and out: "internal/db" — the generated files go in internal/db and start with package db, so you import them as github.com/yourname/taskd/internal/db.
  • sql_package: "pgx/v5" — emit code that calls pgx directly, matching the pool from Chapter 6. The alternative, database/sql, would add a layer we deliberately do not want.
  • emit_json_tags: true — every generated struct field gets a struct tag naming its JSON key. CreatedAt gets `json:"created_at"`, so when Chapter 8 (CRUD done properly) writes a db.Task straight out as a response, the JSON already uses database-style names.
  • emit_pointers_for_null_types: true — for nullable columns, prefer a Go pointer over a pgtype wrapper where sqlc can. A nullable text column becomes *string instead of pgtype.Text, and nil means NULL. (You will meet the first one of those in Chapter 15’s stripe_customer_id.)
  • overrides: — a list of “when you see this database type, use this Go type instead”.

Why there are two overrides and not one

This is the part to read twice.

An override replaces sqlc’s default choice of Go type for a database type. The second override — the one guarded by nullable: true — handles due_at, our one column that may be NULL. It maps it to *time.Time: a pointer, where nil means the database holds NULL.

The first override has no nullable: line, so it applies to the ordinary, NOT NULL case: created_at and updated_at. Without it, sqlc’s pgx/v5 driver maps those to pgtype.Timestamptz as well — a struct with a .Time field and a .Valid field — even though they can never be invalid, because they are NOT NULL.

You can watch the difference. With no overrides at all, the generated Task struct is:

// internal/db/models.go — what you get with NO overrides. Not what we want.
type Task struct {
	ID        int64              `json:"id"`
	CreatedAt pgtype.Timestamptz `json:"created_at"`
	UpdatedAt pgtype.Timestamptz `json:"updated_at"`
	Title     string             `json:"title"`
	Notes     string             `json:"notes"`
	Status    string             `json:"status"`
	Priority  string             `json:"priority"`
	DueAt     pgtype.Timestamptz `json:"due_at"`
	Version   int32              `json:"version"`
}

With only the nullable override — which is what the original edition of this book printed — the nullable column is fixed and the two NOT NULL ones are not:

// internal/db/models.go — with ONLY `nullable: true`. Still broken.
type Task struct {
	ID        int64              `json:"id"`
	CreatedAt pgtype.Timestamptz `json:"created_at"`  // ← two-field struct
	UpdatedAt pgtype.Timestamptz `json:"updated_at"`  // ← two-field struct
	Title     string             `json:"title"`
	Notes     string             `json:"notes"`
	Status    string             `json:"status"`
	Priority  string             `json:"priority"`
	DueAt     *time.Time         `json:"due_at"`
	Version   int32              `json:"version"`
}

With both overrides, which is the file you typed above:

// internal/db/models.go — with BOTH overrides. This is the one we want.
type Task struct {
	ID        int64      `json:"id"`
	CreatedAt time.Time  `json:"created_at"`
	UpdatedAt time.Time  `json:"updated_at"`
	Title     string     `json:"title"`
	Notes     string     `json:"notes"`
	Status    string     `json:"status"`
	Priority  string     `json:"priority"`
	DueAt     *time.Time `json:"due_at"`
	Version   int32      `json:"version"`
}

Nothing in this chapter breaks with the middle version. That is exactly what makes it dangerous. The break arrives four chapters later, in Chapter 11 (Stateful tokens), where the code stores a token’s expiry — a plain time.Time — into a generated params struct:

// cmd/api/tokens.go — Chapter 11 writes this. Where the missing
// override detonates.
err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
    Hash: token.Hash, UserID: token.UserID,
    Expiry: token.Expiry, Scope: token.Scope,
})
Common mistake

You’ll see (in Chapter 11, if you skipped the first override): cannot use token.Expiry (variable of struct type time.Time) as pgtype.Timestamptz value in struct literal — with your own file, line and column in front of it. It means: the generated InsertTokenParams.Expiry field is a pgtype.Timestamptz, because tokens.expiry is a NOT NULL timestamptz and no override told sqlc to use time.Time. Fix: add the unguarded - db_type: "timestamptz" / go_type: "time.Time" override to sqlc.yaml and run sqlc generate again. The error points at tokens.go and the cause is in sqlc.yaml, which is why it is worth fixing now, four chapters early.

Chapter 16 (Stripe II) hits the same wall from another direction with CurrentPeriodEnd: time.Unix(sub.CurrentPeriodEnd, 0), which is also a plain time.Time.

Note

The original edition’s sqlc.yaml has only the nullable: true override, and its prose describes the problem as affecting “nullable columns”. That is the one place in the book where the printed configuration does not support the book’s own later code. This edition prints the corrected file — the same one that ships in the finished repository — and this is the only change of substance in the chapter.

Step 3 — First queries: plain SQL with magic comments

Create the folder and the file:

mkdir -p sql/queries
-- sql/queries/tasks.sql — new file

-- The comment line IS the interface: "name:" becomes the Go
-- function's name; ":one" declares it returns exactly one row.
-- $1..$4 become the fields of a generated CreateTaskParams struct, in order.
-- name: CreateTask :one
INSERT INTO tasks (title, notes, priority, due_at)
VALUES ($1, $2, $3, $4)
RETURNING *;      -- hand the inserted row straight back: id, timestamps and all

-- name: GetTask :one
SELECT * FROM tasks
WHERE id = $1;

What this file says.

  • The line -- name: CreateTask :one is the only line sqlc treats as instructions. CreateTask becomes the Go function’s name. :one is the return mode.
  • $1 to $4 are bind parameters. You saw them in SQL and databases in one sitting: you send the query text and the values as two separate things, and the database never confuses one for the other. That is what stops SQL injection — a user whose task title is '); DROP TABLE tasks; -- gets a task with a strange title, not a deleted table, because the text is never spliced into the statement.
  • RETURNING * asks Postgres to hand back the row it just wrote. That matters here because the database fills in four of the nine columns itself: id (generated), created_at, updated_at (defaulted to now()) and version (defaulted to 1). Without RETURNING, you would have to run a second SELECT to find out what you just created.
  • SELECT * means “all columns”. Safe here for a reason explained under Pitfalls.

Here is the bind-parameter idea drawn, because it is the difference between a working API and a famous incident:

  WRONG — string pasting                RIGHT — bind parameter
  ──────────────────────                ──────────────────────
  "... WHERE title = '" + in + "'"      "... WHERE title = $1", in

  in = "x'; DROP TABLE tasks; --"       in = "x'; DROP TABLE tasks; --"
        │                                     │
        ▼                                     ▼
  the DB receives TWO statements         the DB receives ONE statement
  and runs both                          and one 30-character value

The comment rule that will bite you exactly once

sqlc reads your comments. Any line inside a .sql file that begins with -- name is treated as a metadata line — sqlc’s word for “an instruction to me, not a note to a human”. If such a line is not a well-formed query name, sqlc refuses to run at all.

That means a prose comment must never wrap onto a line that starts with name. Look again at the comment above CreateTask: it breaks after “the Go” and after “one row”, not after “the Go function’s”. That is deliberate.

Common mistake

You’ll see: sql/queries/tasks.sql:1:1: invalid metadata: -- name; ":one" declares it returns exactly one row. $1..$4 become the It means: one of your comment lines starts with -- name but is not a query name. sqlc points at line 1 of the file, not at the offending line, so the error location is no help — the message text is, because it quotes the line back to you. Fix: rewrap the prose so no line begins with name, then run sqlc generate again.

Remember this

Inside a .sql file, never start a comment line with -- name unless it is the query name. Rewrap the sentence instead.

This is worth stating as a rule rather than a curiosity because the failure is total: sqlc produces no output at all, so a single stray comment stops the whole project generating. It is also the one sqlc error whose reported location is misleading, which costs people ten minutes the first time.

Note

The original edition’s comment wraps onto a line beginning -- name;, so copying it verbatim makes sqlc generate fail before it produces anything. The prose here is word-for-word the original’s; only the line breaks moved.

The four return modes

The return mode is the second half of the magic comment, and it decides the generated function’s signature.

Mode The generated function returns Use it for
:one (Task, error) — a single row, or pgx.ErrNoRows if there were none fetch-by-id, insert with RETURNING
:many ([]Task, error) — a slice, empty if nothing matched listing, searching
:exec error only a write whose result you do not inspect
:execrows (int64, error) — how many rows were affected delete, where 0 means “did not exist”
New word

sentinel error — a specific error value the library exports, which you compare against with errors.Is(err, pgx.ErrNoRows) rather than reading its text. :one returning pgx.ErrNoRows is the fact behind every 404 Not Found in this book. Chapter 8 (CRUD done properly) is where it gets turned into an HTTP status.

:execrows is the one Chapter 15 (Stripe I) writes and Chapter 16 (Stripe II) cashes in: when Stripe sends the same webhook twice, “how many rows did that insert affect — one or zero?” is how the server tells a new event from a repeat.

Step 4 — Generate, and read what you got

sqlc generate

What you should see: nothing at all. sqlc prints no output on success and exits with status 0. Silence is the success message. Any output is an error, and every error names the file and the line.

Now look at what appeared:

ls internal/db

Three files: db.go, models.go, tasks.sql.go. Open all three. Reading generated code once, properly, is the single fastest way to stop finding it magical.

internal/db/models.go — one Go struct per table, mirroring the columns in order. This is the Task struct printed in Step 2 with both overrides. Note the struct tags: `json:"created_at"` is a note attached to the CreatedAt field saying “when you turn me into JSON, call me created_at”. The Go field name does not change; only the wire format does.

internal/db/db.go — the plumbing, identical for every project:

// internal/db/db.go — GENERATED. Do not edit.
type DBTX interface {
	Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
	Query(context.Context, string, ...interface{}) (pgx.Rows, error)
	QueryRow(context.Context, string, ...interface{}) pgx.Row
}

func New(db DBTX) *Queries {
	return &Queries{db: db}
}

type Queries struct {
	db DBTX
}

func (q *Queries) WithTx(tx pgx.Tx) *Queries {
	return &Queries{
		db: tx,
	}
}

What this says. DBTX is an interface — a list of method signatures, with no implementation. Any type that has all three methods satisfies it, without ever mentioning DBTX in its own source (this is the implicit-satisfaction idea from Go in one sitting, §Interfaces). Two types in pgx happen to have exactly these three methods: *pgxpool.Pool, the connection pool from Chapter 6, and pgx.Tx, a transaction — a group of statements that all take effect or none do.

So the same generated query code runs against the pool for ordinary work, and against a transaction when several writes must succeed or fail together, with no second version of anything. WithTx is the door: hand it a transaction and you get a *Queries that runs everything inside that transaction. Nothing in this book uses that door yet; the interface makes it free when you need it.

internal/db/tasks.sql.go — the part you came for:

// internal/db/tasks.sql.go — GENERATED. Do not edit.
const createTask = `-- name: CreateTask :one

INSERT INTO tasks (title, notes, priority, due_at)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, updated_at, title, notes, status, priority, due_at, version
`

type CreateTaskParams struct {
	Title    string     `json:"title"`
	Notes    string     `json:"notes"`
	Priority string     `json:"priority"`
	DueAt    *time.Time `json:"due_at"`
}

func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
	row := q.db.QueryRow(ctx, createTask,
		arg.Title,
		arg.Notes,
		arg.Priority,
		arg.DueAt,
	)
	var i Task
	err := row.Scan(
		&i.ID,
		&i.CreatedAt,
		&i.UpdatedAt,
		&i.Title,
		&i.Notes,
		&i.Status,
		&i.Priority,
		&i.DueAt,
		&i.Version,
	)
	return i, err
}

What this code says, line by line.

  1. const createTask = ... — your SQL, stored as a Go string. Notice RETURNING * has become an explicit column list. sqlc expanded the * at generation time by looking at the migration, which is why SELECT * is not the hazard here that it is in hand-written code.
  2. type CreateTaskParams struct — one field per bind parameter, in $1-to-$4 order, with the Go type each column really is. DueAt is *time.Time because due_at is nullable.
  3. func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) — a method on *Queries (the receiver syntax from Go in one sitting, §Methods). It takes a context — the per-request envelope carrying cancellation from Chapter 6 — and a params struct, and returns a filled Task.
  4. q.db.QueryRow(ctx, createTask, arg.Title, ...) — the call into pgx. q.db is the DBTX, so this line is what runs against either the pool or a transaction.
  5. row.Scan(&i.ID, &i.CreatedAt, ...) — the exact hand-written scan from §1, in exactly the right order, written by a program that read the column list. This is the boilerplate the whole decision was about.

Scroll down and you will find GetTask, which looks slightly different:

// internal/db/tasks.sql.go — GENERATED. Do not edit.
func (q *Queries) GetTask(ctx context.Context, id int64) (Task, error) {
	row := q.db.QueryRow(ctx, getTask, id)
	var i Task
	err := row.Scan(...)
	return i, err
}

No GetTaskParams struct — a bare id int64 argument instead. sqlc generates a params struct only when a query has more than one parameter, because that is when the ordering mistake becomes possible. One argument cannot be swapped with itself.

Tip

Read internal/db/tasks.sql.go once, all the way through, and the mystery evaporates for good. It’s short, boring, and exactly what you’d write by hand on your most careful day. Knowing that removes the “magic” unease.

Step 5 — Wire it into the application

The generated Queries value needs somewhere to live. It goes on the application struct — the one box from Chapter 2 that holds everything shared, so that every handler can reach it through its receiver.

// cmd/api/main.go — add this line to the existing import block
"github.com/yourname/taskd/internal/db"
// cmd/api/main.go — add the q field to the existing struct
type application struct {
    config config
    logger *slog.Logger
    db     *pgxpool.Pool
    q      *db.Queries
}
// cmd/api/main.go — inside main(), after the pool exists
app.q = db.New(pool)

db.New is the constructor from db.go: hand it anything satisfying DBTX, get a *Queries back. The pool satisfies DBTX, so this is the only wiring there is.

Note

One rename is required here, and it is the original’s own trap. Chapter 6 named the pool variable db (db, err := openDB(cfg)). This chapter imports a package also called db. Inside main, the local variable wins, so db.New(pool) asks the compiler to find a New method on a connection pool. It fails with: db.New undefined (type *pgxpool.Pool has no field or method New) — with your own line and column in front of it. Rename the local variable to pool — which is what Appendix E’s final main.go uses — and both names coexist. The struct field stays db; only the local variable moves.

Because a beginner cannot safely apply three separate patches to a file they cannot see, here is the whole of main() as it stands after this change.

// cmd/api/main.go — replaces the whole main() function
func main() {
    configPath := flag.String("config", "config.toml", "path to config file")
    flag.Parse()

    cfg, err := loadConfig(*configPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    logger := newLogger(cfg)

    // ch. 6 — RENAMED from `db` to `pool`, so the `db` package name is free.
    pool, err := openDB(cfg)
    if err != nil {
        logger.Error("cannot connect to database", "error", err)
        os.Exit(1)
    }
    defer pool.Close()
    logger.Info("database connection pool established")

    app := &application{config: cfg, logger: logger, db: pool}

    // ch. 7 — the sqlc query set, built once, shared by every handler.
    app.q = db.New(pool)

    err = app.serve()
    if err != nil {
        logger.Error("server error", "error", err)
        os.Exit(1)
    }
}
Note

Appendix E’s final main.go folds the assignment into the struct literal (q: db.New(pool), alongside db: pool,). Identical result; the separate line is kept here because it is what the original prints and it makes the new part obvious.

Step 6 — Prove it works with a temporary insert

Nothing calls a query yet. Before building handlers, run one query directly from main so you can see a row appear in Postgres.

// cmd/api/main.go — TEMPORARY. Put it after `app.q = db.New(pool)`,
// before `app.serve()`. Delete it once it prints.
t, err := app.q.CreateTask(context.Background(), db.CreateTaskParams{
    Title: "first task via sqlc", Notes: "", Priority: "none", DueAt: nil,
})
app.logger.Info("created", "id", t.ID, "title", t.Title)

This needs "context" in main.go’s import block.

What this code says. context.Background() is the empty, never-cancelled context, used here because there is no request to attach to — this runs at start-up. Every field is named, so nothing can be positionally mis-ordered. DueAt: nil is legal precisely because due_at is nullable and the override made it a pointer: nil means “store NULL”.

Note

The original prints this snippet without checking err, and so do we. Be aware of what that means: if the insert fails, t is a zero-valued Task and the log line reads id=0 title="" instead of anything alarming. If you would rather be told, put if err != nil { logger.Error("smoke test", "error", err); os.Exit(1) } between the two statements. Either way, this block comes out again in a minute.

Step 7 — Add the Makefile targets

sqlc generate is a command you will run hundreds of times, so it gets a Makefile target like every other command in this project.

# Makefile — add these two targets
.PHONY: sqlc
sqlc:
	sqlc generate

.PHONY: sqlc/vet
sqlc/vet:
	sqlc vet
Warning

The indented lines under a target must start with a real tab character, not spaces. Make will fail with Makefile:NN: *** missing separator. Stop. if your editor converted the tab. This is the most common Makefile error there is.

sqlc vet runs lint rules against your queries — automated nagging about SQL that parses but looks wrong. With no rules configured it prints nothing and exits 0, which is the baseline you want before adding rules.

Note

The original says sqlc vet “joins CI in ch. 26 so bad SQL can’t merge”. What Chapter 26 (CI/CD) actually wires into make audit and the pipeline is sqlc diff, which is the more important of the two: it fails the build if the committed generated code is not what sqlc generate would produce right now. Keep the sqlc/vet target — it is in the finished repository’s Makefile — and expect sqlc diff to be the one CI runs.


7. Checkpoint: prove it works

Four commands, in order.

sqlc generate

Expected: no output, exit status 0.

grep -c pgtype internal/db/models.go

Expected: 0. This is the whole of Step 2’s argument reduced to one number. If it prints anything else, one of your two timestamptz overrides is missing or misindented.

go run ./cmd/api

Expected: the usual start-up lines, plus one new line from the temporary insert. The exact timestamp is yours; the shape is:

time=... level=INFO msg="database connection pool established"
time=... level=INFO msg=created id=1 title="first task via sqlc"
time=... level=INFO msg="starting server" addr=:4000 env=development

The id is whatever Postgres assigned — 1 on a fresh database, higher if you have inserted before. Stop the server with Ctrl-C.

make db/psql

Then, at the taskd=# prompt:

SELECT id, title, status, priority, due_at, version FROM tasks;

Expected: at least one row, with title of first task via sqlc, status of open, priority of none, due_at empty (that blank is NULL), and version of 1. You sent Go a title, notes, a priority and a nil; the id, the status, the version and both timestamps were filled in by Postgres and handed straight back by RETURNING *. Type \q to leave psql.

Checkpoint

A row you never typed values for, created by a Go function you never wrote, from SQL you did write. That is the whole chapter. Now delete the temporary insert block from main.go, or your server will create a task every time it starts, forever.

If you got something else:

What you saw Cause Fix
invalid metadata: -- name; ... a prose comment line begins with -- name rewrap the comment (Step 3)
grep -c pgtype prints anything but 0 only the nullable: true override is present, or neither is add the unguarded override (Step 2), regenerate
db.New undefined (type *pgxpool.Pool has no field or method New) the Chapter 6 local variable is still called db rename it to pool (Step 5)
failed to connect ... connection refused the database container is not running docker compose up -d db, wait for healthy, retry

8. Common mistakes (and the quick fix)

Everything sqlc rejects, it names. The file paths below are from a throwaway extra.sql; yours will say tasks.sql.

What you’ll see What it means in English The fix
extra.sql:1:1: relation "things" does not exist Your query mentions a table sqlc cannot find in migrations/. sqlc reads files, not your database — creating the table by hand in psql does not help. Write the migration, or fix the table name.
extra.sql:2:8: column "body" does not exist The same thing one level down: the table is there, that column is not. Check the name against migrations/000001_create_tasks.up.sql, or add a migration.
extra.sql:1:1: invalid query type: :single You invented a return mode. There are exactly four. Pick one from the table in Step 3.
error parsing configuration files. sqlc.(yaml|yml) or sqlc.json: file does not exist You ran sqlc generate from the wrong directory. cd to the project root — the folder with go.mod in it.
make sqlc fails with missing separator. Stop. Your Makefile recipe is indented with spaces where make requires a tab. Re-indent those lines with a real tab character.
You edited internal/db/tasks.sql.go and your change vanished That folder is output, not source. Every sqlc generate overwrites it. Put the change in the .sql file, or in your own package. sqlc diff reports the drift before you lose it.
A second task appears in the database every time the server starts The Step 6 temporary block is still in main.go. Delete it.

Two of them deserve more than a table row, because both are silent.

Common mistake

You’ll see: q.CountTasks undefined (type *db.Queries has no field or method CountTasks) It means: either you forgot to run sqlc generate after adding the query, or the query has no magic comment. A query with no -- name: line is silently skipped — sqlc does not warn you, it generates nothing for it, and the missing function surfaces as a Go compile error later, in a different file, with no mention of SQL. Fix: check the query has a -- name: X :mode line directly above it, then make sqlc.

Common mistake

You’ll see: nothing. The build is green and the endpoint returns a 500 with column "x" does not exist in the server log. It means: a migration ran and sqlc generate did not. Your Go is compiled against yesterday’s schema, so the compiler had nothing to object to. Fix: make db/migrations/up sqlc, then rebuild. Make the two-command form your reflex; the next section explains why it is the most valuable habit in the chapter.


9. Pitfalls

The regenerate reflex. Change a migration or a query and forget sqlc generate → code compiles against yesterday’s schema. Muscle memory fix: never run migrations without regenerating (make db/migrations/up sqlc). Safety net: CI sqlc diff in Chapter 26 (CI/CD), which fails the build if the committed generated code is not what the current SQL would produce. You can run it yourself at any time: sqlc diff prints a unified diff and exits 1 if there is drift, prints nothing and exits 0 if there is not.

SELECT * in queries. Fine here — sqlc expands it at generation time, so it’s really an explicit column list under the hood. You saw that expansion in Step 4: RETURNING * in your SQL became a nine-column list in the generated Go. New columns flow into the struct on the next generate. (The usual argument against SELECT * is about hand-written code, where a new column silently breaks a positional scan. That cannot happen when the scan is regenerated from the schema.)

The one case SELECT * cannot handle is “all the columns of a table plus something extra”, such as a row count computed alongside each row. There, * and an extra expression would flatten into one anonymous struct. sqlc’s answer is sqlc.embed(tasks), which keeps the table’s columns grouped as a nested Task and puts the extra column beside it. Chapter 9 (Listing at scale) is where the list query needs exactly that; the word is here so it is not a surprise then.

Dynamic SQL wall. sqlc compiles static statements. “Sort by an arbitrary user-chosen column” cannot be a bind parameter — a placeholder stands in for a value, and a column name is part of the query’s structure, which the database must know before it can plan the query. That is true of SQL itself, not just sqlc. Chapter 9 (Listing at scale) hits this wall on purpose and walks through the three escapes.

Don’t fear the generated code — read it once. It’s short, boring, and exactly what you’d write by hand on your most careful day. Knowing that removes the “magic” unease.

The payoff, drawn

The reason for all of this is what happens when the schema changes underneath you. Here is a column rename with and without sqlc.

   BEFORE                              AFTER  `notes` → `body` in a migration
   ──────                              ─────

   hand-written scan:                  hand-written scan:
   ┌──────────────────┐                ┌──────────────────┐
   │ go build   ✓     │                │ go build   ✓     │  ← still green
   │ tests      ✓     │                │ tests      ✓     │  ← still green
   └──────────────────┘                └──────────────────┘
                                          then, in production:
                                          column "notes" does not exist

   sqlc:                               sqlc generate, then:
   ┌──────────────────┐                ┌──────────────────────────────────┐
   │ go build   ✓     │                │ go build   ✗                     │
   └──────────────────┘                │ unknown field Notes in struct    │
                                       │ literal of type CreateTaskParams │
                                       │ ... and every other call site    │
                                       └──────────────────────────────────┘

The compiler hands you the complete list of places to fix, before anything ships. Chapter 12 (Ownership) adds a user_id column to tasks and changes five queries at once; that is the chapter where this stops being a nice property and starts being the reason the change is safe.


10. Check yourself — quiz

  1. Where does sqlc learn the shape of the tasks table, and does it connect to your database to do it?
  2. sqlc.yaml has two timestamptz overrides. What does each one cover, and what exactly breaks if you delete the first?
  3. You add a query to sql/queries/tasks.sql but forget the -- name: line. What does sqlc generate print?
  4. GetTask takes (ctx, id int64) while CreateTask takes (ctx, arg CreateTaskParams). Why the difference?
  5. A :one query matches no rows. What does the generated function return, and which package does that value come from?
  6. Why is SELECT * acceptable in a sqlc query but risky in hand-written scanning code?
  7. Your colleague deletes internal/db/ from Git, arguing “it’s generated, it doesn’t belong in the repo”. Give two concrete things that break.
  8. You rename notes to body in a migration, apply it, and do not run sqlc generate. Does go build succeed? Does the API work?
Answers
  1. From the .up.sql files in migrations/, named by schema: "migrations" in sqlc.yaml. It does not connect to any database — it parses the files as text, in numeric order, building an in-memory model of the tables. This is why sqlc works with the database container stopped, and why creating a table by hand in psql does not make sqlc aware of it.

  2. The second (guarded by nullable: true) covers due_at, mapping it to *time.Time so nil means NULL. The first, unguarded, covers NOT NULL timestamps — created_at and updated_at — mapping them to plain time.Time. Delete the first and those two fields become pgtype.Timestamptz. Nothing in Chapter 7 breaks; Chapter 11 fails to compile with cannot use token.Expiry (variable of struct type time.Time) as pgtype.Timestamptz value in struct literal, and Chapter 16 fails the same way on CurrentPeriodEnd.

  3. Nothing. Exit status 0, no warning. sqlc treats a statement with no magic comment as not-a-query and skips it silently. You find out at go build, when the function you expected is undefined: q.CountTasks undefined (type *db.Queries has no field or method CountTasks). This makes “check the magic comment” the first thing to try when a function you know you wrote is missing.

  4. sqlc emits a params struct only when a query has more than one bind parameter, because that is when arguments can be swapped by mistake. GetTask has exactly one ($1), so a bare argument is safe. CreateTask has four, two of which are string — the swappable case that named struct fields make impossible.

  5. pgx.ErrNoRows, from github.com/jackc/pgx/v5. You test for it with errors.Is(err, pgx.ErrNoRows) rather than by reading the message. Chapter 8 turns that check into a 404 Not Found.

  6. Because sqlc expands the * into an explicit column list at generation time, using the migration as the source of truth, and regenerates the matching Scan call at the same moment. The two can never be out of step. Hand-written code has no such link: a new column added to the table changes what SELECT * returns while the Scan call keeps the old argument list.

  7. (a) Anyone cloning the repository cannot run go build until they install sqlc and generate — including CI, and including you on a new laptop. (b) sqlc diff, the CI check from Chapter 26 that catches stale generated code, has nothing to compare against. A third: code review loses the ability to see how a schema change rippled through the query layer, because the rippling is invisible.

  8. go build succeeds. internal/db/models.go still says Notes string, every call site still compiles, and nothing warns you. The failure arrives the first time that query actually runs — from Chapter 8 onward, that means the first request that hits the endpoint — with Postgres replying column "notes" does not exist. That gap between a green build and a broken endpoint is precisely what the regenerate reflex and CI sqlc diff exist to close. Exercise 2 has you do it on purpose.


11. Practice

Exercise 1 — Write your first two queries (easy)

Add two queries to sql/queries/tasks.sql: CountTasks, returning the total number of rows, and ListRecentTasks, returning the five most recently created. Generate them, then call both from a temporary block in main that logs the results. Delete the block afterwards.

Solution
-- sql/queries/tasks.sql — add at the end
-- name: CountTasks :one
SELECT count(*) FROM tasks;

-- name: ListRecentTasks :many
SELECT * FROM tasks ORDER BY created_at DESC LIMIT 5;
make sqlc
// cmd/api/main.go — TEMPORARY, after app.q is set. Delete after it prints.
n, err := app.q.CountTasks(context.Background())
if err != nil {
    logger.Error("count", "error", err)
    os.Exit(1)
}
recent, err := app.q.ListRecentTasks(context.Background())
if err != nil {
    logger.Error("recent", "error", err)
    os.Exit(1)
}
logger.Info("task census", "total", n, "recent", len(recent))

If you already removed the Step 6 block, your editor may have removed the "context" import with it — put it back. Then run go run ./cmd/api and look for a line of the shape level=INFO msg="task census" total=N recent=M, where N is however many rows your tasks table has and M is N capped at 5.

Now read what sqlc generated for these two, in internal/db/tasks.sql.go. CountTasks takes only a context and returns (int64, error) — no params struct, because the query has no $n placeholders at all. ListRecentTasks returns ([]Task, error) and contains a rows.Next() / rows.Scan(...) loop you would otherwise write by hand and eventually mis-order. That loop is the boilerplate the whole decision was about.

Exercise 2 — Feel the regenerate reflex (medium)

First, put the Step 6 temporary insert block back into main.go. You need it: right now it is the only line in the whole project that names a generated field, and this exercise is about watching a call site break.

Then rename notes to note in migrations/000001_create_tasks.up.sql, and reset the database so the edited migration applies from scratch:

docker compose down -v
docker compose up -d db
make db/migrations/up

Now build without running sqlc generate. Then run sqlc generate and build again. Explain what each build did and why the first one is the dangerous one. Rename it back when you are done.

Solution

The first build succeeds:

go build ./... && echo BUILD-OK

internal/db/models.go still says Notes string, the generated CreateTaskParams still has a Notes field, and the Go compiler has no way to know the column under it changed name. The failure would appear the first time that query actually ran, as a Postgres error: column "notes" does not exist. A green build and a broken endpoint at the same time is the worst combination there is, and it is entirely silent.

After sqlc generate, the build fails, loudly, at every site that mentions Notes — here, the one line you restored:

cmd/api/main.go:NN:NN: unknown field Notes in struct literal of type db.CreateTaskParams

That is the whole bet paying out. In Chapter 12, when a schema change touches five queries at once, this same mechanism hands you all five call sites in one build.

Fix the call site (rename the field to Note:), or rename the column back and regenerate. Then install the muscle memory the chapter prescribes — never migrate without regenerating. A one-line Makefile target makes it automatic:

# Makefile — optional convenience target
.PHONY: db/migrate
db/migrate: db/migrations/up sqlc

Once you have put everything back, confirm the round trip is clean:

sqlc generate && go build ./... && echo BUILD-OK

Then delete the temporary insert block again.

Warning

docker compose down -v deletes the named volume, and with it every row in your development database. That is what makes the edited migration re-apply from an empty schema. Never run it against anything you care about.

Exercise 3 — Read the generated code, properly (harder)

Open internal/db/db.go and internal/db/tasks.sql.go and answer these five questions from the code, not from memory. Write your answers in learnings/ch07.md before checking the key.

  1. What is DBTX, and which two concrete types satisfy it?
  2. Why does CreateTask take a CreateTaskParams struct rather than four separate arguments?
  3. Where did the json:"created_at" tags come from?
  4. GetTask returns (Task, error). What error does it return for an id that does not exist, and which package is that error from?
  5. If you added a colour text NOT NULL DEFAULT '' column to the migration and regenerated, which generated files would change, and would your handler still compile?
Solution
  1. DBTX is a three-method interface — Exec, Query, QueryRow — describing “anything you can run SQL against”. It is satisfied by *pgxpool.Pool (your connection pool) and by pgx.Tx (a transaction), which is how the same generated code runs inside transactions later, for free. Neither type mentions DBTX anywhere in its own source: having the methods is the declaration. That is Go’s implicit interface satisfaction doing real architectural work.

  2. Because the query has more than one parameter, and positional arguments are exactly the failure mode sqlc exists to kill. A struct literal names each field, so swapping Title and Notes is impossible; four bare string arguments would be silently swappable. sqlc emits bare arguments for single-parameter queries — compare GetTask(ctx, id).

  3. From emit_json_tags: true in sqlc.yaml. This is why Chapter 8 can return a db.Task straight to the client with no mapping layer and no separate response type.

  4. pgx.ErrNoRows, from github.com/jackc/pgx/v5. This single fact drives every 404 in the book: Chapter 8’s errors.Is(err, pgx.ErrNoRows) becomes notFoundResponse. And after Chapter 12 scopes every query by user_id, the same error covers both “doesn’t exist” and “isn’t yours” — which is how another user’s task returns 404 rather than 403, without a line of extra code.

  5. internal/db/models.go (the Task struct gains Colour string) and internal/db/tasks.sql.go (every expanded SELECT * / RETURNING * scan gains a field). Your handler still compiles, because you construct CreateTaskParams by field name and never scan manually — the new column flows through. Contrast with removing or renaming a column, which breaks the build at every mention. Additive changes are free; destructive changes are compiler-guided. Verify it:

    make db/migrations/up && make sqlc && go build ./... && echo STILL-COMPILES
    grep -n Colour internal/db/models.go
    

12. FAQ

Is this an ORM? No, and the difference is the point. An ORM writes the SQL and you never see it. sqlc reads the SQL you wrote and writes the Go. The query that reaches Postgres is the query in your file, character for character — you can copy it into psql and run it. What you gave up is writing Scan calls by hand, which nobody has ever enjoyed.

Do I need sqlc installed on the production server? No. It never runs there. sqlc generate runs on your laptop (and in CI, to check nothing is stale); its output is ordinary Go source that gets compiled into the binary. Appendix D lists it under “build-time tools (never imported at runtime)” alongside migrate, in a separate group from the modules the running server actually imports.

Why commit generated code — isn’t that what .gitignore is for? .gitignore is for build output. Generated Go is build input: the compiler needs it. If it were not committed, nobody could build the project without first installing sqlc at the right version, which means a broken go build for every new contributor and every CI job. The cost is some noise in diffs. The benefit is that git clone && go build works. Chapter 26 adds sqlc diff to CI, which removes the one real risk of committing it — that someone edits the SQL and forgets to regenerate.

What happens when sqlc can’t express my query? That happens, and the book walks into it deliberately rather than pretending otherwise. Chapter 9 (Listing at scale) needs a sort column chosen at request time, which no bind parameter can express in any language. It shows three ways out and picks one. You are never stuck: app.db — the raw pool — is still on the application struct, so you can always drop to hand-written pgx for the one query that needs it. In this entire codebase, that never becomes necessary.

Why is a mis-wrapped comment allowed to break the build? Honestly, it is a rough edge. sqlc’s parser cannot distinguish “a comment that happens to start with name” from “a malformed query name”, so it takes the safe route and refuses rather than guessing. The alternative — guessing — would mean a typo’d -- name; GetTask :one silently produced no function, which is a worse failure because it is silent. Given the choice between a loud stop and a quiet skip, a loud stop at generation time is the right call. Rewrap the sentence and move on.

This is one chapter for two SQL queries. Is it worth it? Ask again at Chapter 12. That chapter adds a user_id column to tasks and rewrites five queries to scope by it. With hand-written scanning, that is five careful edits plus a prayer; with sqlc, it is a migration, one make sqlc, and a compiler that hands you the exact list of call sites to fix. The setup cost is paid once, in this chapter. The return arrives every time the schema moves for the next twenty chapters.


13. Where we are

Schema-verified, typed queries with zero scanning boilerplate. But the API still speaks Fprintf-JSON and can’t receive input. Part III turns this into an actual API.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go            # UPDATED: q field, db.New(pool), pool rename
│   ├── server.go
│   ├── routes.go
│   ├── config.go
│   ├── db.go              # openDB — the pool constructor, ch. 6
│   ├── middleware.go
│   ├── errors.go          # still the ch. 2 stub; ch. 8 replaces it
│   └── healthcheck.go
├── internal/
│   └── db/                # NEW: generated by sqlc — never edit, always commit
│       ├── db.go          #   DBTX, Queries, New, WithTx
│       ├── models.go      #   the Task struct
│       └── tasks.sql.go   #   CreateTask, GetTask
├── migrations/
│   ├── 000001_create_tasks.up.sql
│   └── 000001_create_tasks.down.sql
├── sql/queries/
│   └── tasks.sql          # NEW: the SQL you write
├── sqlc.yaml              # NEW: config, with BOTH timestamptz overrides
├── Makefile               # UPDATED: sqlc, sqlc/vet
├── config.toml
├── docker-compose.yml
├── .envrc                 # gitignored
├── .gitignore
└── go.mod   go.sum

What works end to end: the program can create a task in Postgres and read one back, through Go functions generated from SQL you wrote, with the compiler checking that the two agree.

What is still fake or missing:

  • No HTTP endpoint touches the database. The only query call was a temporary line in main, and you deleted it. Chapter 8 (CRUD done properly) builds the handlers.
  • The healthcheck still hand-writes JSON with Fprintf. Chapter 8 replaces it with writeJSON.
  • The API cannot receive input. There is no JSON decoding, no validation, no error envelope yet — all Chapter 8.
  • Tasks belong to nobody. There is no user_id column, because there are no users. Chapter 10 creates them and Chapter 12 (Ownership) connects the two.
  • No transaction ever begins. DBTX and WithTx make transactions available; nothing in the book uses them yet.

For your notes

Copy these into learnings/ch07.md, in your own words:

  1. sqlc is a compiler you run at your desk, not a library your server imports. Its output is ordinary Go, committed to the repository; the running binary has never heard of sqlc.
  2. One truth, two consumers. migrations/ describes the tables for both the database and the code generator. A second copy of the schema is a second thing to keep in sync, which means a thing that will eventually be out of sync.
  3. The magic comment is the interface. -- name: X :one decides the Go function’s name and its return type. A query without one is silently skipped — that is the first thing to check when a function you know you wrote is undefined.
  4. $1 is a value slot, never a column name. That is a fact about SQL, not a limit of sqlc, and it is why user input can never be spliced into a query’s structure.
  5. Both timestamptz overrides are load-bearing. sqlc’s pgx driver maps NOT NULL timestamps to pgtype.Timestamptz exactly as it maps nullable ones. Override both, or a chapter four ahead fails to compile with an error pointing at the wrong file.

Chapter 8 — CRUD done properly: JSON helpers, errors, validation

Everything the server has done so far, it has done to itself. It read its own config, opened its own database pool, and printed one hand-written line of JSON about its own health. From this chapter on, strangers send it data. That changes the job completely: a program that only emits can be careless, and a program that accepts input from the internet cannot. We build the four small pieces of machinery every remaining endpoint in this book stands on — a hardened readJSON, an enveloped writeJSON, a real errors.go, and a forty-line validator — and then use them to build the full life of a task: create it, read it, change part of it, delete it.

What you’ll be able to do by the end

  • Create, fetch, partially update and delete tasks over HTTP with curl, and see the rows change in Postgres.
  • Send a request wrong on purpose — malformed JSON, a typo’d key, an empty title, a two-gigabyte body, an id of abc — and get back a different, useful, correctly-numbered error for each.
  • Explain what the version column does, what the book’s handler actually does with it, and what the gap between those two things is.
  • Read any handler in the rest of this book and name which of its four beats you are looking at.
  • Point at the healthcheck’s JSON and say “a function built that, not a Fprintf”.

Time: ~70 minutes reading, ~60 minutes typing.

You need before starting: a working Chapter 7 (sqlc: SQL in, type-safe Go out) — the generated internal/db package, app.q on the application struct, and the tasks table migrated. Two commands prove it. First, with Docker running:

docker compose up -d db
go run ./cmd/api

You should see a log line containing msg="starting server" and no error. Then, in a second terminal:

curl -i localhost:4000/v1/healthcheck

The first line should read HTTP/1.1 200 OK, and the body should be the hand-written JSON from Chapter 2 (The skeleton):

{"status":"available","environment":"development","version":"0.1.0"}

That body is the crime this chapter finally solves.


1. The problem, in plain words

Picture a counter clerk at a post office. Behind the counter, everything is orderly: forms in trays, stamps in a drawer, a computer that talks to the depot. In front of the counter is the public, and the public hands over anything at all — forms filled in the wrong box, forms in pencil, forms in a language nobody there reads, forms two hundred pages long, a form for a parcel that was collected last week, and occasionally a form designed specifically to jam the machine.

A good clerk does not “process” the good ones and crash on the bad ones. A good clerk has a practised response for each way a form can be wrong, and every response tells the customer exactly what to fix. That is the entire subject of this chapter.

Your API is that counter. Up to now the only thing anyone could do was ask it a question with no body (GET /v1/healthcheck). Now it accepts bodies, and the moment it does, all of this is possible:

  • The body is not JSON at all. It is half a JSON document, or two JSON documents glued together, or a photograph.
  • The body is valid JSON, but a field is the wrong shape: {"title": 123}.
  • The body is valid JSON with a typo in a key: {"titel": "buy milk"}. Nothing crashes. If you do nothing about it, the server saves a task with an empty title, answers 200 OK, and the client team spends a day proving the bug is yours.
  • The body is two gigabytes. Your server reads all of it into memory, because that is what computers do when told to read.
  • The body is perfect JSON with a perfectly-typed empty title, which your database will happily store, making the product wrong instead of the code wrong.
  • The URL says /v1/tasks/abc, and abc is not a number.
  • The URL says /v1/tasks/999, and there is no task 999.
  • Two people edit the same task at the same time, and one of the two edits vanishes without anybody being told.
Why this exists

An API’s quality is not visible on the happy path. Every API in the world can return a task when you ask correctly. The difference between an API people build on and an API people file tickets about is what happens on the other eight lines of that list. So we design those first, and let the happy path fall out of the helpers we built for the failures.

There is a second, quieter problem. Each of those failures needs a response, and if each handler invents its own response shape, your API becomes several APIs wearing one hostname: one endpoint answers {"error": "..."}, another answers {"message": "..."}, a third answers plain text with a 200. Clients then need one parser per endpoint. So the answers live in one file, and every handler calls into it.

What breaks if you skip this chapter: nothing, for about a week. Then a client typo silently destroys data, an error message leaks your Postgres error text to the public internet, and two editors overwrite each other on a Tuesday afternoon that nobody can reconstruct afterwards.


2. New words in this chapter

Long list, and you are not expected to arrive knowing any of it. Each one is defined again at the moment the chapter first needs it.

Word What it means here
CRUD Create, Read, Update, Delete — the four basic operations on stored records.
REST A style of API where things (tasks, users) have addresses and HTTP verbs act on them.
HTTP method / verb The verb of a request: GET fetches, POST creates, PATCH edits part of something, PUT replaces or performs an action, DELETE removes.
status code The three-digit number on every response: 2xx worked, 4xx the caller was wrong, 5xx the server was wrong.
201 Created Success, and something new now exists — the Location header says where.
400 Bad Request Your request was malformed; we could not even read it.
404 Not Found Nothing lives at that address.
409 Conflict Someone changed this record since you read it; fetch it again and retry.
422 Unprocessable Entity The request was well-formed JSON, but its contents broke a rule.
Location header A response header giving the URL of the thing that was just created.
JSON JavaScript Object Notation — the standard text format for data on the web. Six kinds of value: object, array, string, number, true/false, null.
marshal / unmarshal Turning Go values into JSON text, and JSON text back into Go values. Also called serialize / deserialize.
decoder (json.Decoder) The object that reads JSON out of a request body as a stream, one value at a time.
request body The data carried by a request, after its method, path and headers.
envelope This book’s convention of wrapping every response in a named key: {"task": {...}}, {"error": ...}.
body size cap / MaxBytesReader A hard limit on how many bytes a client may send.
memory bomb / DoS Denial of Service — making a server useless by exhausting a resource rather than by breaking in.
DisallowUnknownFields Reject bodies containing keys we don’t recognise, so a client typo is an error instead of silent data loss.
validation Checking a request’s contents against the rules before touching the database, and reporting every problem at once.
sentinel error A specific named error value you compare against, such as pgx.ErrNoRows.
errors.Is / errors.As Is asks “is this that known error?”; As asks “is this an error of type X, and if so, give it to me”.
panic Go’s “this should be impossible” exit from a function; Chapter 4’s recoverPanic middleware turns one into a 500 instead of a dead server.
slice Go’s growable list of values of one type, written []T.
map A lookup table from keys to values, written map[K]V.
any A value of any type at all; Go’s escape hatch for when the type genuinely varies.
nil The “nothing here” value — an absent pointer, an empty map, a missing error.
struct tag A note in backticks attached to a struct field, telling libraries how to name it: json:"due_at".
anonymous struct A one-off struct declared at the spot it is used, with no name.
generics ([T comparable]) Writing one function that works for several types instead of copying it per type.
variadic (...T) A parameter accepting any number of arguments; permitted... spreads a slice into one.
reflection A library’s ability to inspect your types while the program runs. Powerful, and hard to step through.
path parameter The variable part of a route pattern: the {id} in /v1/tasks/{id}.
partial update / PATCH Sending only the fields you want changed, leaving the rest alone.
optimistic locking Every row carries a counter; an update only succeeds if the counter still matches the one you read.
lost update The bug optimistic locking prevents: the second writer silently erases the first writer’s change.
atomicity The guarantee that an operation happens completely or not at all, with nothing in between.
row lock / deadlock Holding a row against other writers; a deadlock is two holders each waiting forever for the other’s row.
edit conflict The name we give a rejected stale write, reported as 409.
DTO Data Transfer Object — an extra struct that exists only to shape data for the wire. Deliberately avoided here.
snake_case The created_at naming style used in JSON and SQL, as opposed to Go’s CreatedAt.
information disclosure Accidentally telling an attacker something useful through an error message.

3. The goal

The full task lifecycle over HTTP — POST /v1/tasks, GET /v1/tasks/{id}, PATCH /v1/tasks/{id}, DELETE /v1/tasks/{id} — built on three small pieces of infrastructure we’ll reuse for every future endpoint: a hardened readJSON, an enveloped writeJSON, a real errors.go, and a 40-line validator package. Plus optimistic locking so concurrent editors can’t silently clobber each other.

In plain terms: by the end of this chapter, a stranger with curl can manage tasks in your database, and cannot break your server by trying.

New word

CRUD — Create, Read, Update, Delete. The four things you can do to a stored record, and, not coincidentally, four HTTP verbs: POST, GET, PATCH (or PUT), DELETE. When someone says “it’s a CRUD app”, they mean most of its code is these four operations repeated per table.


4. The thinking

Where did I start? With the error surface, not the happy path

An API’s quality shows in its failures: malformed JSON, unknown fields, a 2 GB body, an ID of “abc”, a valid request for a missing row, two clients updating the same task. Enumerate those first and the helpers design themselves.

This inversion — failures first — is the single most Edwards-flavored habit in the book.

Note

Alex Edwards is the author of Let’s Go Further, named in the preface as one of this codebase’s two influences. “Edwards-flavored” here means one specific habit: before writing the function that succeeds, write down every way the caller can fail, and design the shared helper that answers all of them. You are about to do that for JSON, and then never think about it again.

json.Decoder, not json.Unmarshal, and locked down

Go gives you two ways to turn JSON text into a Go value.

json.Unmarshal(data, &v) json.NewDecoder(r.Body).Decode(&v)
Input a []byte you already hold in memory a stream, read as it arrives
To use it on a request read the whole body first, then unmarshal hand it the body directly
Can reject unknown keys no yes, dec.DisallowUnknownFields()
Can detect trailing junk you must check the leftovers yourself decode a second time and expect end-of-input
Memory the whole body, always the whole body too, in practice, but the cap is enforced by the reader

The decoder wins because two of the three defences we want are methods on it.

Three decoder behaviors are production-mandatory:

  1. A body size cap (http.MaxBytesReader), or someone POSTs you a memory bomb.
  2. DisallowUnknownFields, or clients typo titel, get silent 200s, and file bugs against you.
  3. A check for trailing garbage after the first JSON value.

Then: Go’s decode errors are hostile strings; we triage them into humane messages once, here, forever.

New word

memory bomb — a request whose only purpose is to make your server allocate memory until the operating system kills it. It needs no exploit and no cleverness: a POST with a very long body is enough, if nothing caps the length. The general category is a DoS (Denial of Service) attack: the server is not broken into, it is made useless.

Envelope responses

{"task": {...}} instead of a bare object. Costs one map literal; buys self-describing payloads and a forever-place to add siblings (metadata arrives next chapter) without breaking clients.

bare object {...} envelope {"task": {...}}
A client reading a response must already know what it asked for can see what it is holding
Adding pagination metadata later breaking change: the top level changes shape additive: a new sibling key
Errors need a different shape from success same shape: {"error": ...}
Cost none one map literal per response
Think of it like

An envelope is the difference between being handed a loose sheet of paper and being handed a labelled envelope. Both contain the letter. Only one of them tells you what you are holding and leaves room to put a second page in later.

Validation: library or hand-rolled?

go-playground/validator runs on struct tags and reflection — compact, but rules become stringly-typed annotations you can’t step through. Edwards’ alternative is a ~40-line Validator you write once: plain Go conditions, a plain error map, and a test that needs nothing but the struct.

tag-based library the 40-line Validator
A rule looks like validate:"required,max=200" in a string v.Check(len(title) <= 200, ...) in Go
Debugging a rule step into the library’s reflection put a breakpoint on the if
Custom rule register a named function, learn the API write an if
Compile-time checking none — a typo’d tag silently does nothing full: it is ordinary Go
Lines you own 0 ~40
Wins when 50 entities, deeply nested rules an API this size

For an API this size the hand-rolled version wins on debuggability alone. (At 50 entities with deeply nested rules, revisit.)

New word

reflection — a library’s ability to look at your types while the program is running: “what fields does this struct have, and what strings are attached to them?” It is how tag-based validators work. It is also why their failures are hard to trace: there is no line of your code to put a breakpoint on.

Partial updates

PATCH with pointer fields (*string) — nil means “not provided”, which JSON’s absent-vs-null distinction otherwise makes invisible. Fetch current row, overlay provided fields, validate the result, write back.

Here is the problem in one picture. A plain string field has only one way to say “empty”, and JSON has three different things to say:

   JSON the client sent      Go field: string      Go field: *string
   ─────────────────────     ────────────────      ──────────────────
   {"title":"buy milk"}      "buy milk"            ptr ──▶ "buy milk"
   {}          (absent)      ""                    nil
   {"title":""}              ""                    ptr ──▶ ""
   {"title":null}            ""                    nil

With a plain string, rows two, three and four are the same value, so “the client did not mention title” and “the client wants the title cleared” are indistinguishable. With a *string, row one and row three are distinct from row two — which is the distinction PATCH is built on.

Rows two and four remain identical even with a pointer. That limitation is real, and we come back to it honestly in Part 6g.

Optimistic locking

Two clients GET version 1; both PATCH; without protection the second write silently erases the first (lost update). Here it is on a timeline. Read down the middle column: that is the row as the database holds it, moment by moment.

  time   client A                 tasks row 7            client B
  ────   ──────────────────────   ─────────────────      ─────────────────────
   1     GET /v1/tasks/7 ──────▶  title="Milk"
   2                              title="Milk"    ◀───── GET /v1/tasks/7
   3     types "Oat milk"                                types "Milk 2L"
   4     PATCH title="Oat milk"▶  title="Oat milk"
   5                              title="Milk 2L" ◀───── PATCH title="Milk 2L"
   6                   A's edit is gone. Nobody was told.
                       Both clients received 200 OK.

Fix: UPDATE ... WHERE id=$1 AND version=$2, bumping version — the second writer matches zero rows and receives 409 Conflict with instructions to re-fetch.

  time   client A                 tasks row 7            client B
  ────   ──────────────────────   ─────────────────      ─────────────────────
   1     GET ──────────────────▶  title="Milk" v=1
   2                              title="Milk" v=1 ◀──── GET
   4     UPDATE ... WHERE v=1 ▶   title="Oat"  v=2       (A wins: 200 OK)
   5                              UPDATE ... WHERE v=1 ◀ B tries, still holding v=1
                                  0 rows match ───────▶  409 Conflict
                                                         "re-fetch and retry"

No row locks held across user think-time, no deadlocks; the database’s atomicity does the work.

New word

atomicity — the guarantee that an operation happens completely or not at all. A single SQL UPDATE statement is atomic: no other connection can see it half-done, and two of them cannot both match the same version = 1 row. That is the entire mechanism here; we are borrowing a guarantee Postgres already gives us rather than building one.

The alternative is pessimistic locking, and it is worth knowing why we did not choose it:

pessimistic (SELECT ... FOR UPDATE) optimistic (version counter)
How lock the row when read, hold it until the write read freely, check the counter at write time
Held across a human thinking yes — that is the problem no
Two clients both reading second one waits neither waits
Risk deadlocks, and one slow user stalling others a wasted round trip when a conflict happens
Conflict is detected before the work after the work
New word

row lock — a database’s way of saying “nobody else may write this row until I say so”. deadlock — two connections each holding a lock the other needs, both waiting forever, until the database kills one of them. Locks held while a human decides what to type are how you get both.

Note

Read that last diagram carefully and hold onto it, because in Part 6g you will discover that the handler this book prints does not quite implement it. That gap is examined honestly there, not hidden.


5. A picture of it

Three pictures, in the order you will build them.

One: the four-beat handler. Every write endpoint in this book — tasks here, users in Chapter 10 (Users and passwords), tokens in Chapter 11 (Stateful tokens), billing in Chapter 15 (Stripe I) — is this same bar of music with different notes. The right-hand column is the status code that beat can produce when it fails.

        POST /v1/tasks     {"title":"buy milk","priority":"high"}
                    │
                    ▼
    ┌───────────────────────────────────────────────┐
    │ BEAT 1  DECODE    app.readJSON(w, r, &input)  │───▶ 400 Bad Request
    ├───────────────────────────────────────────────┤
    │ BEAT 2  VALIDATE  data.ValidateTask(v, ...)   │───▶ 422 Unprocessable
    ├───────────────────────────────────────────────┤
    │ BEAT 3  QUERY     app.q.CreateTask(ctx, ...)  │───▶ 500 Server Error
    ├───────────────────────────────────────────────┤
    │ BEAT 4  RESPOND   app.writeJSON(201, ...)     │───▶ 201 Created
    └───────────────────────────────────────────────┘
  1. Decode turns bytes into a Go struct. Anything wrong here is the client’s fault.
  2. Validate checks the contents against business rules. The body parsed; the answers were wrong.
  3. Query talks to Postgres. Anything wrong here is our fault or the database’s.
  4. Respond writes the status, the headers and the envelope.

Two: readJSON’s three gates. The body passes through three checks before your handler ever sees a value, and each gate exists because of a specific thing that goes wrong without it.

   request body
        │
        ▼
   ┌──────────────┐     ┌──────────────────┐     ┌──────────────────┐
   │ GATE 1       │     │ GATE 2           │     │ GATE 3           │
   │ 1 MB cap     │────▶│ unknown keys     │────▶│ one value only   │────▶ dst
   └──────────────┘     └──────────────────┘     └──────────────────┘
   stops:               stops:                   stops:
   a 2 GB upload        {"titel":"buy milk"}     {"a":1}{"b":2}
   eating your RAM      silently saving nothing  a second hidden doc

Three: the error funnel. Every non-2xx response the API will ever send leaves through one function, which means the shape of your errors is decided in one place and cannot drift.

   badRequestResponse ────────┐
   notFoundResponse ──────────┤
   failedValidationResponse ──┼──▶ errorResponse ──▶ writeJSON ──▶ {"error":...}
   editConflictResponse ──────┤          │
   serverErrorResponse ───────┘          │
              │                          └── status code chosen by the caller
              └──▶ logError ──▶ app.logger.Error(...)      ← 5xx only

The asymmetry at the bottom is the rule to memorise: 5xx logs the details and tells the client nothing; 4xx tells the client everything and needn’t log.


6. The steps

Seven parts. Parts 6a–6d build machinery and nothing is visible from outside; the API comes alive in 6e. Type them in order — later parts will not compile without the earlier ones, which is the point.

Part 6a — The helpers file, the most-reused code in the project

cmd/api/helpers.go is a new file. It is long enough that we type it in three pieces; the three blocks below are one file, in this order.

// cmd/api/helpers.go — new file (part 1 of 3: package, imports, envelope, writeJSON)
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "strings"

    "github.com/go-chi/chi/v5"
)

// envelope is the wrapper every response body wears: {"task": {...}},
// {"error": "..."}. It's just a map from string keys to anything.
type envelope map[string]any

func (app *application) writeJSON(w http.ResponseWriter, status int,
    data envelope, headers http.Header) error {

    // Marshal to memory FIRST. If we encoded straight into w and the
    // encoding failed halfway, the client would already have a 200
    // status and half a body — an unfixable lie. Memory-first means we
    // only start writing once we know the whole payload is good.
    js, err := json.Marshal(data)
    if err != nil {
        return err
    }
    js = append(js, '\n') // trailing newline: nicer in terminals, free

    // Copy any extra headers the caller wants (e.g. Location on a 201).
    for key, value := range headers {
        w.Header()[key] = value
    }

    // Order is law: headers -> status line -> body. WriteHeader sends
    // the status; after it, header changes are silently ignored.
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    w.Write(js)
    return nil
}

What this code says, line by line

  • type envelope map[string]any — a map is Go’s lookup table: keys on the left, values on the right, written map[KeyType]ValueType. This one maps strings to any. Giving it a name means we can write envelope{"task": task} instead of map[string]any{"task": task} several hundred times.
  • any is Go’s “a value of any type at all”. (It is an alias for interface{}, which you will see in older code; they are the same thing.) We need it because the value under "error" is sometimes a string and sometimes a map of field errors.
  • func (app *application) writeJSON(...) — a method on the application struct from Chapter 2 (The skeleton), the one box holding the logger, config and DB pool. Every helper in this book is a method on it, which is how they all reach those things.
  • json.Marshal(data)marshal means “turn this Go value into JSON text”. It returns a []byte (a slice of bytes: Go’s growable list) and an error.
  • js = append(js, '\n')append adds to the end of a slice and returns the new slice; you must assign the result back. '\n' in single quotes is a single byte, the newline character. Double quotes would make it a string, which is a different type.
  • for key, value := range headersrange walks a collection. Over a map it hands you each key and value in turn, in no guaranteed order.
  • w.Header()[key] = valuehttp.Header is a map: map[string][]string. The value is a slice because a header may legitimately appear more than once in one response. Indexing the map directly and assigning replaces whatever was there for that key.
  • w.WriteHeader(status) sends the status line. Everything after it is body. Setting a header after this call does nothing at all, and Go will not warn you — hence “order is law”.
  • w.Write(js) sends the bytes.
Why this exists

Why marshal into memory first? json.NewEncoder(w).Encode(v) writes as it encodes. If the encoding fails on field nine of eleven, the client already has 200 OK in its hands and half a body on the wire, and there is no way to take it back — HTTP has no undo. Marshalling first means we discover the failure while we can still choose to send a 500 instead. For payload sizes like ours the extra copy costs nothing measurable.

Now part two of the same file, appended below writeJSON.

// cmd/api/helpers.go — part 2 of 3: readJSON (append below writeJSON)

// readJSON decodes a request body into dst (a pointer to a struct),
// converting Go's hostile decode errors into messages a client can act on.
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    // Defense 1: cap the body at 1 MB. Without this, a client can POST
    // gigabytes and your server will faithfully buffer them into RAM.
    maxBytes := 1_048_576 // 1 MB
    r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))

    // Defense 2: reject keys we don't know. Otherwise a client who
    // typos "titel" gets a silent 200 with nothing saved — and files
    // the bug against YOU.
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    err := dec.Decode(dst)
    if err != nil {
        // Triage: figure out WHICH way the body was bad, and say so in
        // plain language. errors.As asks "is this error of type X?"
        // and fills the variable if yes; errors.Is compares to a
        // known sentinel value. This switch is the whole vocabulary
        // of ways a JSON body can be wrong — learn it once, reuse it
        // in every project forever.
        var syntaxError *json.SyntaxError
        var unmarshalTypeError *json.UnmarshalTypeError
        var invalidUnmarshalError *json.InvalidUnmarshalError
        var maxBytesError *http.MaxBytesError

        switch {
        case errors.As(err, &syntaxError):
            return fmt.Errorf("body contains badly-formed JSON (at character %d)",
                syntaxError.Offset)
        case errors.Is(err, io.ErrUnexpectedEOF):
            return errors.New("body contains badly-formed JSON")
        case errors.As(err, &unmarshalTypeError):
            if unmarshalTypeError.Field != "" {
                return fmt.Errorf("body contains incorrect JSON type for field %q",
                    unmarshalTypeError.Field)
            }
            return fmt.Errorf("body contains incorrect JSON type (at character %d)",
                unmarshalTypeError.Offset)
        case errors.Is(err, io.EOF):
            return errors.New("body must not be empty")
        case strings.HasPrefix(err.Error(), "json: unknown field "):
            field := strings.TrimPrefix(err.Error(), "json: unknown field ")
            return fmt.Errorf("body contains unknown key %s", field)
        case errors.As(err, &maxBytesError):
            return fmt.Errorf("body must not be larger than %d bytes",
                maxBytesError.Limit)
        case errors.As(err, &invalidUnmarshalError):
            panic(err) // programmer error: dst wasn't a pointer
        default:
            return err
        }
    }

    // Defense 3: the body must be ONE JSON value. Decoding again into a
    // throwaway struct should hit end-of-input (io.EOF); anything else
    // means trailing garbage like {"a":1}{"b":2}.
    if err := dec.Decode(&struct{}{}); err != io.EOF {
        return errors.New("body must only contain a single JSON value")
    }
    return nil
}

What this code says, line by line

  • dst any — the destination. The caller passes a pointer to their struct (&input) so the decoder can fill it in. The parameter is any because every handler passes a different struct type.
  • 1_048_576 — Go lets you put underscores in numbers for readability. This is 1024 × 1024, one megabyte.
  • http.MaxBytesReader(w, r.Body, int64(maxBytes)) — wraps the body so that reading past the limit produces an error instead of more bytes. It takes w as well, so that when the limit trips, the server knows the request was cut short and can close the connection rather than try to keep it alive.
  • dec.DisallowUnknownFields() — from now on, a key in the JSON with no matching struct field is an error rather than something quietly skipped.
  • err := dec.Decode(dst) — read one JSON value from the stream and fill dst.
  • var syntaxError *json.SyntaxError — declaring a variable of pointer-to-error-type, with no value yet (nil). It is a slot for errors.As to fill.
  • switch { with nothing after switch — a plain list of conditions, evaluated top to bottom; the first true wins. It is if / else if / else if with less punctuation.
New word

errors.Is vs errors.As. Go errors can wrap other errors, so you rarely compare with ==. errors.Is(err, io.EOF) asks: is this error, or anything it wraps, that exact known value? Use it for sentinel errors — specific named values like io.EOF or pgx.ErrNoRows. errors.As(err, &syntaxError) asks: is this error, or anything it wraps, of type *json.SyntaxError? If so, put it in this variable so I can read its fields. Use it when you want data out of the error — here, syntaxError.Offset, the character position where the JSON went wrong. The & is required: As must be able to write into your variable.

The seven cases, in English:

Case The body was The client sees
*json.SyntaxError not valid JSON at some position body contains badly-formed JSON (at character 9)
io.ErrUnexpectedEOF valid JSON that stops mid-value body contains badly-formed JSON
*json.UnmarshalTypeError right key, wrong kind of value body contains incorrect JSON type for field "title"
io.EOF completely empty body must not be empty
unknown field a key we don’t have a field for body contains unknown key "titel"
*http.MaxBytesError bigger than 1 MB body must not be larger than 1048576 bytes
*json.InvalidUnmarshalError fine — we passed a non-pointer (nothing: we panic)

Two of those deserve a second look.

The unknown-field case is the ugly one: it matches on the text of the error, because the standard library does not give that failure a type of its own. String-matching an error message is normally a smell — it breaks when the library rewords itself. Here it is the only door open, and you write it once in your career.

Note the one panic: InvalidUnmarshalError means we passed a non-pointer — a bug, not input. Panicking on programmer errors and returning errors on user input is a distinction worth enforcing.

Why this exists

A panic unwinds the program. Chapter 4 (A server that dies well) put a recoverPanic middleware outermost, so a panic in a handler becomes a 500 for that one request rather than a dead server. The distinction being drawn here: bad input from a client is expected and gets a polite error; a mistake by us that no input could cause is not expected, and should be loud, immediate and impossible to ignore during development.

The last defence reads oddly the first time:

  • dec.Decode(&struct{}{})struct{}{} is a value of an empty struct type; & takes its address. We do not care what gets decoded into it. We care about the error: if the body held exactly one JSON value, the second decode finds nothing left and returns io.EOF, and that is the only outcome we accept.
Common mistake

You’ll see: a client sends {"title":"a"}{"title":"b"} and gets {"error":"body must only contain a single JSON value"}. It means: gate 3 did its job. Without it, the first document would be accepted and the second silently ignored — which is how a request that looks accepted saves the wrong data. Fix: nothing to fix; this is the feature working.

Now part three, the two small readers.

// cmd/api/helpers.go — part 3 of 3: readIDParam and itoa (append at the end)

func (app *application) readIDParam(r *http.Request) (int64, error) {
    id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
    if err != nil || id < 1 {
        return 0, errors.New("invalid id parameter")
    }
    return id, nil
}

// itoa: int64 -> string. strconv.Itoa only takes int; our IDs are int64.
func itoa(i int64) string { return strconv.FormatInt(i, 10) }
Note

itoa is printed here for the first time. The original edition mentioned it in a parenthesis — “itoa is a two-line strconv.FormatInt wrapper in helpers.go” — and never showed it, while four later chapters call it. Without these two lines your build fails at Part 6e with undefined: itoa. The code is exactly what the parenthesis described.

What this code says, line by line

  • chi.URLParam(r, "id") — chi is the router from Chapter 2. When a route is registered as /tasks/{id}, the {id} is a path parameter: chi captures whatever was in that slot and stores it on the request. This asks for it by name and gets a string back.
  • strconv.ParseInt(s, 10, 64) — parse the string as a number. 10 is the base (decimal, as opposed to 16 for hex), 64 is the bit size, which makes the result an int64 — matching the bigint id column from Chapter 5 (PostgreSQL and migrations).
  • if err != nil || id < 1 — one branch for both failures: “not a number at all”, and “a number we could never have issued” (our ids start at 1). Both mean the URL names nothing.
  • The function returns (int64, error) — two values, Go’s normal way of reporting failure.
Remember this

readJSON and writeJSON are the two most-called functions in this codebase. Everything after this chapter — users, tokens, billing, password resets — reads and writes through them. Half an hour spent understanding them now is repaid twenty times.

Part 6b — Replace the stub errors.go with the real one

Chapter 2 gave you a deliberately crude errors.go with two functions built on http.Error, so the project would compile. Replace that whole file with this one. Every non-2xx the API will ever send originates here — one file to grep when a client asks “what does this error mean”:

// cmd/api/errors.go — replaces the whole file (the Chapter 2 stub)
package main

import (
    "fmt"
    "net/http"
)

// logError: server-side detail, never shown to clients.
func (app *application) logError(r *http.Request, err error) {
    app.logger.Error(err.Error(), "method", r.Method, "path", r.URL.Path)
}

// errorResponse is the single funnel every error passes through, so every
// error the API ever emits has the same {"error": ...} shape. message is
// `any` because it's sometimes a string, sometimes a map of field errors.
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request,
    status int, message any) {

    err := app.writeJSON(w, status, envelope{"error": message}, nil)
    if err != nil {
        // Writing the error failed?! Log it and send a bare 500 —
        // the last resort of a handler with nothing left to say.
        app.logError(r, err)
        w.WriteHeader(http.StatusInternalServerError)
    }
}

func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.logError(r, err)
    app.errorResponse(w, r, http.StatusInternalServerError,
        "the server encountered a problem and could not process your request")
}

func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
    app.errorResponse(w, r, http.StatusNotFound,
        "the requested resource could not be found")
}

func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}

func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request,
    errors map[string]string) {
    app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
}

func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
    app.errorResponse(w, r, http.StatusConflict,
        "unable to update the record due to an edit conflict, please try again")
}

var _ = fmt.Sprintf // keep fmt for later additions

What this code says, line by line

  • app.logger.Error(err.Error(), "method", r.Method, "path", r.URL.Path) — the structured logger from Chapter 3 (Configuration and logging). The first argument is the message; after it come alternating key/value pairs, so the line comes out searchable rather than as prose.
  • errorResponse(..., message any)any again, and for a concrete reason: four of the five callers pass a string, and failedValidationResponse passes a map[string]string of field-name → problem. Both are valid JSON values; the funnel does not care which.
  • envelope{"error": message} — the same envelope every success uses, so a client can look for error on every response and be done.
  • The if err != nil inside errorResponse handles the impossible-but-not-quite case: writing the error response itself failed. There is nothing sensible left to say, so we log it and send a bare 500 with no body.
  • http.StatusUnprocessableEntity is the constant for 422. Using the constants rather than the numbers means a typo is a compile error rather than a mystery.
  • var _ = fmt.Sprintf — see the note below.
Note

var _ = fmt.Sprintf looks like nonsense, and nearly is. Go refuses to compile a file that imports a package it does not use: "fmt" imported and not used. Right now nothing in this file uses fmt, but later chapters add error responses that do. That line assigns something from fmt to the blank identifier _ (a deliberate throwaway) purely to keep the import legal. The honest alternative is to delete the "fmt" import today and add it back in Chapter 10. Both are defensible; the book keeps the line so the import block stops changing.

Tip

failedValidationResponse names its parameter errors, which is also the name of a standard library package. Inside that one function, the name errors now means the parameter, and the package would be unreachable. It is harmless here because this file does not import errors at all — but it is a real Go rule worth knowing: names shadow, and packages are not special.

The rule this file exists to enforce. The pattern to internalize: 5xx logs details and tells the client nothing; 4xx tells the client everything and needn’t log. Leaking pq: duplicate key value violates... to users is amateur hour and an information disclosure.

New word

information disclosure — accidentally telling an attacker something useful. A raw database error names your tables, your columns, your constraints and sometimes your schema layout. None of that helps the caller fix their request; all of it helps someone probing you.

Here is the status-code vocabulary this chapter uses, which is also most of the vocabulary the rest of the book uses:

Code Name We send it when Logged?
200 OK a read, update or delete succeeded no
201 Created a POST created a row; Location says where no
400 Bad Request we could not read the body at all no
404 Not Found the URL names nothing — bad id, or no such row no
409 Conflict someone else changed the row since you read it no
422 Unprocessable Entity we read the body fine; its contents broke a rule no
500 Internal Server Error we broke, or Postgres did yes, with detail
Why this exists

Why 422 and not 400 for an empty title? They answer different questions. 400 means “this was not a request I could parse” — the envelope was gibberish. 422 means “I read your request perfectly, and the answers you gave are not acceptable”. A client can act on that difference: a 400 is a bug in their serialisation code; a 422 is something to show the user next to the offending form field.

Part 6c — Cash the promise: the healthcheck finally writes real JSON

Chapter 2 hand-wrote the healthcheck’s JSON with fmt.Fprintf and called it a crime, on the grounds that Chapter 8’s writeJSON would have a crime to solve. Chapter 6 (Connecting with pgx/v5) upgraded the handler to ping the database and left the response as .... This is that payoff. Replace the body of healthcheckHandler:

// cmd/api/healthcheck.go — replaces the whole file
package main

import (
    "context"
    "net/http"
    "time"
)

// healthcheckHandler answers "is this instance able to do work?" — which
// means asking the database, not just proving the process is alive
// (ch. 6). Orchestrators and uptime monitors point their probes here.
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    dbStatus := "up"
    if err := app.db.Ping(ctx); err != nil {
        dbStatus = "down"
    }

    status := http.StatusOK
    if dbStatus == "down" {
        status = http.StatusServiceUnavailable
    }

    err := app.writeJSON(w, status, envelope{
        "status":      "available",
        "database":    dbStatus,
        "environment": app.config.env,
        "version":     version,
    }, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}
Note

This handler is printed in full here for the first time. The original edition promised twice that Chapter 8 would fix the healthcheck’s hand-written JSON, and never reprinted the handler — its final body appears nowhere in the book. The two pieces reconstructed here are the ones the original’s own text requires: the Chapter 2 payload moved onto writeJSON, and the 503 Service Unavailable branch that Chapter 6 (“it should fail when the DB does”) and Chapter 27 (Production checklist, “kill Postgres → healthcheck goes down”) both assume.

What this code says, line by line

  • context.WithTimeout(r.Context(), 2*time.Second) — a context is Go’s cancellation mechanism, introduced in Chapter 6. This one derives from the request’s own context and self-cancels after two seconds, so a wedged database gives a fast honest “down” instead of a hanging probe.
  • defer cancel() — release the timer whatever happens next. defer schedules a call for when the surrounding function returns.
  • app.db.Ping(ctx) — one real round trip to Postgres.
  • http.StatusServiceUnavailable is 503: “I am running but I cannot serve.” That is precisely what an instance with no database is, and it is the signal a load balancer needs to stop sending it traffic.
  • The envelope has four keys and no wrapper name, because this response is the status; there is no resource to name.
Tip

json.Marshal sorts map keys alphabetically. So this body comes out in the order database, environment, status, version — not the order you typed. Struct fields, by contrast, keep their declaration order. Knowing which is which saves you from believing your code changed something it didn’t.

Part 6d — The validator

Two new files: the tiny generic package, and the domain rules that use it.

// internal/validator/validator.go — new file
package validator

import "slices"

// Validator accumulates field errors instead of failing on the first one,
// so a client fixing a form learns about ALL its problems in one round trip.
type Validator struct {
    Errors map[string]string // field name -> human message
}

func New() *Validator {
    return &Validator{Errors: make(map[string]string)}
}

// Valid: no errors recorded means the input passed.
func (v *Validator) Valid() bool { return len(v.Errors) == 0 }

// AddError keeps only the FIRST error per field — one clear message
// beats three contradictory ones.
func (v *Validator) AddError(key, message string) {
    if _, exists := v.Errors[key]; !exists {
        v.Errors[key] = message
    }
}

// Check records an error only when ok is false. Usage reads like a rule:
//    v.Check(title != "", "title", "must be provided")
func (v *Validator) Check(ok bool, key, message string) {
    if !ok {
        v.AddError(key, message)
    }
}

// PermittedValue: is value one of the allowed options? The [T comparable]
// makes it generic — works for strings today, ints tomorrow.
func PermittedValue[T comparable](value T, permitted ...T) bool {
    return slices.Contains(permitted, value)
}

That’s the whole library.

What this code says, line by line

  • package validator in internal/validator/ — a new package. internal/ is enforced by the Go toolchain: nothing outside this module can import it.
  • make(map[string]string) — maps must be created before use. A nil map can be read from but writing to one panics; make gives you a real, empty one.
  • if _, exists := v.Errors[key]; !exists — the comma-ok idiom. Indexing a map can return two values: the value, and a boolean saying whether the key was present. We discard the value with _ and keep only the boolean, because we care about presence, not content.
  • func (v *Validator) Valid() bool { return len(v.Errors) == 0 } — a one-line method. len on a map is the number of keys.
  • v.Check(ok bool, key, message string) — note the argument order: the condition first, so each call site reads as a sentence: “check that title is not empty; if not, title must be provided.”
New word

genericsPermittedValue[T comparable](value T, permitted ...T) says: this function works for any type T, as long as T is comparable (Go can test two of them with ==). Strings qualify; so do ints. Without generics you would write PermittedStringValue and PermittedIntValue and keep them in sync forever. comparable is called a constraint: the promise the type must keep for the function body to be legal.

New word

variadic parameter — the ...T in permitted ...T means “and then any number of Ts”. Inside the function, permitted is an ordinary slice. At a call site you can either list values (PermittedValue(s, "open", "done")) or spread an existing slice with three trailing dots (PermittedValue(s, Statuses...)). The dots are what turns one slice argument into many.

slices.Contains is from Go’s standard library (Go 1.21 and later): does this slice hold this value? Two lines of code you no longer have to write or test.

Domain rules live next to the domain:

// internal/data/tasks.go — new file
package data

import (
    "time"

    "github.com/yourname/taskd/internal/validator"
)

var Priorities = []string{"none", "low", "medium", "high"}
var Statuses = []string{"open", "done", "archived"}

func ValidateTask(v *validator.Validator, title, notes, status, priority string,
    dueAt *time.Time) {

    v.Check(title != "", "title", "must be provided")
    v.Check(len(title) <= 200, "title", "must not be more than 200 characters")
    v.Check(len(notes) <= 5000, "notes", "must not be more than 5000 characters")
    v.Check(validator.PermittedValue(status, Statuses...),
        "status", "must be one of: open, done, archived")
    v.Check(validator.PermittedValue(priority, Priorities...),
        "priority", "must be one of: none, low, medium, high")
}

What this code says

  • var Priorities = []string{...} — a slice literal, declared at package level, capitalised so other packages can see it (Go’s rule: capital means exported). These two lists are the same values the CHECK constraints in Chapter 5’s migration enforce. The database is the final word; this is the polite word, delivered before the database has to be rude.
  • ValidateTask takes the fields, not a struct. That is deliberate: the create handler and the update handler assemble their values differently, and both can call this.
  • dueAt *time.Time is accepted and not checked. It is in the signature because rules about due dates are the obvious next thing to add — Exercise ideas at the end of this chapter start there.
Why this exists

Why validate at all, when Chapter 5’s CHECK constraints already forbid a bad status? Because of what the failure looks like. The constraint is the guarantee — no code path can store status = 'banana'. But if you let it do the rejecting, Postgres returns an error and your handler turns that into a 500 carrying text like new row for relation "tasks" violates check constraint "tasks_status_check" — your internals, in public, telling the caller nothing they can act on. Validating first turns the same rejection into 422 {"status":"must be one of: open, done, archived"}. The constraint is the guarantee; the validator is the manners.

Note

len(title) counts bytes, not characters. A title of 200 emoji is 800 bytes and will be rejected as “more than 200 characters”. For a task title this is a fine approximation, and the book keeps it; the honest description of the rule is “200 bytes”. Chapter 10 hits the same distinction where it matters much more, with bcrypt’s 72-byte password limit.

Part 6e — The create handler, and the four-beat bar

New file cmd/api/tasks.go. This is the template every write endpoint in the book follows.

// cmd/api/tasks.go — new file
package main

import (
    "errors"
    "net/http"
    "time"

    "github.com/jackc/pgx/v5"

    "github.com/yourname/taskd/internal/data"
    "github.com/yourname/taskd/internal/db"
    "github.com/yourname/taskd/internal/validator"
)

func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) {
    // An anonymous struct declared right where it's used: this is the
    // request's SHAPE. The `json:"..."` tags map JSON keys to fields;
    // anything a client sends outside these four keys is rejected by
    // readJSON's DisallowUnknownFields.
    var input struct {
        Title    string     `json:"title"`
        Notes    string     `json:"notes"`
        Priority string     `json:"priority"`
        DueAt    *time.Time `json:"due_at"` // pointer: nil = not provided
    }

    // STEP 1 — decode. Any failure here is the client's fault: 400.
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    if input.Priority == "" {
        input.Priority = "none" // sensible default beats a required field
    }

    v := validator.New()
    data.ValidateTask(v, input.Title, input.Notes, "open", input.Priority, input.DueAt)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // STEP 3 — the database, through sqlc's typed function. r.Context()
    // ties the query's lifetime to the request: client disconnects,
    // query cancels. A failure here is OUR fault (or Postgres'): 500.
    task, err := app.q.CreateTask(r.Context(), db.CreateTaskParams{
        Title:    input.Title,
        Notes:    input.Notes,
        Priority: input.Priority,
        DueAt:    input.DueAt,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // STEP 4 — respond: 201 Created, a Location header pointing at the
    // new resource (REST manners), and the row itself in the envelope.
    headers := make(http.Header)
    headers.Set("Location", "/v1/tasks/"+itoa(task.ID))
    app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers)
}

What this code says, line by line

  • var input struct { ... } — an anonymous struct: a struct type declared inline, used once, never named. It exists to describe the shape of this one request. A named type would be ceremony; nothing else will ever need it.
  • `json:"title"` — a struct tag. Backticks, no spaces around the colon. It tells encoding/json that this field is called title on the wire. Without it, the decoder matches case-insensitively on the Go name, which is a coincidence you should not rely on. Tags are also what makes the response come out as due_at rather than DueAt — the snake_case naming that JSON and SQL both prefer.
  • DueAt *time.Time — a pointer, so nil distinguishes “the client didn’t send a due date” from “the client sent one”. It also serialises to null on the way out, for free.
  • if err := app.readJSON(w, r, &input); err != nil { — an if with an init statement: declare err and test it in one line. The variable exists only inside the if. Go code is full of this shape; it keeps short-lived errors from cluttering the function.
  • &input — the address of the struct, so readJSON can fill it. Passing input itself would hand over a copy, and the decoder would panic with InvalidUnmarshalError — the one panic we built on purpose.
  • The return after every error response. Every one. Without it the handler carries on and writes a second response on top of the first. More on that in Pitfalls; it is the single most common bug in this style of code.
  • data.ValidateTask(v, input.Title, input.Notes, "open", input.Priority, input.DueAt) — the literal "open" is not a placeholder. The CreateTask query does not insert a status, so Postgres applies the column default, which Chapter 5 set to 'open'. We validate the value that will actually be stored.
  • v.Errors goes straight into failedValidationResponse, which sends it as the error value: {"error":{"title":"must be provided"}}. One round trip, every problem.
  • r.Context() — the request’s context. If the client hangs up, the query is cancelled instead of running on for a client that will never read it.
  • make(http.Header) then headers.Set("Location", ...) — build the extra headers as a map and hand them to writeJSON, which copies them in before writing the status.
  • itoa(task.ID)task.ID is an int64; string concatenation needs a string. strconv.Itoa only accepts int, which is why the wrapper exists.

The beats, labelled: decode (STEP 1), validate (the validator.New() block — the original code labels 1, 3 and 4 and leaves validation as the unlabelled second beat), query (STEP 3), respond (STEP 4).

Decode → validate → query → respond. Every write handler in this book — tasks, users, tokens, billing — is this same four-beat bar with different notes. Once you hear the rhythm, new endpoints stop being design problems and become fill-in-the-blanks.

Remember this

Four beats, and each one owns a failure code: decode fails → 400, validate fails → 422, query fails → 500, respond → 201. When you read a handler in Chapter 15 and feel lost, find the four beats first. Everything else in the function is a detail hanging off one of them.

Notice we return the sqlc-generated db.Task directly — emit_json_tags gave it proper snake_case tags. No mapping layer, no DTO ceremony, until the day the wire format must diverge from the table. That day may come; today is not it.

New word

DTO — Data Transfer Object: an extra struct whose only job is to be the shape you send over the wire, copied field by field from your database struct. It earns its keep when the API’s shape and the table’s shape genuinely differ — hiding a column, renaming a field, merging two tables. Until then it is two structs, one copy loop and one more place to forget a field.

Part 6f — Get and delete, and the SQL behind them

Short enough to show whole. Add both to cmd/api/tasks.go:

// cmd/api/tasks.go — add these two handlers
func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        app.notFoundResponse(w, r)
        return
    }
    task, err := app.q.GetTask(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows):
            app.notFoundResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }
    app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil)
}

func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        app.notFoundResponse(w, r)
        return
    }
    rows, err := app.q.DeleteTask(r.Context(), id) // :execrows
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    if rows == 0 {
        app.notFoundResponse(w, r)
        return
    }
    app.writeJSON(w, http.StatusOK,
        envelope{"message": "task successfully deleted"}, nil)
}

What this code says, line by line

  • A bad id (/v1/tasks/abc) produces notFoundResponse, not a 400. That is a deliberate choice, argued in Pitfalls: a URL that names nothing is a 404, whatever the reason it names nothing.
  • errors.Is(err, pgx.ErrNoRows)pgx.ErrNoRows is a sentinel error: a single named value the driver returns when a :one query matched nothing. errors.Is compares against it through any wrapping.
  • The switch with two cases separates “you asked for something that isn’t there” (404) from “something went wrong at our end” (500). Every database call in the rest of this book uses this same two-branch shape.
  • rows, err := app.q.DeleteTask(...) returns a count, not a row, because of the :execrows annotation on the query. rows == 0 is how we know the id was never there; without the count we would answer 200 to a delete that deleted nothing.
  • The delete’s envelope has a message key rather than a task key. There is no task left to return.
Note

Chapter 24 (Documenting the API) documents DELETE /v1/tasks/{id} as returning 204 No Content. The code here returns 200 OK with a message body. The code is what runs; when you get to Chapter 24, the spec is the thing to correct. Two honest choices exist — 204 is more RESTful, a message body is friendlier to a human with curl — and the only wrong choice is having both written down.

The matching SQL. Append to sql/queries/tasks.sql, then run make sqlc:

-- sql/queries/tasks.sql — append these two queries

-- :execrows returns HOW MANY rows were affected — which is how the
-- handler distinguishes "deleted" (1) from "never existed" (0).
-- name: DeleteTask :execrows
DELETE FROM tasks WHERE id = $1;

-- The optimistic lock lives in two places here:
--   SET version = version + 1     every successful write bumps the counter
--   WHERE ... AND version = $7    and only succeeds against the version
--                                 the client actually read.
-- If someone else wrote first, versions no longer match, zero rows update,
-- and :one reports pgx.ErrNoRows — which the handler translates to 409.
-- name: UpdateTask :one
UPDATE tasks
SET title = $2, notes = $3, status = $4, priority = $5, due_at = $6,
    version = version + 1, updated_at = now()
WHERE id = $1 AND version = $7
RETURNING *;
make sqlc

That runs sqlc generate, which reads these queries plus your migrations and rewrites internal/db. Two new functions appear: DeleteTask(ctx, id) (int64, error) and UpdateTask(ctx, UpdateTaskParams) (Task, error), with UpdateTaskParams carrying one field per placeholder, in placeholder order: ID, Title, Notes, Status, Priority, DueAt, Version.

Common mistake

You’ll see: ./tasks.go:98:24: app.q.UpdateTask undefined (type *db.Queries has no field or method UpdateTask) (your line number will differ). It means: you added the SQL but did not regenerate, so Go is compiling against yesterday’s generated code. Fix: make sqlc, then build again. Chapter 7’s pitfall — “never change SQL without regenerating” — starts collecting its debts here.

Note

Chapter 12 (Ownership) adds user_id to every one of these queries and renumbers the placeholders, because tasks will belong to people. Today there is one implicit user: whoever can reach port 4000. That is why every curl in this chapter works with no token.

Part 6g — Partial update, with conflict detection

The longest handler in the chapter, and the one with the most to say afterwards.

// cmd/api/tasks.go — add this handler
func (app *application) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        app.notFoundResponse(w, r)
        return
    }

    task, err := app.q.GetTask(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows):
            app.notFoundResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    var input struct {
        Title    *string    `json:"title"`
        Notes    *string    `json:"notes"`
        Status   *string    `json:"status"`
        Priority *string    `json:"priority"`
        DueAt    *time.Time `json:"due_at"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // The overlay: every field is a pointer, so nil cleanly means "the
    // client didn't send this one — keep the current value". Only
    // provided fields (*input.X dereferences the pointer) are copied
    // onto the freshly-fetched row.
    if input.Title != nil    { task.Title = *input.Title }
    if input.Notes != nil    { task.Notes = *input.Notes }
    if input.Status != nil   { task.Status = *input.Status }
    if input.Priority != nil { task.Priority = *input.Priority }
    if input.DueAt != nil    { task.DueAt = input.DueAt }

    v := validator.New()
    data.ValidateTask(v, task.Title, task.Notes, task.Status, task.Priority, task.DueAt)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    updated, err := app.q.UpdateTask(r.Context(), db.UpdateTaskParams{
        ID: task.ID, Title: task.Title, Notes: task.Notes,
        Status: task.Status, Priority: task.Priority, DueAt: task.DueAt,
        Version: task.Version,
    })
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows): // version mismatch → someone got there first
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }
    app.writeJSON(w, http.StatusOK, envelope{"task": updated}, nil)
}

What this code says, line by line

  • The order matters: id, then fetch, then decode. A PATCH to a task that does not exist is a 404 even if the body is also malformed — the address is checked before the contents.
  • Title *string — a pointer to a string. Three states, as the diagram in Part 4 showed: nil (absent), or a pointer to a value (provided, possibly empty).
  • if input.Title != nil { task.Title = *input.Title } — the * in front of input.Title is dereferencing: “the value this pointer points at”. Note the asymmetry with the declaration: *string in a type means “pointer to string”, *x in an expression means “the thing x points at”. Same symbol, opposite directions.
  • task.DueAt = input.DueAt — no * here, because the field on task is also a *time.Time. We copy the pointer, not the time.
  • The validation runs on task — the merged result — not on input. A PATCH that sets status to "banana" must be rejected, and a PATCH that sets nothing must still leave a row that obeys the rules.
  • Version: task.Version — the version from the row we fetched a moment ago. Hold that thought.
  • pgx.ErrNoRows from an UPDATE ... RETURNING means the WHERE matched nothing. Since we already know the row exists (we fetched it), the only remaining explanation is that the version no longer matches: someone wrote between our read and our write. That is an edit conflict, and it becomes a 409.

The honest part. Read the last two bullets together and you will notice something.

Note

What this code actually does, versus the story in Part 4. The lost-update scenario has two clients that each read version 1, think for a while, and then write. For the second write to be refused, the server must compare against the version that client read — which means the client has to send it.

This handler never receives a version from the client. Its input struct has five fields and none of them is version. The value it passes to UpdateTask comes from its own GetTask, performed microseconds earlier in the same request. So the guard is real, but the window it guards is the gap between this handler’s read and this handler’s write — sub-millisecond — rather than the human think-time the story describes.

Consequence for Step 8 below: “open two terminals, fetch in both, update in both” will produce two 200s, not a 409, because the second request re-reads the current version before writing. The book’s code is printed here exactly as it is; the exercise at the end of this chapter closes the gap, and the honest one-sentence description of the current behaviour is: version protects against concurrent writes racing inside the handler, not against stale clients.

What would the story require? A version supplied by the client, used when present:

// cmd/api/tasks.go — NOT part of taskd, do not type this in.
// This is what the two-terminal demo would need; Exercise 3 adds it properly.
var input struct {
    // ... the five fields above, plus:
    Version *int32 `json:"version"` // the version the client read
}

expected := task.Version
if input.Version != nil {
    expected = *input.Version // compare against what the CLIENT saw
}
// ... then pass Version: expected to UpdateTask

Two details make this more than a one-line change, which is why it is an exercise rather than a footnote. First, DisallowUnknownFields means that until the field exists, a client sending {"version":1} gets a 400 body contains unknown key "version" — the feature is not merely missing, it is actively rejected. Second, you must decide whether version is optional (clients that don’t care get last-write-wins) or mandatory (every PATCH must prove it read first). Both are defensible; a booking system would choose mandatory. Chapter 24’s OpenAPI spec documents the client-supplied contract, so that chapter and this one must be reconciled whichever way you go.

The other limitation, stated plainly. With pointer-overlay PATCH, a client cannot set due_at back to null (absent and null both decode to nil). If you need “explicit null”, the known fixes are a custom Optional[T] type or json.RawMessage sniffing. We accept the limitation and document it — a fine trade at this API’s size.

Part 6h — Routes, and exercising every failure

Four new lines in routes.go, inside the /v1 group:

// cmd/api/routes.go — replace the r.Route("/v1", ...) block
r.Route("/v1", func(r chi.Router) {
    r.Get("/healthcheck", app.healthcheckHandler)
    r.Route("/tasks", func(r chi.Router) {
        r.Post("/", app.createTaskHandler)
        r.Get("/{id}", app.showTaskHandler)
        r.Patch("/{id}", app.updateTaskHandler)
        r.Delete("/{id}", app.deleteTaskHandler)
    })
})

What this code says

  • The nested r.Route("/tasks", ...) glues /tasks in front of everything inside it, so r.Post("/", ...) registers POST /v1/tasks.
  • {id} is the path parameter readIDParam reads back out.
  • The inner r shadows the outer one. That is chi’s normal idiom, not a mistake.
  • Method and path together identify an endpoint: GET /v1/tasks/{id} and DELETE /v1/tasks/{id} are two different endpoints at the same address.
Note

GET /v1/tasks — the list — is not registered yet; it arrives in Chapter 9 (Listing at scale). Until then, asking for it gets chi’s built-in 405 Method Not Allowed: an Allow header naming the methods this path does support, and an empty body. An empty body from a JSON API looks alarming; it is chi answering before any of our handlers run.

Now exercise every failure you built for:

curl -d '{"title":"write chapter 8"}' localhost:4000/v1/tasks
curl -d '{"titel":"typo"}' localhost:4000/v1/tasks        # unknown key
curl -d '{"title":""}' localhost:4000/v1/tasks             # 422 validation map
curl -X PATCH -d '{"status":"done"}' localhost:4000/v1/tasks/1
curl localhost:4000/v1/tasks/999                           # 404

What each flag does, once, so you can read every curl line in this book:

Flag Means
-d '...' send this string as the request body — and, unless told otherwise, use POST
-X PATCH use this method instead of the default
-i print the response headers as well as the body
-H 'K: V' add a request header
-s silent: no progress meter (useful when piping into jq)
Common mistake

You’ll see: these commands work, and yet curl -d sends Content-Type: application/x-www-form-urlencoded — the header for HTML form submissions, not JSON. It means: readJSON never inspects the content type, so it succeeds by accident. Real clients send the right header, and Chapter 23 (Hardening the edge) adds code that cares about it. Fix: get into the habit now: curl -H "Content-Type: application/json" -d '{"title":"x"}' localhost:4000/v1/tasks.


7. Checkpoint: prove it works

Build first. From the project root:

go build ./...

No output means it compiled. Then run it and leave it running:

go run ./cmd/api

In a second terminal, walk the whole lifecycle. 1 — the crime, solved:

curl -i localhost:4000/v1/healthcheck

The first line is HTTP/1.1 200 OK, there is a Content-Type: application/json header, and the body is now built by writeJSON — four keys in alphabetical order, ending with a newline:

{"database":"up","environment":"development","status":"available","version":"0.1.0"}

2 — create:

curl -i -H "Content-Type: application/json" \
     -d '{"title":"write chapter 8","priority":"high"}' \
     localhost:4000/v1/tasks

You should see HTTP/1.1 201 Created and a Location: /v1/tasks/1 header (the number will be whatever id Postgres assigned). The body is one envelope with one key:

{"task":{"id":1,"created_at":"...","updated_at":"...","title":"write chapter 8",
"notes":"","status":"open","priority":"high","due_at":null,"version":1}}

That arrives as a single line; it is wrapped here to fit the page. The two timestamps are real RFC 3339 times from your database, so yours will differ. Everything else should match: notes is "" because you sent no notes and Go’s zero value for a string is the empty string; status is "open" because CreateTask does not insert a status, so Postgres applied the column default; due_at is null because the DueAt pointer was never set; and version starts at 1, as the migration’s DEFAULT 1 says.

3 — read it back, using the id from the Location header:

curl localhost:4000/v1/tasks/1

The same envelope, with HTTP/1.1 200 OK if you add -i.

4 — update part of it:

curl -X PATCH -H "Content-Type: application/json" \
     -d '{"status":"done"}' localhost:4000/v1/tasks/1

The response is the updated task: "status":"done", "version":2, a new updated_at, and every other field unchanged. The version bump is the optimistic-lock counter doing its half of the job.

5 — the four failures, each on one line:

curl -i -d '{"titel":"typo"}'  localhost:4000/v1/tasks
curl -i -d '{"title":""}'      localhost:4000/v1/tasks
curl -i -d '{"title":123}'     localhost:4000/v1/tasks
curl -i localhost:4000/v1/tasks/999

In order, you should get:

Request Status line Body
{"titel":"typo"} HTTP/1.1 400 Bad Request {"error":"body contains unknown key \"titel\""}
{"title":""} HTTP/1.1 422 Unprocessable Entity {"error":{"title":"must be provided"}}
{"title":123} HTTP/1.1 400 Bad Request {"error":"body contains incorrect JSON type for field \"title\""}
/v1/tasks/999 HTTP/1.1 404 Not Found {"error":"the requested resource could not be found"}

6 — delete:

curl -i -X DELETE localhost:4000/v1/tasks/1

HTTP/1.1 200 OK and {"message":"task successfully deleted"}. Run the same command again: now it is 404, because :execrows reported zero.

Checkpoint

Six responses, six different status codes: 200, 201, 400, 422, 404, and a second 404. If all six match, every piece of this chapter is wired correctly.

If you got something else:

What you got Cause Fix
curl: (7) Failed to connect to localhost port 4000 the server isn’t running, or it exited look at the first terminal; if it exited, the error is the last line it printed
500 on create, and a log line containing relation "tasks" does not exist migrations were never applied to this database make db/migrations/up, then retry
500 on create, and a log line containing connection refused Postgres isn’t up docker compose up -d db, wait a few seconds, retry
404 on create with {"error":"the requested resource could not be found"} the route block wasn’t saved, or the server wasn’t restarted save routes.go, stop the server with Ctrl-C, go run ./cmd/api again
405 with an empty body on GET /v1/tasks listing doesn’t exist yet correct — Chapter 9 adds it

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
./tasks.go:60:36: undefined: itoa Part 3 of helpers.go is missing add the two-line itoa function
./tasks.go:98:24: app.q.UpdateTask undefined (type *db.Queries has no field or method UpdateTask) the SQL is written but not generated make sqlc
"fmt" imported and not used you deleted var _ = fmt.Sprintf but kept the import put the line back, or drop the import
http: superfluous response.WriteHeader call from main.(*application).errorResponse (errors.go:31) in the server log a handler wrote two responses: you forgot a return after an error response add the return; see the pitfall below
panic: json: Unmarshal(non-pointer struct {...}) in the log, 500 to the client you passed input to readJSON instead of &input add the &
{"error":"body contains unknown key \"version\""} DisallowUnknownFields rejecting a key the struct doesn’t have either the client has a typo, or the field genuinely needs adding to input
{"error":"body must not be empty"} on a curl you thought had a body the shell ate your quotes, usually by mixing ' and " wrap the JSON in single quotes, use double quotes inside
A PATCH seems to do nothing and returns 200 you sent a key the struct has, with the same value it already had check version in the response: it increments on every successful write
422 with {"status":"must be one of: open, done, archived"} you sent a status the domain doesn’t have use one of the three; the database’s CHECK constraint would have rejected it anyway, with a much uglier message
Common mistake

You’ll see: everything looks fine, but the server log has http: superfluous response.WriteHeader call from ... and a task you expected to be rejected is in the database. It means: an error branch is missing its return. The client got the 422 (the first WriteHeader wins and the second is discarded), and then the handler carried on and ran the insert anyway. Fix: add the return. Then check the others: grep -n -A1 'Response(w, r' cmd/api/*.go and confirm every hit is followed by a return.


9. Pitfalls

  • json.NewEncoder(w).Encode(v) directly to the ResponseWriter. If encoding fails midway you’ve already sent a 200 status and half a body. Marshal to memory first (as writeJSON does); for our payload sizes the extra copy is irrelevant.

  • Validating before overlaying in PATCH — you must validate the merged result, or a valid request can produce an invalid row. Concretely: a request containing only {"notes":"x"} is a perfectly valid request, and if the row it lands on has an empty title (because a previous bug wrote one), validating only the input would let it through.

  • Forgetting return after an error response. The handler happily continues and writes a second response; Go’s HTTP server logs superfluous response.WriteHeader. Every error branch ends in return. Every one. Note the asymmetry that makes this worth being absolute about: the visible symptom is a stray log line, and the invisible one is a database write on a request you told the client you had rejected.

  • 404 vs 422 for bad IDs. /tasks/abc is a URL that names nothing → 404, not 400/422. Consistency here makes clients’ error handling sane. The rule to state out loud: the address is 404’s business; the body is 400 and 422’s business.

  • The 409 that cannot fire. As Part 6g explains, the printed handler compares against a version it fetched itself, so the “two terminals” demo produces two 200s. Do not spend an evening concluding your code is broken; the gap is in the book, and Exercise 3 closes it.

  • writeJSON’s return value is ignored in the task handlers. createTaskHandler and friends call app.writeJSON(...) without checking the error, while healthcheckHandler checks it. For these payloads — structs of strings, numbers and times — json.Marshal cannot fail, so nothing is lost today. It is worth knowing that the inconsistency is there, so you copy the checked version into any handler whose payload comes from somewhere less predictable.

  • emit_pointers_for_null_types bites both ways. DueAt is *time.Time in the generated struct because the column is nullable. That is why the overlay copies the pointer without a *. Chapter 9 hits the mirror image of this and gets a compile error about it; when you see cannot use x (variable of type string) as *string value, this is the setting responsible.


10. Check yourself — quiz

  1. Why does writeJSON marshal into a byte slice before touching the ResponseWriter, instead of encoding straight into it?
  2. DisallowUnknownFields turns a client’s typo into a 400. Whose bug was it before that line existed, and whose bug was it reported as?
  3. You need to detect pgx.ErrNoRows, and you need to read the .Offset field off a *json.SyntaxError. Which of errors.Is and errors.As goes with which, and why?
  4. A client sends {"title":""} to POST /v1/tasks. Which status comes back, and why is it not the other obvious candidate?
  5. GET /v1/tasks/abc returns 404. Trace the two function calls that produce it, and say why the book prefers 404 to 400 here.
  6. UpdateTask is a :one query. What does it mean when it returns pgx.ErrNoRows in updateTaskHandler, given that the handler already fetched the row successfully a few lines earlier?
  7. In the PATCH input struct, Title is a *string. What does nil mean, what does a pointer to "" mean, and which of JSON’s four ways of mentioning (or not mentioning) a field cannot be told apart?
  8. Which responses does this API write to the log, and what would it cost you to log the 4xx bodies too — or to send the 5xx details to the client?
Answers
  1. Because HTTP cannot be taken back. Encoding directly into w sends 200 OK and a partial body before you find out the encoding failed, leaving the client holding a successful-looking response containing broken JSON. Marshalling first means a failure happens while we can still choose to send a 500 instead. The cost is one extra copy of a small payload.

  2. It was always the client’s bug — they typed titel instead of title. But without the check the server answers 200 OK and saves a task with an empty title, so it looks like a server bug, and the ticket is filed against you. The line converts a silent data-loss bug into a loud, correctly-attributed error.

  3. errors.Is(err, pgx.ErrNoRows) for the sentinel: it is one specific known value, and Is compares identity through any wrapping. errors.As(err, &syntaxError) for the syntax error: it is a type you want an instance of, and As both tests the type and fills your variable so you can read .Offset. Rule of thumb: Is for “which error”, As for “give me the error”.

  4. 422 Unprocessable Entity, with {"error":{"title":"must be provided"}}. The other candidate is 400, which would be wrong here: the body was perfectly well-formed JSON and decoded without complaint. It failed a business rule, not a parsing rule. The distinction tells a client whether to fix their serialiser (400) or show a message next to a form field (422).

  5. readIDParam calls strconv.ParseInt("abc", 10, 64), which returns an error, so readIDParam returns errors.New("invalid id parameter"); the handler’s first if err != nil branch calls app.notFoundResponse. The reasoning: the URL is an address, and /v1/tasks/abc addresses nothing that could ever exist. Answering 404 for every “that address names nothing” case — bad format, deleted row, someone else’s row (Chapter 12) — gives clients one branch to write.

  6. It means zero rows matched WHERE id = $1 AND version = $7. Since we know the id exists, the version must have changed between our GetTask and our UpdateTask — someone else wrote first. The handler turns that into 409 Conflict. Note the scope honestly: because the version came from this handler’s own read, the window being protected is microseconds wide, not the human think-time in the chapter’s story.

  7. nil means “the client did not send this field, keep the current value”. A pointer to "" means “the client explicitly set the title to empty” — which the validator will then reject. The two that cannot be told apart are an absent field ({}) and an explicit null ({"title":null}): both decode to nil. That is why due_at can never be cleared once set.

  8. Only the 5xx path logs: serverErrorResponse calls logError before it responds. 4xx responses are not logged, because a client sending bad requests is not an incident, and logging every one of them hands anyone on the internet a way to fill your disk. Sending 5xx details to the client is the opposite mistake: pq: duplicate key value violates unique constraint "users_email_key" tells the caller your table names, your column names and your constraints — an information disclosure, and useless to them anyway.


11. Practice

Exercise 1 — Break the return, and watch what it costs (easy)

In createTaskHandler, delete the return on the line after app.failedValidationResponse(w, r, v.Errors). Rebuild and run. Count the rows in the table, POST a task with an empty title, and count again. Read both what curl shows you and what the server logs. Then restore the line and write the rule down in your own words.

Solution

Count before and after:

docker compose exec db psql -U taskd -d taskd -tAc "SELECT count(*) FROM tasks;"
curl -s -o /dev/null -H "Content-Type: application/json" \
     -d '{"title":""}' localhost:4000/v1/tasks
docker compose exec db psql -U taskd -d taskd -tAc "SELECT count(*) FROM tasks;"

With the return removed, the count goes up by one. The client still receives the 422 — the first WriteHeader wins, and everything written after it is discarded — but the handler carried on past the error branch, ran CreateTask, and stored the invalid row. The server log gains a line of the shape:

http: superfluous response.WriteHeader call from main.(*application).errorResponse (errors.go:31)

(the file and line will match your copy).

So the loud symptom is cosmetic and the quiet symptom is data corruption. That asymmetry is why the rule is absolute rather than stylistic: every error branch ends in return. Restore the line, rebuild, and confirm the count stays flat. Then audit the rest:

grep -n -A1 'Response(w, r' cmd/api/*.go

Every response call should be followed by return — except the ones that are the last statement in their function, where returning is what happens anyway.

Exercise 2 — Add PUT /v1/tasks/{id}/status (medium)

Add a small dedicated endpoint that sets only a task’s status, following the four-beat bar. Reuse the existing UpdateTask query — write no new SQL. It should return 200 with the updated task, 404 for an id that does not exist, and 422 for a status outside the permitted three.

Solution
// cmd/api/tasks.go — add this handler
func (app *application) setTaskStatusHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        app.notFoundResponse(w, r)
        return
    }

    var input struct {
        Status string `json:"status"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    v.Check(validator.PermittedValue(input.Status, data.Statuses...),
        "status", "must be one of: open, done, archived")
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    task, err := app.q.GetTask(r.Context(), id)
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows):
            app.notFoundResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    updated, err := app.q.UpdateTask(r.Context(), db.UpdateTaskParams{
        ID: task.ID, Title: task.Title, Notes: task.Notes,
        Status: input.Status, Priority: task.Priority, DueAt: task.DueAt,
        Version: task.Version,
    })
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows):
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }
    app.writeJSON(w, http.StatusOK, envelope{"task": updated}, nil)
}
// cmd/api/routes.go — add inside r.Route("/tasks", ...)
r.Put("/{id}/status", app.setTaskStatusHandler)

Verify:

curl -s -H "Content-Type: application/json" -d '{"title":"status drill"}' \
     localhost:4000/v1/tasks
# note the id in the response, then (using 2 as an example):

curl -s -X PUT -H "Content-Type: application/json" \
     -d '{"status":"done"}' localhost:4000/v1/tasks/2
# 200, and the returned task has "status":"done" with version incremented

curl -s -X PUT -H "Content-Type: application/json" \
     -d '{"status":"banana"}' localhost:4000/v1/tasks/2
# {"error":{"status":"must be one of: open, done, archived"}}

curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H "Content-Type: application/json" \
     -d '{"status":"done"}' localhost:4000/v1/tasks/999999
# 404

Two deliberate reuses: validator.PermittedValue and the existing UpdateTask. Worth saying out loud afterwards: this endpoint is not strictly necessary — PATCH already does it. Adding endpoints is cheap; removing them is not. In a real API you would ask whether the convenience earns a permanent line in the public spec.

Exercise 3 — The 409 that never fires (harder)

Part 4 promised: “two clients GET version 1; both PATCH; the second gets 409 Conflict”. Try to produce that 409 with the API exactly as this chapter built it. Then explain why you cannot, and fix it.

Solution

The attempt. Create a task, read its version in two terminals, PATCH in both:

curl -s -H "Content-Type: application/json" -d '{"title":"lost update"}' \
     localhost:4000/v1/tasks
# note the id, say 3

curl -s -o /dev/null -w 'first=%{http_code}\n'  -X PATCH \
     -H "Content-Type: application/json" \
     -d '{"title":"client A"}' localhost:4000/v1/tasks/3
curl -s -o /dev/null -w 'second=%{http_code}\n' -X PATCH \
     -H "Content-Type: application/json" \
     -d '{"title":"client B"}' localhost:4000/v1/tasks/3

Both print 200, and the task’s title is client B. A’s edit is gone, and nobody was told — which is exactly the lost update the chapter set out to prevent.

Why. updateTaskHandler’s input struct has five pointer fields and none of them is Version. The version it sends to UpdateTask came from its own GetTask, milliseconds earlier in the same request. So WHERE ... AND version = $7 can only catch a writer who lands between this handler’s read and its write. The client’s read — the one separated from the write by a human — never reaches the server at all.

The fix, in two parts. Add the field:

// cmd/api/tasks.go — updateTaskHandler's input struct
var input struct {
    Title    *string    `json:"title"`
    Notes    *string    `json:"notes"`
    Status   *string    `json:"status"`
    Priority *string    `json:"priority"`
    DueAt    *time.Time `json:"due_at"`
    Version  *int32     `json:"version"` // the version the client read
}

and use it when supplied:

// cmd/api/tasks.go — replace the UpdateTask call
expected := task.Version
if input.Version != nil {
    expected = *input.Version
}

updated, err := app.q.UpdateTask(r.Context(), db.UpdateTaskParams{
    ID: task.ID, Title: task.Title, Notes: task.Notes,
    Status: task.Status, Priority: task.Priority, DueAt: task.DueAt,
    Version: expected,
})

Version is *int32 because that is the type sqlc generated for the integer column, and a pointer so that “not sent” stays distinguishable from “sent as 0”.

Note that adding the struct field is not optional decoration: without it, DisallowUnknownFields answers 400 body contains unknown key "version", so the feature would be undiscoverable even if the comparison logic existed.

Verify:

# create, then read the version once — both "clients" now hold v=1
curl -s -H "Content-Type: application/json" -d '{"title":"lost update"}' \
     localhost:4000/v1/tasks
curl -s localhost:4000/v1/tasks/4      # read "version" out of the response, say 1

curl -s -o /dev/null -w 'first=%{http_code}\n'  -X PATCH \
     -H "Content-Type: application/json" \
     -d '{"title":"client A","version":1}' localhost:4000/v1/tasks/4
curl -s -o /dev/null -w 'second=%{http_code}\n' -X PATCH \
     -H "Content-Type: application/json" \
     -d '{"title":"client B","version":1}' localhost:4000/v1/tasks/4

Now the first prints 200 and the second prints 409, and the stored title is client A — B’s write was refused, not silently lost. That two-line change in the output is the whole lesson.

The design decision this leaves you. As written, the version is optional: clients that omit it get last-write-wins, clients that send it get conflict detection. Requiring it on every PATCH is the stricter choice and the right one for anything resembling a booking system. Whichever you pick, write it in the API’s documentation — Chapter 24 (Documenting the API) is where that contract gets published, and it currently describes the client-supplied version this exercise built.


12. FAQ

Why is this so much code just to read some JSON?

Because roughly eight lines of it read JSON and the other seventy answer the question “what do I tell the client when it isn’t JSON?”. You write those seventy once per project — in fact, most Go developers carry a version of readJSON from job to job. Compare it to the alternative: every handler doing its own decode and inventing its own error text, which is more total code, spread across more files, all of it slightly different.

Can I use json.Unmarshal instead?

You can, and for reading a config file or a webhook payload you already hold in memory, it is the right tool — Chapter 16 (Stripe II) uses it for exactly that. For request bodies the decoder wins for a specific reason: DisallowUnknownFields and the trailing-garbage check are methods on json.Decoder. With Unmarshal you would first read the entire body yourself (io.ReadAll), which is the memory bomb you were trying to avoid, and then reimplement both checks by hand.

What’s the real difference between 400, 404, 409 and 422?

They answer four different questions, and clients branch on them differently:

Code The question it answers What a client should do
400 Could I read the request at all? fix the code that built the request
404 Does this address name anything? check the id; stop retrying
409 Is your copy of this record current? re-fetch, re-apply, retry
422 Are the contents acceptable? show the user which field is wrong

Why write my own validator instead of using a library?

Mostly for the debugger. A tag-based library expresses rules as strings inside struct tags, read by reflection at runtime: a typo in a tag silently does nothing, a custom rule means learning a registration API, and stepping through a failure means stepping through someone else’s reflection code. The forty lines here are ordinary Go — if statements you can breakpoint, a map you can print. That trade flips at scale: fifty entities with deeply nested rules is where the library’s compactness starts to win. taskd has three.

Why does the API return {"task": {...}} instead of the task itself?

Three reasons, in ascending order of importance. It is self-describing, so a response tells you what it is. It matches the error shape, so {"error": ...} is not a special case. And it leaves room: next chapter adds {"tasks": [...], "metadata": {...}}, and because there was already a named key at the top level, no existing client breaks. Un-enveloping later is a breaking change; enveloping from the start is one map literal.

Do I have to send version on every PATCH?

Today you cannot — the input struct has no version field, and DisallowUnknownFields will answer 400 if you try. The chapter’s optimistic-lock story describes the design the SQL supports; Exercise 3 is where the handler catches up with it. This is a real gap in the original book rather than a subtlety you missed, and being able to spot the difference between “the code does not do this” and “I do not understand the code” is one of the more valuable things this chapter can teach you.


13. Where we are

A complete, defensive CRUD API for a single implicit user. Listing everything with SELECT * won’t survive contact with real data — next.

More precisely: POST, GET, PATCH and DELETE all work end to end against Postgres; every failure mode we enumerated at the start of the chapter now has its own status code and its own sentence; the healthcheck’s hand-written JSON is gone; and the version column is incrementing on every write, guarding a window narrower than the story that introduced it.

Still fake, or still missing: there are no users, so every task belongs to everyone (Chapter 10 adds users, Chapter 12 makes tasks belong to them); there is no way to list tasks (Chapter 9); and nothing stops a stranger from deleting your data, because there is no authentication yet (Chapter 11, Stateful tokens).

The repo as it now stands

taskd/
├── cmd/api/
│   ├── config.go
│   ├── db.go
│   ├── errors.go            ← REPLACED this chapter (the real one)
│   ├── healthcheck.go       ← REWRITTEN this chapter (writeJSON, 503 branch)
│   ├── helpers.go           ← NEW
│   ├── main.go
│   ├── middleware.go
│   ├── routes.go            ← four new routes
│   ├── server.go
│   └── tasks.go             ← NEW (four handlers)
├── internal/
│   ├── data/
│   │   └── tasks.go         ← NEW (ValidateTask, Statuses, Priorities)
│   ├── db/                  (generated by sqlc — never edited)
│   │   ├── db.go
│   │   ├── models.go
│   │   └── tasks.sql.go     ← regenerated: DeleteTask, UpdateTask
│   └── validator/
│       └── validator.go     ← NEW
├── migrations/
│   ├── 000001_create_tasks.up.sql
│   └── 000001_create_tasks.down.sql
├── sql/queries/
│   └── tasks.sql            ← two new queries
├── config.toml
├── docker-compose.yml
├── Makefile
├── go.mod
└── go.sum

For your notes

Five things worth copying into learnings/ch08.md by hand:

  1. The four-beat bar. Decode → validate → query → respond, and each beat owns a status code: 400, 422, 500, 2xx. Every write handler in the rest of this book is this shape.
  2. 5xx logs the detail and tells the client nothing; 4xx tells the client everything and needn’t log. One funnel (errorResponse) enforces it, so the rule cannot drift endpoint by endpoint.
  3. Marshal before you write. Once WriteHeader has run, the status is a promise you cannot take back, so find out whether the body is valid while you still have a choice.
  4. A pointer field is how JSON says “absent”. nil means the client did not mention it; a pointer to the zero value means the client set it to empty. null and absent remain indistinguishable, which is a documented limitation, not a bug you introduced.
  5. WHERE version = $7 plus “zero rows updated” is optimistic locking — but only if the version being compared came from the client. In this chapter’s code it comes from the server’s own read, so the guard is narrower than the story. Knowing the difference is the point.

Chapter 9 — Listing at scale: filtering, sorting, pagination

Chapter 8 gave taskd four endpoints that each deal with one task. This chapter builds the fifth, and it is a different kind of animal: GET /v1/tasks has to answer questions about many tasks at once — which ones, in what order, how many at a time, and how many there are in total. That turns out to require a surprising amount of care, because every one of those four questions arrives as text typed by a stranger, and one of them cannot be answered the obvious way at all.

What you’ll be able to do by the end

  • Ask the API for a filtered, sorted, paged slice of tasks with one URL, and get back both the page and a metadata block saying which page it is and how many pages exist.
  • Explain, out loud, why ORDER BY $1 is illegal in every SQL database and every language, and what a CASE ladder does about it.
  • Reject a hostile query string — a ten-million-row page, a sort key that isn’t real, a status that doesn’t exist — with a 422 and a field-by-field explanation.
  • Read one SQL query and point at the three separate tricks holding it together.
  • Say why an empty list must serialise as [] and never as null, and prove it in a terminal.

Time: ~50 minutes reading, ~35 minutes typing.

You need before starting: a working Chapter 8 (CRUD done properly) — the tasks table, the writeJSON/readJSON helpers, errors.go, the validator package, and the four task endpoints. Two commands prove it:

curl -i localhost:4000/v1/healthcheck

You should see a first line reading HTTP/1.1 200 OK. Then:

curl -s -H "Content-Type: application/json" \
  -d '{"title":"chapter 9 warm-up"}' localhost:4000/v1/tasks

You should see a JSON object beginning {"task":{"id": — with whatever id Postgres assigned.


1. The problem, in plain words

Imagine a filing cabinet with forty thousand paper forms in it. Someone asks you for “the forms”. You do not empty the cabinet onto their desk. You ask three questions back: which ones, in what order, and how many at a time. And when you hand over the first twenty, you tell them how thick the stack was, so they know whether to expect one more handful or two thousand.

GET /v1/tasks as it stands does not ask any of those questions. It doesn’t exist yet at all — Chapter 8 built create, read-one, update and delete, and deliberately left the list for here, because listing is where a toy API and a real one visibly part company.

Consider what “return all the tasks” costs when the table is big:

  • The server builds the whole result in memory before sending it. Forty thousand rows of JSON is tens of megabytes, per request, per caller.
  • The network carries all of it, even though the caller will show twenty.
  • The client parses all of it. A phone will stutter; a browser tab will freeze.
  • The database reads every matching row from disk whether anyone looks at it or not.

None of that is hypothetical. It is the standard way a small service falls over on the day it stops being small — the response that was 4 KB in development is 40 MB in production, and the first symptom is that everything else gets slow too, because your server is busy serialising JSON nobody asked for.

So a list endpoint needs four capabilities, and they are the four questions from the filing cabinet:

Capability The URL says What it does
Filter ?status=open&priority=high&search=report narrows which rows
Sort ?sort=-created_at decides the order
Paginate ?page=2&page_size=20 takes a fixed-size slice
Count (nothing — it comes back automatically) says how big the whole stack was

The first three are instructions from the caller. The fourth is a courtesy the caller cannot compute for themselves: without a total, a user interface cannot draw “page 2 of 9”, cannot grey out the Next button on the last page, and cannot tell the difference between “no results” and “you have scrolled off the end”.

Everything in this chapter follows from those four, plus one uncomfortable fact: all of it arrives as text typed by someone you have never met, sitting inside the URL, and some of it has to end up inside a SQL statement.


2. New words in this chapter

Word What it means here
query string The ?status=open&page=2 part of a URL, carrying optional parameters. Everything after the ?, with & between pairs.
url.Values Go’s parsed form of a query string: a map from a key to a list of strings, because a key may legally appear more than once.
percent-encoding How characters that would confuse a URL (spaces, &, %) are written inside one: %20, %26, %25.
filter A condition that decides which rows come back at all.
sort key The name of the column (and direction) the caller wants results ordered by. Here: created_at, -created_at, due_at, -due_at, priority, with a leading - meaning descending.
pagination Returning results in numbered pages rather than all at once.
offset pagination LIMIT 20 OFFSET 40 — skip 40 rows, take 20. Supports “jump to page 7”; slows down on deep pages.
keyset pagination “Give me the next 20 after this item” — fast at any depth, but no page numbers.
LIMIT / OFFSET The two SQL clauses that implement offset pagination: how many, and how many to skip first.
O(1) “Big-O of one”: the cost does not grow as the data grows. O(n) means cost grows in step with the number of rows.
window function A SQL feature that computes a value across the whole result and attaches it to every row — here count(*) OVER(), the total count without a second query.
metadata Data about the response rather than the response itself: which page this is, how many there are.
bind parameter A $1-style placeholder in a query. The query text and the values travel separately, so a value can never become part of the statement.
SQL injection The attack where user text pasted into a query becomes part of the query. Bind parameters prevent it for values; a safelist is what protects everything else.
safelist (whitelist) A closed list of allowed values; anything not on it is rejected. Safer than listing what is banned.
query planner The part of Postgres that decides how to execute a query before it runs it.
nullable argument (sqlc.narg) An sqlc marker declaring that a parameter may be NULL; the generated Go field becomes a pointer, and nil means “not supplied”.
sqlc.embed An sqlc marker that groups a table’s columns into one nested struct field instead of flattening them.
CASE expression SQL’s inline if/else. CASE WHEN cond THEN x END yields x when the condition holds and NULL otherwise.
ILIKE Postgres’s case-insensitive “contains this text” match, where % means “any run of characters”.
NULLS LAST An ORDER BY instruction putting rows with no value at the bottom instead of the top.
total ordering An ordering with no ties left over, so the sequence of rows is fully determined. Achieved by ending ORDER BY with a unique column.
btree index Postgres’s default index type — good for equality and ranges, useless for “contains” searches.
GIN index / pg_trgm / tsvector Index types and extensions for real text search. Noted here as the upgrade path, not built.
EXPLAIN ANALYZE The Postgres command that shows how a query was actually executed and what each step cost.
DoS (denial of service) Making a service unavailable by overwhelming it — here, by asking for a ten-million-row page.
premature optimization Making something faster before you have evidence it is slow.
nil slice vs zero-length slice In Go, a slice variable that was never given a value (nil) and one made with length zero behave alike in code but encode differently in JSON: null versus [].
omitempty A struct-tag option telling Go’s JSON encoder to leave a field out entirely when its value is zero.
ceiling division Integer division that rounds up. Written (a + b - 1) / b.

That is a long list. None of it needs memorising now; each term is defined again where the chapter first leans on it.


3. The goal

GET /v1/tasks?status=open&priority=high&search=report&sort=-created_at&page=2&page_size=20 — with a metadata block (current page, total records, last page) in every list response, and safe handling of every hostile query-string value.

New word

query string — everything after the ? in a URL. It is a list of key=value pairs joined by &, and it is how a client passes optional instructions with a GET request, which has no body to put them in. The - in sort=-created_at is our own convention, not an HTTP one: it means “descending”.

Warning

In a shell, & means “run this in the background”. A URL with a query string must be quoted: curl 'localhost:4000/v1/tasks?page=2&page_size=20'. Without the quotes your terminal cuts the URL at the first & and you spend ten minutes wondering why page_size is being ignored.


4. The thinking

Three sub-problems, each with real solution paths.

4a. Pagination style: offset or keyset

There are two ways to hand out pages, and they fail in different directions.

New word

Offset paginationLIMIT 20 OFFSET 40: “skip the first 40 matching rows, then give me 20”. Page number times page size is the offset, so the client only has to know which page it wants. Keyset paginationWHERE created_at < $last: “give me the 20 that come after the last one I already have”. The client sends a bookmark instead of a page number.

Keyset is O(1) at any depth and stable under concurrent inserts — the right call for infinite feeds.

New word

O(1) (“big-O of one”) — the cost stays the same no matter how much data there is. A keyset query jumps straight to the bookmark using an index and reads 20 rows, whether the bookmark is at row 40 or row 4,000,000. O(n) would mean the cost grows in proportion to the row count. Stable under concurrent inserts means that if somebody adds a task while you are reading page 3, your next page does not shift by one and show you a row twice.

Offset degrades on deep pages — Postgres must produce and discard the skipped rows — but it supports “jump to page 7” and total counts, which task-manager user interfaces genuinely want.

Offset (LIMIT/OFFSET) Keyset (WHERE col < $last)
“Jump to page 7” yes no — you can only go next/previous
Total record count natural to include awkward; usually omitted
Cost at page 1 cheap cheap
Cost at page 50,000 slow — a million rows produced then thrown away identical to page 1
Row appears twice if data changes mid-scroll possible no
Client complexity a page number must carry the last row’s sort value

Personal task lists are thousands of rows, not billions: offset, with keyset noted as the upgrade path if a tasks-like table ever gets social-network big.

Remember this

Choose the pagination style that matches the user interface you owe your users, then write down the row count at which the choice stops being true. Ours is roughly “when a single user’s task list gets into the millions” — a threshold this product will never cross.

4b. The total count: two queries, or one clever one

The client needs total_records. There are two ways to get it.

A separate COUNT(*) query is the obvious one: run the filter twice, once counting and once fetching. That is two round trips to the database, and worse, the two queries see the database at two different moments — somebody can insert a row in between, and you return “20 of 100” from a page that was drawn against 99.

The window-function trick puts count(*) OVER() as an extra column on the same query: one round trip, count and page from the same snapshot.

New word

window function — a SQL function that computes a value across a whole set of rows and then attaches the answer to each row, instead of collapsing them into one row the way a plain count(*) does. OVER() with empty parentheses means “the window is the entire result set, after WHERE but before LIMIT”.

Think of it like

count(*) OVER() is a printing press stamping “page 2 of 7” onto every sheet as it comes off the roller. The press knows the total; each sheet carries it; nobody has to count the pile twice.

We take the window function and let sqlc’s sqlc.embed keep the row struct clean.

4c. Dynamic queries hit sqlc’s wall

Chapter 7 (sqlc) promised that this chapter would hit sqlc’s one hard limit on purpose. Here it is. Two dynamics are in play, and they are different.

Optional filtersstatus may or may not be present in the query string. This is solvable in pure SQL with the null-check idiom:

(param IS NULL OR column = param)

Read it as: “if the client didn’t filter on this, let everything through; otherwise match it.” sqlc’s sqlc.narg() makes the parameter nullable so Go can pass nil for “not supplied”. Postgres’ query planner handles this well at our scale.

New word

query planner — the part of Postgres that reads your SQL and decides how to execute it: which index to use, which order to join tables in, whether to sort in memory or on disk. It runs before a single row is touched.

Dynamic ORDER BY is the wall. A column name cannot be a bind parameter. That is SQL, not sqlc, and it is worth deriving rather than memorising.

New word

bind parameter — the $1, $2 placeholders in a query. You send the database two things: the query text, and separately a list of values. The database parses and plans the text once, then drops the values into the slots. Nothing in a value can ever become part of the statement.

Now the derivation. To plan a query, Postgres must already know its structure: which tables, which columns, which index might serve the sort. That happens before the values arrive. A bind parameter is a slot for a value, filled in after planning. A column name is structure, needed before planning. So ORDER BY $1 can never work — not in Go, not in Python, not with any library, not ever. And it does not fail the way you would want it to: depending on the driver you either get a type-inference complaint, or a query that runs happily and sorts every row by one constant string, so every row ties and the order is arbitrary. The one thing you will not get is a message saying “a column name cannot be a parameter”.

Remember this

A bind parameter holds a value, never a piece of the query’s structure. Column names, table names, ASC/DESC and LIMIT keywords are structure.

There are three escapes, and they are all legitimate:

Escape How it works Cost
(a) N copies of the query Write ListTasksByCreatedAtDesc, ListTasksByDueAtAsc… one per sort Explodes combinatorially: 5 sorts × 3 optional filters is unmaintainable
(b) CASE-per-sort-key One static query whose ORDER BY contains a CASE line per allowed sort; the sort key arrives as an ordinary value parameter Generally defeats index-based ordering; unfamiliar to read at first
© Hand-built SQL with pgx Drop out of sqlc for this one endpoint, build the ORDER BY string in Go from a safelist, run it with pgx directly You lose sqlc’s compile-time checking for that query, and own the string-building

We use (b) to stay inside sqlc and show the idiom; know that © is what many teams choose once sorts multiply, and it is not cheating — sqlc for the 95%, pgx for the 5% is a completely respectable architecture.

Safety rule regardless of path: sort values are checked against a safelist before they go anywhere near SQL. Never interpolate user input into an ORDER BY. Ever.

New word

safelist (also called a whitelist) — a closed list of the values you accept. Everything not on it is rejected. The opposite approach, listing what to block, fails the day someone invents an attack you didn’t think of.

Why that rule matters, concretely. Suppose escape © is built carelessly and the sort key is glued straight into the query text:

// NEVER DO THIS — shown once, so you recognise it in someone else's code
sql := "SELECT * FROM tasks ORDER BY " + userInput

A caller who sends ?sort=id; DROP TABLE tasks; -- has not sent a sort key. They have sent a second statement, and your program obediently pastes it into the query text and runs it. That is SQL injection: user text crossing the line from data to instruction.

New word

SQL injection — an attack where text supplied by a user is pasted into a SQL statement and becomes part of the statement instead of remaining a value. Bind parameters make it impossible for values. For anything that cannot be a bind parameter — a column name — a safelist is the only defence.

Think of it like

Injection is someone writing “Smith — and give the bearer everything in the safe” in the name box of a form, and a clerk who reads the whole line as instructions.


5. A picture of it

One request, end to end

What you’re looking at: a single GET travelling from the URL bar to Postgres and back, with the name of the thing that handles it at each stop.

  GET /v1/tasks?status=open&sort=-due_at&page=2&page_size=20
        │
        ▼
  r.URL.Query()             ─▶ url.Values — a map from key to a LIST of strings
        │
        ▼
  readString / readInt      ─▶ status="open"  sort="-due_at"  page=2  size=20
        │                      (defaults fill in whatever the client omitted)
        ▼
  validator + SortSafelist  ─▶ 422 + a field-by-field map if anything is wrong
        │
        ▼
  data.Filters              ─▶ Limit() = 20    Offset() = (2-1)*20 = 20
        │
        ▼
  db.ListTasksParams        ─▶ nil for absent filters, values for present ones
        │
        ▼
  ONE SQL query             ─▶ up to 20 rows, each carrying total_count
        │
        ▼
  envelope{tasks, metadata} ─▶ 200 OK
  1. r.URL.Query() parses the text after the ? into url.Values.
  2. Two small readers pull typed values out of it, substituting defaults for anything missing.
  3. Validation happens before any database work — a bad request costs no query.
  4. Filters converts a human page number into the LIMIT and OFFSET SQL wants.
  5. One query returns both the page and the total.
  6. The response carries the rows and the metadata as siblings inside the envelope.

Offset pagination, and what it throws away

  LIMIT 20 OFFSET 40      ("page 3, twenty per page")

    row 1  ─┐
     ...    │  Postgres PRODUCES these, then discards them
    row 40 ─┘
    row 41 ─┐
     ...    │  the 20 rows that reach the client
    row 60 ─┘
    row 61  ·  never looked at

  Cost grows with the OFFSET. Page 3 discards 40 rows; page 50,000
  discards 999,980.

  ── the upgrade path, for comparison ────────────────────────────────
  KEYSET:  WHERE created_at < $last ORDER BY created_at DESC LIMIT 20

    index seek straight to the bookmark ─▶ read 20 ─▶ done
    identical cost whether the bookmark is at row 40 or row 4,000,000

count(*) OVER(): the total, riding along

  the filtered set (101 rows)          the page returned (rows 41..60)
  ┌───────────────────────┐            ┌────┬─────────────┬─────────────┐
  │ every row matching    │            │ id │ title       │ total_count │
  │ the WHERE clause,     │  OVER()    ├────┼─────────────┼─────────────┤
  │ before LIMIT/OFFSET   │  ────────▶ │ 73 │ file taxes  │     101     │
  │                       │            │ 74 │ buy milk    │     101     │
  │      101 rows         │            │ …  │ …           │     101     │
  └───────────────────────┘            └────┴─────────────┴─────────────┘

  One query. One snapshot. Every row carries the same total, so the Go
  code reads it off whichever row it happens to be holding.

6. The steps

Five steps. One new file, two edits to existing files, one new SQL query, one new handler.

Step 1 — Filter plumbing in internal/data

Paging is arithmetic, and arithmetic belongs somewhere it can be tested without a database. This new file holds the shape of a page request, the rules it must obey, and the sums that turn a page number into SQL and back into a metadata block.

// internal/data/filters.go — new file
package data

import "github.com/yourname/taskd/internal/validator"

type Filters struct {
	Page         int
	PageSize     int
	Sort         string
	SortSafelist []string
}

func ValidateFilters(v *validator.Validator, f Filters) {
	v.Check(f.Page > 0, "page", "must be greater than zero")
	v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million")
	v.Check(f.PageSize > 0, "page_size", "must be greater than zero")
	v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100")
	v.Check(validator.PermittedValue(f.Sort, f.SortSafelist...),
		"sort", "invalid sort value")
}

func (f Filters) Limit() int32  { return int32(f.PageSize) }
func (f Filters) Offset() int32 { return int32((f.Page - 1) * f.PageSize) }

type Metadata struct {
	CurrentPage  int   `json:"current_page,omitempty"`
	PageSize     int   `json:"page_size,omitempty"`
	FirstPage    int   `json:"first_page,omitempty"`
	LastPage     int   `json:"last_page,omitempty"`
	TotalRecords int64 `json:"total_records,omitempty"`
}

func CalculateMetadata(total int64, page, pageSize int) Metadata {
	if total == 0 {
		return Metadata{}
	}
	return Metadata{
		CurrentPage:  page,
		PageSize:     pageSize,
		FirstPage:    1,
		LastPage:     int((total + int64(pageSize) - 1) / int64(pageSize)),
		TotalRecords: total,
	}
}

What this code says, line by line

  • Filters is one struct carrying everything about how the caller wants the list shaped, as opposed to which rows they want. Keeping the four together means one thing to pass around and one thing to validate.
  • SortSafelist []string lives in the struct rather than in the validator because different endpoints will one day allow different sorts. The list travels with the request it governs.
  • 10_000_000 — Go lets you put underscores in numbers purely for readability. It is the same value as 10000000; the compiler ignores the underscores. Use them on anything past four digits.
  • f.SortSafelist... — the three dots spread a slice into a variadic parameter. PermittedValue (from Chapter 8) is declared as PermittedValue[T comparable](value T, permitted ...T) bool, meaning it accepts any number of trailing arguments. The ... says “pass each element of this slice as a separate argument” rather than passing the slice itself.
  • func (f Filters) Limit() int32 — a method on Filters. The (f Filters) before the name is the receiver: inside the function, f is the value it was called on. It is a value receiver, not a pointer, because these methods only read.
  • int32 — not a typo for int. Postgres’ LIMIT and OFFSET arrive in the generated Go code as int32, so the conversion happens here, once, instead of at every call site.
  • (f.Page - 1) * f.PageSize — the entire translation from human page numbers to SQL. Page 1 skips nothing; page 2 with 20 per page skips 20. The - 1 is why users say “page 1” and databases say “offset 0”.
  • omitempty — a struct tag option. Struct tags are strings attached to fields that libraries read at runtime; encoding/json reads these. omitempty means “if this field holds its zero value, leave the key out of the JSON entirely”. So when CalculateMetadata returns the empty Metadata{}, every field is zero, every key is omitted, and the client receives {} — an honest “there is nothing to page through” rather than a block of misleading zeros.
  • int((total + int64(pageSize) - 1) / int64(pageSize))ceiling division. Go’s integer division rounds down: 101 / 20 is 5, which would hide the last record. Adding pageSize - 1 before dividing rounds up instead. The int64(...) conversions are there because total is an int64 and Go refuses to mix numeric types silently — a deliberate language choice that catches real bugs.

Work the arithmetic on paper once and you will never doubt it again:

  total = 101, pageSize = 20

  plain division:    101 / 20        = 5   ← record 101 has nowhere to live
  ceiling division: (101 + 19) / 20
                   = 120 / 20        = 6   ← pages 1..5 hold 20, page 6 holds 1

  and on an exact fit of 100 records:
  ceiling division: (100 + 19) / 20
                   = 119 / 20        = 5   ← still 5. The +19 never over-counts.

The shape of a list response

What you’re looking at: the JSON the finished endpoint produces for page 2 of a 101-record result, with each metadata field labelled.

{
  "metadata": {
    "current_page":   2,    ← the page you asked for
    "page_size":     20,    ← how many per page
    "first_page":     1,    ← always 1; there so a client needn't hardcode it
    "last_page":      6,    ← ceil(101 / 20)
    "total_records": 101    ← count(*) OVER(), measured before LIMIT
  },
  "tasks": [ {…task…}, {…task…}, … 20 of them … ]
}

That metadata key is the sibling Chapter 8 promised when it argued for the envelope. This is what the envelope bought: a place to add a second top-level key without breaking a single existing client.

Note

first_page is always 1, and it is emitted. Chapter 24 (OpenAPI) documents this response and its schema must list all five fields, not four — a spec that omits first_page describes a response the server does not send.

Checkpoint

Run go build ./cmd/api — it should print nothing. filters.go compiles on its own; nothing calls it yet.

Step 2 — Query-string readers appended to helpers.go

The handler needs to pull typed values out of a query string, with defaults, without repeating itself five times. Two small functions do it. Append them to the bottom of cmd/api/helpers.go.

// cmd/api/helpers.go — append these two functions
func (app *application) readString(qs url.Values, key, def string) string {
	if s := qs.Get(key); s != "" {
		return s
	}
	return def
}

func (app *application) readInt(qs url.Values, key string, def int,
	v *validator.Validator) int {

	s := qs.Get(key)
	if s == "" {
		return def
	}
	i, err := strconv.Atoi(s)
	if err != nil {
		v.AddError(key, "must be an integer value")
		return def
	}
	return i
}

Both functions need imports helpers.go does not have yet. This is the complete import block after the change — strconv and the rest were already there from Chapter 8; the two marked lines are new:

// cmd/api/helpers.go — the import block, replacing the Chapter 8 version
import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"  // NEW — url.Values
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"

	"github.com/yourname/taskd/internal/validator" // NEW — *validator.Validator
)

What this code says, line by line

  • url.Values is Go’s parsed query string. It is defined as map[string][]string — a map from a key to a list of strings, because ?status=open&status=done is legal HTTP.
  • qs.Get(key) returns the first value for that key, or the empty string if the key is absent. It never returns an error and never panics on a missing key.
  • if s := qs.Get(key); s != "" — Go’s if-with-initialiser. It runs qs.Get, stores the result in s, then tests it. s exists only inside the if. This is idiomatic Go for “fetch, then check”, and keeps short-lived variables from leaking into the rest of the function.
  • An empty value counts as absent. ?sort= gets the default, same as omitting sort entirely. That is a deliberate simplification: a client that sends an empty string almost always means “never mind”.
  • strconv.Atoi(s) — “ASCII to integer”. It converts a string to an int and returns (int, error). "20" gives 20, nil; "abc" gives 0 and a non-nil error.
  • v.AddError(key, ...) then return def — on bad input the function records the problem and returns the default. It does not stop the world. The handler carries on collecting every other problem, checks v.Valid() once, and reports all of them together. That is the Chapter 8 validator’s whole design: one round trip tells the client everything that is wrong.
  • func (app *application) — both are methods on application even though neither touches app. That is house style: helpers hang off application so they are reachable from any handler without a package-level import, and so that one day they can use the logger without a signature change.
Common mistake

You’ll see: ./cmd/api/helpers.go:126:35: undefined: url It means: you added the functions but not the import. Go has no implicit lookup — a file that names a package must import it. (Every Go error starts with file:line:column; yours will name whatever line your code landed on, so match on the message after the colons, not the numbers.) Fix: add "net/url" and the internal/validator line to the import block above. An editor with gopls installed does this on save.

Step 3 — The one big query, using both idioms

Add this to the bottom of sql/queries/tasks.sql, next to the four queries from Chapter 8.

-- sql/queries/tasks.sql — append this query
-- name: ListTasks :many
SELECT sqlc.embed(tasks), count(*) OVER() AS total_count
FROM tasks
WHERE (sqlc.narg('status')::text   IS NULL OR status   = sqlc.narg('status'))
  AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
  AND (sqlc.narg('search')::text   IS NULL
       OR title ILIKE '%' || sqlc.narg('search') || '%')
ORDER BY
  CASE WHEN sqlc.arg('sort')::text = 'created_at' THEN created_at END ASC,
  CASE WHEN sqlc.arg('sort')::text = '-created_at' THEN created_at END DESC,
  CASE WHEN sqlc.arg('sort')::text = 'due_at'      THEN due_at END ASC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = '-due_at'     THEN due_at END DESC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = 'priority'    THEN priority END ASC,
  id ASC
LIMIT sqlc.arg('page_limit') OFFSET sqlc.arg('page_offset');

Unpack the three tricks, top to bottom.

count(*) OVER() is a window function: it computes the count over the whole filtered result set and attaches it to every row as an extra column — so each row carries both itself and the total, from one query, one snapshot.

sqlc.narg('status') declares a nullable argument, and the idiom (param IS NULL OR column = param) reads as “if the client didn’t filter on this, let everything through; otherwise match it” — optional filters in pure, static SQL.

New word

sqlc.narg — “nullable argument”. sqlc.arg('x') declares a normal parameter; the extra n tells sqlc the parameter may be NULL, so the generated Go field is a pointer (*string) rather than a plain string. nil on the Go side becomes NULL on the SQL side.

New word

::text — a Postgres cast: “treat this as type text”. It is on the parameter because a bare $1 IS NULL gives Postgres nothing to infer a type from, and it would refuse to plan the query. The cast answers the question once, and every later use of the same named parameter inherits it.

The CASE ladder is the ORDER BY escape hatch: each line says “if the sort key is X, order by this column, else contribute nothing” — exactly one line fires per request, chosen by an ordinary bind parameter, which is legal where ORDER BY $1 is not.

Here is that claim made visible. What you’re looking at: the same six ORDER BY lines, evaluated for a request with ?sort=-due_at.

  request:  ?sort=-due_at        →  the bound value for 'sort' is '-due_at'

  ORDER BY
    CASE WHEN sort = 'created_at'  THEN created_at END ASC       → NULL
    CASE WHEN sort = '-created_at' THEN created_at END DESC      → NULL
    CASE WHEN sort = 'due_at'      THEN due_at END ASC  NULLS LAST → NULL
    CASE WHEN sort = '-due_at'     THEN due_at END DESC NULLS LAST → due_at ◀
    CASE WHEN sort = 'priority'    THEN priority END ASC         → NULL
    id ASC                                                       → tiebreak

    ◀ = the one line that fires for this request

  A CASE with no matching WHEN and no ELSE yields NULL — for EVERY row.
  Sorting by a column where every row is NULL changes nothing: all rows
  tie, and the ordering falls through to the next line. Five lines tie,
  one line decides, and id ASC settles anything still level.
New word

CASE WHEN … THEN … END — SQL’s inline if. With no ELSE, the result is NULL whenever no WHEN matches. NULLS LAST puts rows whose sort column is NULL at the bottom; without it Postgres sorts NULL first on DESC, which would put every undated task above your urgent ones.

The trailing id ASC is not decoration: it makes every ordering total, so rows can’t shuffle between pages when the primary sort has ties. Unstable pagination is one of those bugs users report as “sometimes an item appears twice” and you chase for a week.

Think of it like

Sorting a deck of cards by suit alone leaves four piles that can be shuffled internally each time. Sorting by suit then rank leaves exactly one arrangement. id ASC is the rank.

Now regenerate the Go code:

make sqlc

sqlc generate prints nothing when it succeeds. It rewrites internal/db/tasks.sql.go, which you never edit by hand.

What sqlc generated, and why the handler looks the way it does. The original edition never printed this file, and the handler in Step 4 is unreadable without it. These are the three declarations that matter, taken from the generated output:

// internal/db/tasks.sql.go — GENERATED by sqlc; never edit. Shown here so
// you can read the handler in Step 4.
type ListTasksParams struct {
	Status     *string `json:"status"`
	Priority   *string `json:"priority"`
	Search     *string `json:"search"`
	Sort       string  `json:"sort"`
	PageOffset int32   `json:"page_offset"`
	PageLimit  int32   `json:"page_limit"`
}

type ListTasksRow struct {
	Task       Task  `json:"task"`
	TotalCount int64 `json:"total_count"`
}

func (q *Queries) ListTasks(ctx context.Context, arg ListTasksParams) ([]ListTasksRow, error)
  • The three sqlc.narg parameters became *string — pointers. That is emit_pointers_for_null_types: true in sqlc.yaml (Chapter 7) doing what it promised: a nullable column or parameter becomes a pointer, and nil means NULL.
  • sort, being a plain sqlc.arg, is a plain string. It can never be NULL, so it needs no pointer.
  • PageLimit and PageOffset are int32 — which is why Filters.Limit() and Filters.Offset() return int32.
  • ListTasksRow has a nested Task field. That is sqlc.embed(tasks): without it, sqlc would flatten all nine task columns plus total_count into one flat struct of ten fields, and the handler would have to rebuild a Task field by field. With it, you get a real db.Task you can hand straight to the JSON encoder.
  • The field order follows the parameter numbers sqlc assigned. It does not matter to us, because the handler builds the struct with field names rather than positionally.
Common mistake

You’ll see: ./cmd/api/tasks.go:141:22: app.q.ListTasks undefined (type *db.Queries has no field or method ListTasks) — with whatever line number your call happens to sit on. It means: you wrote the SQL but did not regenerate. internal/db still holds Chapter 8’s four functions. Fix: make sqlc, then rebuild. If sqlc itself errors, read its message: it points at the line in tasks.sql it could not parse.

Step 4 — The handler

Now the piece that ties the previous three together. Add this function to cmd/api/tasks.go; it needs no new imports, because net/http, data, db and validator all arrived in Chapter 8.

// cmd/api/tasks.go — add this handler
func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) {
	v := validator.New()
	qs := r.URL.Query()

	status := app.readString(qs, "status", "")
	priority := app.readString(qs, "priority", "")
	search := app.readString(qs, "search", "")

	f := data.Filters{
		Page:     app.readInt(qs, "page", 1, v),
		PageSize: app.readInt(qs, "page_size", 20, v),
		Sort:     app.readString(qs, "sort", "-created_at"),
		SortSafelist: []string{"created_at", "-created_at",
			"due_at", "-due_at", "priority"},
	}
	if status != "" {
		v.Check(validator.PermittedValue(status, data.Statuses...), "status", "invalid")
	}
	if priority != "" {
		v.Check(validator.PermittedValue(priority, data.Priorities...), "priority", "invalid")
	}
	data.ValidateFilters(v, f)
	if !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	rows, err := app.q.ListTasks(r.Context(), db.ListTasksParams{
		Status:     nilIfEmpty(status),
		Priority:   nilIfEmpty(priority),
		Search:     nilIfEmpty(search),
		Sort:       f.Sort,
		PageLimit:  f.Limit(),
		PageOffset: f.Offset(),
	})
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}

	// Each generated row is {Task, TotalCount} thanks to sqlc.embed —
	// peel the tasks out, and grab the count (identical on every row,
	// courtesy of the window function).
	tasks := make([]db.Task, 0, len(rows)) // 0-length, NOT nil: encodes as []
	var total int64
	for _, row := range rows {
		tasks = append(tasks, row.Task)
		total = row.TotalCount
	}

	app.writeJSON(w, http.StatusOK, envelope{
		"tasks":    tasks,
		"metadata": data.CalculateMetadata(total, f.Page, f.PageSize),
	}, nil)
}

with the tiny helper (helpers.go):

// cmd/api/helpers.go — append this function
func nilIfEmpty(s string) *string {
	if s == "" {
		return nil
	}
	return &s
}

(sqlc’s narg generates *string params because of emit_pointers_for_null_types.)

What this code says, line by line

  • v := validator.New() first, qs := r.URL.Query() second. The validator is created before anything is read, because readInt needs somewhere to put its complaints.
  • Defaults live at the call site: page 1, twenty per page, newest first. A caller who sends no query string at all gets a sensible list rather than an error.
  • SortSafelist is written out in the handler, next to the endpoint it governs. Compare it with the CASE ladder in Step 3 — the five entries must match the five WHEN branches exactly. Nothing in the compiler enforces that; Exercise 1 makes you feel why that matters.
  • if status != "" guards the PermittedValue check because an absent filter is legal. Only a supplied status has to be one of open, done, archived.
  • data.Statuses... / data.Priorities... are the package-level slices from Chapter 8’s internal/data/tasks.go, the same ones ValidateTask uses. One list, two callers, no drift.
  • if !v.Valid()failedValidationResponse returns 422 Unprocessable Entity with the whole error map, and — critically — return stops the handler. Every error branch in this book ends in return; forget it and Go writes a second response and logs superfluous response.WriteHeader.
  • nilIfEmpty(status) converts “the caller didn’t ask” from an empty string into a nil pointer, which is what the SQL’s IS NULL branch tests. This one-line function is the hinge the whole optional-filter design turns on.
  • r.Context() ties the query’s lifetime to the request: if the client hangs up, the query is cancelled instead of running on for nothing.
  • tasks := make([]db.Task, 0, len(rows)) — a slice with length zero and capacity len(rows). Length zero means it holds nothing yet; capacity means the memory for the coming appends is reserved in one go rather than grown repeatedly. The capacity is a small efficiency; the length-zero-not-nil part is the load-bearing bit, explained below.
  • for _, row := range rowsrange yields index and value; _ discards the index. Each iteration appends the embedded task and overwrites total. Overwriting looks wasteful and is correct: every row carries the same total, so the last write is as good as the first, and it costs one assignment.
  • envelope{...} is Chapter 8’s map[string]any. Two keys now instead of one.
Important

make([]db.Task, 0, len(rows)) versus var tasks []db.Task. Both give you a slice you can append to, and in Go code they behave identically. In JSON they do not. A nil slice encodes as null; a zero-length slice encodes as []. Every front end that writes data.tasks.map(...) crashes on null and copes fine with []. This is one character of difference in the source and a support ticket in production.

Why returning &s from nilIfEmpty is safe. s is a parameter — a local variable inside the function — and the function returns a pointer to it. In C that would be a dangling pointer, because the local disappears when the function does. In Go the compiler notices the pointer escapes and allocates s where it will outlive the call. You never think about it, and it is always correct.

Route. Add one line inside the /tasks group in cmd/api/routes.go:

// cmd/api/routes.go — the /v1 block after the change
r.Route("/v1", func(r chi.Router) {
	r.Get("/healthcheck", app.healthcheckHandler)
	r.Route("/tasks", func(r chi.Router) {
		r.Post("/", app.createTaskHandler)
		r.Get("/", app.listTasksHandler) // NEW — the list endpoint
		r.Get("/{id}", app.showTaskHandler)
		r.Patch("/{id}", app.updateTaskHandler)
		r.Delete("/{id}", app.deleteTaskHandler)
	})
})

r.Get("/", ...) inside r.Route("/tasks", ...) registers GET /v1/tasks. It sits beside r.Post("/", ...) — same path, different method, different handler. That is what HTTP methods are for.

Step 5 — Verify

Verify with the query string from the goal, and confirm tasks: [] (not null) on an empty page — that’s what the make([]db.Task, 0, ...) preallocation guarantees, and frontend developers will silently bless you for it. The next section walks it command by command.


7. Checkpoint: prove it works

Start the server in one terminal (make run/api) and run these in another.

# 1. it compiles
go build ./cmd/api && echo BUILD-OK

You should see BUILD-OK.

# 2. start from a known state, then create exactly 25 tasks
docker compose exec db psql -U taskd -d taskd -c "TRUNCATE tasks;"
for i in $(seq 1 25); do
  curl -s -o /dev/null -H "Content-Type: application/json" \
    -d "{\"title\":\"task $i\"}" localhost:4000/v1/tasks
done
echo seeded

TRUNCATE tasks empties the table — every task you made in Chapter 8 and the warm-up above is deleted, which is why we do it on a development database and nowhere else. psql answers TRUNCATE TABLE. Then -o /dev/null throws the response bodies away and -s silences curl’s progress meter, so the loop finishes with a single line reading seeded.

Tip

Chapter 8’s curl examples left out -H "Content-Type: application/json" and worked anyway, because nothing in taskd checks the header yet. Send it anyway. The habit costs nothing and stops a mysterious failure the day a content-type check is added.

# 3. the metadata block, on a table of exactly 25 tasks
curl -s 'localhost:4000/v1/tasks?page=2&page_size=20' | jq '.metadata'

jq is the JSON tool installed in Before you begin; jq '.metadata' prints that one key. You should see:

{
  "current_page": 2,
  "page_size": 20,
  "first_page": 1,
  "last_page": 2,
  "total_records": 25
}
# 4. page 2 holds the remaining five
curl -s 'localhost:4000/v1/tasks?page=2&page_size=20' | jq '.tasks | length'

You should see 5.

# 5. the query string from the goal
curl -s 'localhost:4000/v1/tasks?status=open&sort=-created_at&page=1&page_size=5' \
  | jq -c '{got: (.tasks | length), total: .metadata.total_records}'

You should see {"got":5,"total":25} — all 25 seeded tasks are open (the column’s default), so the filter excludes nothing and the count is unchanged.

# 6. a sort key that is not on the safelist
curl -s 'localhost:4000/v1/tasks?sort=password' | jq -c '.error'

You should see {"sort":"invalid sort value"}.

# 7. the self-service DoS attempt
curl -s 'localhost:4000/v1/tasks?page_size=10000000' | jq -c '.error'

You should see {"page_size":"must be a maximum of 100"}.

# 8. the empty-table case — [] and not null
docker compose exec db psql -U taskd -d taskd -c "TRUNCATE tasks;"
curl -s localhost:4000/v1/tasks | jq -c '.'

You should see exactly {"metadata":{},"tasks":[]}. Two things to notice: tasks is [], not null — that is the zero-length slice — and metadata is {} rather than a block of zeros, because CalculateMetadata returns the empty struct and every field carries omitempty.

If you got something else

You got Cause Fix
{"error":"the requested resource could not be found"} The route was never registered, or r.Get("/", …) was written outside the /tasks group Re-check Step 4’s routes block; the new line must sit inside r.Route("/tasks", …)
"tasks": null You wrote var tasks []db.Task instead of make([]db.Task, 0, len(rows)) Restore the make form — see the IMPORTANT callout in Step 4
Every list comes back empty even though rows exist An absent filter is being sent as "" rather than nil; status = '' matches nothing Check that all three filters go through nilIfEmpty
{"page_size":"must be a maximum of 100"} when you sent nothing Your shell ate the URL at the & and an earlier command’s variable leaked in Quote the whole URL: curl 'localhost:4000/...'
ERROR: relation "tasks" does not exist in the server log, 500 at the client The migration never ran against this database make db/migrations/up, then re-seed

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: ./cmd/api/tasks.go:141:22: app.q.ListTasks undefined (type *db.Queries has no field or method ListTasks) It means: the SQL exists but internal/db was not regenerated, so the typed Go function does not exist yet. Fix: make sqlc. Make this reflex automatic: every edit to a .sql file is followed by make sqlc before go build.

Common mistake

You’ll see: ./cmd/api/tasks.go:150:15: cannot use status (variable of type string) as *string value in struct literal It means: you passed the plain string into a field sqlc declared as a pointer, because sqlc.narg plus emit_pointers_for_null_types makes nullable parameters *string. Fix: wrap it — Status: nilIfEmpty(status).

The compiler caught that one for you. The next one is the same confusion made in the other direction, and the compiler cannot help at all — the types are right and the behaviour is wrong.

Common mistake

You’ll see: 200 OK with {"metadata":{},"tasks":[]} on every request, even though psql shows plenty of rows. It means: an empty filter is reaching SQL as '' rather than NULL. The idiom’s first branch ($1::text IS NULL) is false, so the second (status = '') runs, and no task has an empty status. Fix: every optional filter goes through nilIfEmpty. There is no error message for this one — it is silent, and it is the single most infuriating bug in the chapter.

Three more, in table form. The first two produce no error at all.

Symptom What it means Fix
?sort=title returns 200 and rows in id order, after you added "title" to the safelist The safelist and the CASE ladder are out of step. No WHEN matched, every CASE yielded NULL, all rows tied, and id ASC decided everything Add the matching CASE line to tasks.sql and make sqlc. Exercise 1 walks it
?status=open&status=done filters by open only url.Values.Get returns the first value and drops the rest Decide the rule and document it. Silently ignoring extra values is a defensible choice; not knowing you made it is not
{"error":{"status":"invalid"}} and you cannot tell what is valid The handler’s message is the literal string "invalid" This is the original’s own wording and we keep it. Note the contrast with ValidateTask’s "must be one of: open, done, archived" — the friendlier message is the better model for anything you add

9. Pitfalls

  • CASE-sort and indexes. The CASE idiom generally defeats index-based ordering, so Postgres sorts the filtered set in memory. For per-user task lists (thousands of rows) this is nothing; for millions of rows you’d move to path © with real ORDER BY clauses and matching indexes. Know which regime you’re in — EXPLAIN ANALYZE is how.

    New word

    EXPLAIN ANALYZE — put those two words in front of any statement in psql and Postgres runs it and then prints the plan it used, with the real time and row count for each step. Look for a step whose name begins Sort and check whether it says Sort Method: quicksort (in memory, fine) or external merge Disk: (spilling to disk, not fine). It is the only honest way to answer “is this slow?” — guessing is not a method.

  • ILIKE '%x%' can’t use a btree index. At scale, add a pg_trgm GIN index or Postgres full-text search (tsvector). We note it, and deliberately don’t build it — premature optimization is how todo apps grow Elasticsearch clusters.

    New word

    btree index — Postgres’ default index, a sorted tree. It can find title = 'x' or title > 'm' instantly, because sorted order tells it where to look. '%x%' has no anchor at the start of the string, so sorted order tells it nothing and every row must be examined. GIN index with the pg_trgm extension indexes every three-letter fragment, which makes “contains” searches fast. tsvector is full-text search proper: words, stems and ranking. Both are the right answer eventually and the wrong answer today.

  • Unbounded page_size. Without the <=100 check, ?page_size=10000000 is a self-service DoS endpoint. Filters validation is a security control, not politeness.

    New word

    DoS (denial of service) — making a service unavailable to real users by overwhelming it. It does not require a botnet. One caller in a loop asking for ten million rows will do, and because the request is perfectly legal your logs will show nothing but slow 200s.

  • count(*) OVER() cost. The count is computed over the filtered set each call — fine here, but on huge tables teams cache counts or return them approximately. Another “know the upgrade path, don’t take it yet”.

  • A bind parameter stops injection, not surprises. ?search=%25 is percent-encoding — the scheme URLs use to carry characters that would otherwise confuse them, where %25 means a literal %, %20 a space, %26 an ampersand. Go decodes it before your handler sees it, so search becomes a single percent sign, which is the SQL wildcard, so the pattern becomes %%% and matches every task. That is not an injection — the query text never changed, the value stayed a value — but it is a semantic leak, and _ (matching any single character) does the same quietly. If it matters to you, escape % and _ in Go before binding. “It’s parameterised, so it’s safe” is true about injection and false about meaning.


10. Check yourself — quiz

  1. Give one advantage of offset pagination over keyset, and one of keyset over offset.
  2. What exactly does count(*) OVER() count — the rows on this page, or something else? And what does it save you?
  3. What does the n in sqlc.narg stand for, and what does it change about the generated Go struct?
  4. Why can ORDER BY $1 never work, in any database, with any library? Answer from what a bind parameter is.
  5. Delete the trailing id ASC from the query. Nothing errors. What goes wrong, and when does the user notice?
  6. Suppose nilIfEmpty were changed to always return &s, even for the empty string. The code compiles. What does GET /v1/tasks return, and why?
  7. CalculateMetadata(101, 1, 20) — what is LastPage, and why is the + pageSize - 1 there?
  8. A caller sends ?page_size=10000000. Trace what happens, and say why this is described as a security control rather than input tidiness.
Answers
  1. Offset lets a client jump straight to page 7 and lets you return a total record count, both of which a paged user interface needs. Keyset costs the same at page 50,000 as at page 1, and cannot show a row twice when data is inserted mid-scroll. Offset’s weakness is that Postgres must produce and discard every skipped row; keyset’s is that “page 7” is not expressible.

  2. It counts every row matching the WHERE clause — the whole filtered set, before LIMIT and OFFSET cut it down. It saves a second round trip to the database and the risk that the count and the page disagree, because both come from one execution of one query against one snapshot.

  3. “Nullable”. sqlc.arg('x') produces a plain field; sqlc.narg('x') tells sqlc the parameter may be NULL, and with emit_pointers_for_null_types: true the generated field becomes a pointer (*string). nil on the Go side is NULL on the SQL side, which is what the (param IS NULL OR column = param) idiom tests.

  4. A bind parameter is a slot for a value, filled in after the database has parsed and planned the statement. Planning requires knowing the query’s structure — which columns, which index might serve the sort — and a column name is structure. The value arrives too late to be structure, so no library can make it work. Worse, you rarely get a clear error: either the driver complains it cannot infer the parameter’s type, or the query runs and sorts every row by one constant string, so every row ties and the order is arbitrary.

  5. Ordering by created_at alone leaves ties, and rows that tie may come back in any order — a different order on each query, since Postgres makes no promise about unspecified ordering. Page 1 and page 2 are two separate queries, so a tied row can appear on both, or on neither. Users report it as “sometimes an item shows up twice, sometimes one goes missing”, it does not reproduce on your five-row dev database, and you lose a week.

  6. It returns {"metadata":{},"tasks":[]} for every request. All three filters would arrive as pointers to empty strings, so the IS NULL branch is false and the second branch runs: status = '', priority = '', and title ILIKE '%%'. The first two match no row (every task has a real status and priority), so the AND chain excludes everything. No error, no log line — only an API that insists you have no tasks.

  7. LastPage is 6. Go’s integer division rounds down, so 101 / 20 would give 5 and record 101 would have no page to live on. Adding pageSize - 1 first turns division-rounding-down into division-rounding-up: (101 + 19) / 20 = 120 / 20 = 6. It never over-counts on an exact fit, because (100 + 19) / 20 is still 5.

  8. readInt parses it fine (it is a valid integer), so it reaches ValidateFilters, where f.PageSize <= 100 fails, page_size is added to the error map, and the handler returns 422 with {"error":{"page_size":"must be a maximum of 100"}}before any query runs. It is a security control because without it a single caller in a loop makes Postgres read and your server serialise ten million rows per request, which is a denial of service that needs no botnet, breaks no rule, and shows up in your logs as ordinary slow 200s.


11. Practice

Exercise 1 — Add a title sort, the wrong way first (easy)

The safelist and the CASE ladder are two lists that must agree, and nothing in the toolchain checks that they do. Feel the failure before you fix it.

Task. Add "title" to listTasksHandler’s SortSafelist and nothing else. Rebuild, request ?sort=title, and write down exactly what you get. Then make it work properly, for both title and -title.

Answer

The broken half. With "title" on the safelist only, ?sort=title returns 200 OK and rows in id order — no error, no warning, silently the wrong ordering. Validation said “yes, that is a valid sort”, the query looked for a matching WHEN, found none, and every CASE evaluated to NULL for every row. All five sort expressions tied, and the trailing id ASC decided everything.

That is the nastiest class of bug: two layers that each behave reasonably and disagree with each other.

The fix. Both lists, in lockstep. In sql/queries/tasks.sql:

-- sql/queries/tasks.sql — the ORDER BY, with two new lines
ORDER BY
  CASE WHEN sqlc.arg('sort')::text = 'created_at' THEN created_at END ASC,
  CASE WHEN sqlc.arg('sort')::text = '-created_at' THEN created_at END DESC,
  CASE WHEN sqlc.arg('sort')::text = 'due_at'      THEN due_at END ASC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = '-due_at'     THEN due_at END DESC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = 'priority'    THEN priority END ASC,
  CASE WHEN sqlc.arg('sort')::text = 'title'       THEN title END ASC,   -- new
  CASE WHEN sqlc.arg('sort')::text = '-title'      THEN title END DESC,  -- new
  id ASC
LIMIT sqlc.arg('page_limit') OFFSET sqlc.arg('page_offset');

and in cmd/api/tasks.go:

// cmd/api/tasks.go — inside listTasksHandler
SortSafelist: []string{"created_at", "-created_at",
	"due_at", "-due_at", "priority", "title", "-title"},

Then make sqlc and rebuild.

Verify. Seed a few tasks with titles that are not in alphabetical order, then:

curl -s 'localhost:4000/v1/tasks?sort=title' | jq -r '.tasks[].title' | head -3
curl -s 'localhost:4000/v1/tasks?sort=-title' | jq -r '.tasks[].title' | head -3

The two lists should be alphabetical in opposite directions. Before the fix they were identical.

The lasting lesson is about where duplication is dangerous. The safelist exists for security; the ladder exists for behaviour; nothing ties them together. In a real codebase you would generate one from the other, or add a test asserting that every safelist entry actually changes the order. That test is worth writing when Chapter 20 (Testing) arrives.

Exercise 2 — Walk three pages and prove nothing is lost or repeated (medium)

Task. Empty the table, create exactly 45 tasks with distinct titles, then request pages 1, 2 and 3 at page_size=20. Confirm that metadata.last_page is 3, that the three pages hold 20, 20 and 5 tasks, and that no task id appears on two pages.

Answer
# start clean
docker compose exec db psql -U taskd -d taskd -c "TRUNCATE tasks;"

# 45 tasks
for i in $(seq 1 45); do
  curl -s -o /dev/null -H "Content-Type: application/json" \
    -d "{\"title\":\"task $i\"}" localhost:4000/v1/tasks
done

# the metadata on each page
for p in 1 2 3; do
  curl -s "localhost:4000/v1/tasks?page=$p&page_size=20" \
    | jq -c '{page: .metadata.current_page, last: .metadata.last_page,
              total: .metadata.total_records, got: (.tasks | length)}'
done

You should see three lines:

{"page":1,"last":3,"total":45,"got":20}
{"page":2,"last":3,"total":45,"got":20}
{"page":3,"last":3,"total":45,"got":5}

last_page is 3 because (45 + 19) / 20 = 64 / 20 = 3 under integer division.

Now the duplicate check. Collect every id from all three pages, count them, then count the distinct ones:

for p in 1 2 3; do
  curl -s "localhost:4000/v1/tasks?page=$p&page_size=20" | jq '.tasks[].id'
done | sort -n > /tmp/ids.txt

wc -l < /tmp/ids.txt        # how many ids came back
sort -u /tmp/ids.txt | wc -l # how many were distinct

Both numbers should be 45. If they differ, some task was served twice and another never — which is exactly the failure the trailing id ASC prevents. (sort -n sorts numerically; sort -u removes duplicates; wc -l counts lines.)

Exercise 3 — Add a due_before filter (harder)

Task. Add an optional ?due_before=2026-12-31T00:00:00Z filter returning only tasks whose due_at is earlier than the given time. Tasks with a NULL due_at must be excluded when the filter is present and included when it is absent. A malformed timestamp must produce a 422, not a 500.

Answer

Copy the sqlc.narg idiom exactly. In sql/queries/tasks.sql, add one clause to the WHERE:

-- sql/queries/tasks.sql — added to ListTasks' WHERE clause
  AND (sqlc.narg('due_before')::timestamptz IS NULL
       OR (due_at IS NOT NULL AND due_at < sqlc.narg('due_before')))

A new reader in cmd/api/helpers.go, following the shape of readInt:

// cmd/api/helpers.go — append this function (needs "time" in the imports)
func (app *application) readTime(qs url.Values, key string,
	v *validator.Validator) *time.Time {

	s := qs.Get(key)
	if s == "" {
		return nil
	}
	t, err := time.Parse(time.RFC3339, s)
	if err != nil {
		v.AddError(key, "must be an RFC3339 timestamp, e.g. 2026-12-31T00:00:00Z")
		return nil
	}
	return &t
}

And in the handler, read it and pass it through:

// cmd/api/tasks.go — inside listTasksHandler
dueBefore := app.readTime(qs, "due_before", v)
// ... after validation ...
rows, err := app.q.ListTasks(r.Context(), db.ListTasksParams{
	Status:     nilIfEmpty(status),
	Priority:   nilIfEmpty(priority),
	Search:     nilIfEmpty(search),
	DueBefore:  dueBefore,
	Sort:       f.Sort,
	PageLimit:  f.Limit(),
	PageOffset: f.Offset(),
})

Then make sqlc — the generated ListTasksParams gains a DueBefore *time.Time field, because timestamptz plus sqlc.narg plus emit_pointers_for_null_types gives a pointer.

Why due_at IS NOT NULL is not redundant paranoia. In SQL, NULL < '2026-12-31' evaluates to NULL, which is not TRUE, so those rows are excluded anyway. Writing it explicitly documents the intent — and it is exactly the kind of three-valued-logic detail that silently reverses behaviour when somebody later flips < to >= or wraps the clause in a NOT.

Verify.

docker compose exec db psql -U taskd -d taskd -c "TRUNCATE tasks;"
curl -s -o /dev/null -H "Content-Type: application/json" \
  -d '{"title":"soon","due_at":"2026-01-01T00:00:00Z"}' localhost:4000/v1/tasks
curl -s -o /dev/null -H "Content-Type: application/json" \
  -d '{"title":"later","due_at":"2027-01-01T00:00:00Z"}' localhost:4000/v1/tasks
curl -s -o /dev/null -H "Content-Type: application/json" \
  -d '{"title":"undated"}' localhost:4000/v1/tasks

curl -s 'localhost:4000/v1/tasks?due_before=2026-06-01T00:00:00Z' | jq -r '.tasks[].title'
# soon

curl -s 'localhost:4000/v1/tasks' | jq '.metadata.total_records'
# 3

curl -s 'localhost:4000/v1/tasks?due_before=nonsense' | jq -c '.error'
# {"due_before":"must be an RFC3339 timestamp, e.g. 2026-12-31T00:00:00Z"}

12. FAQ

Why can’t I build the SQL string myself? It would be five lines.

You can, and escape © in section 4c is exactly that — a respectable choice. What you cannot do is build it from user input. The moment any part of the query text comes from the request, you have to be right every single time, and the failure mode is not a bug report, it is someone reading your users’ data. If you go that route, the sort key still comes from a safelist first, and the built string contains only values you wrote yourself. The safelist is the security control; the string-building is a detail.

Is offset pagination bad? Everything online says use keyset.

Everything online is thinking about an infinite social feed, where nobody types a page number and the table has a billion rows. For a per-user task list, offset is correct: it is simpler, it gives you page numbers and a total, and the depth at which it hurts is a depth this product will never reach. The honest rule is that offset’s cost is proportional to the offset, so it degrades exactly where users go rarely. Know the threshold, write it in your notes, and revisit when the numbers say so — not when a blog post does.

Why is search only an ILIKE? Real search engines exist.

Because a LIKE on a few thousand rows is instant and costs nothing to build, while proper search costs an extension, an index, a re-index strategy and a new set of failure modes. The upgrade path is written down in the Pitfalls above so that the day the numbers justify it, you know precisely what to do. Building it now would be premature optimization — the process by which a todo app ends up operating an Elasticsearch cluster nobody remembers deciding to run.

Why cap page_size at 100? My client genuinely wants 500.

Then your client makes five requests, and your server stays up when somebody’s client asks for five million. A cap is not an opinion about legitimate use; it is a bound on what a single request can cost you. Pick the number deliberately (100 is a common, defensible choice), document it, and make the error message say the limit — which ValidateFilters does.

Why does an empty list have to be [] and not null? They mean the same thing.

They do not, to a client. [] is “a list, containing nothing” and every language iterates it happily. null is “no list at all”, and data.tasks.map(...) throws a TypeError on it, for task in data["tasks"] throws in Python, and a Go client decoding into a slice gets a nil it must special-case. One character in your handler decides which of those every consumer of your API lives with. Return the empty container.

This is a lot of machinery for “show me some tasks”. Is this really how real companies do it?

Yes, and usually with more. Filtering, sorting, paging and a total is the minimum an API needs before a real user interface can be built against it, and every piece here exists because of a specific failure: unbounded responses, unstable pagination, inconsistent totals, injection, and denial of service. What you should take from the length is not “this is heavy” but “list endpoints are where the hidden requirements live”. The code above is about eighty lines. The bugs it prevents have each cost somebody a week.


13. Where we are

A genuinely usable single-tenant task API. But it’s everyone’s task list — there are no users, no ownership, no security whatsoever. Part IV fixes that, then charges money for it.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go          # UPDATED: GET /v1/tasks
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go         # UPDATED: readString, readInt, nilIfEmpty
│   ├── errors.go
│   ├── healthcheck.go
│   └── tasks.go           # UPDATED: listTasksHandler
├── internal/
│   ├── data/
│   │   ├── tasks.go
│   │   └── filters.go     # NEW: Filters, ValidateFilters, Metadata
│   ├── db/                # sqlc output — tasks.sql.go REGENERATED
│   └── validator/
├── migrations/
├── sql/queries/
│   └── tasks.sql          # UPDATED: ListTasks
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: create, read, update, delete and now list a task — filtered by status, priority and title text, sorted five ways, paged, with a total count and page arithmetic the client can trust. Hostile query strings get a 422 with a field-by-field explanation and never reach the database.

What is still fake or missing:

  • There are no users. Anyone who can reach port 4000 sees and edits every task. Chapter 10 (Users and passwords) and Chapter 11 (Stateful tokens) build identity; Chapter 12 (Ownership) adds user_id to this very query, and the ListTasks written above gains a WHERE user_id = $1 as its first clause.
  • Nothing is cached. Chapter 13 (Caching) revisits listTasksHandler and puts a cache in front of it, keyed on exactly the filters you built here.
  • Search is free for everyone. Chapter 17 (Entitlements) makes it a paid feature by adding one gate to this handler.
  • Nothing limits how often a caller can list. Chapter 14 (Rate limiting) handles that.

For your notes

Copy these into learnings/ch09.md, in your own words:

  1. A bind parameter holds a value, never structure. ORDER BY $1 cannot work in any database with any library, because planning needs the column name before the values arrive. The CASE ladder gets around it by making the sort key an ordinary value.
  2. Anything that cannot be a bind parameter goes through a safelist. Never interpolate user input into an ORDER BY. Ever.
  3. Always end ORDER BY with a unique column. Without a total ordering, tied rows shuffle between queries and items appear on two pages — a bug that never reproduces on a small dev database.
  4. make([]T, 0, n) not var s []T for anything that becomes JSON. Zero-length encodes as []; nil encodes as null; front ends crash on the second one.
  5. Validation limits are security controls. page_size <= 100 is not tidiness — it is the difference between an endpoint and a denial-of-service tool anyone can point at you.

Chapter 10 — Users and passwords

Until now taskd has been a single shared notepad: anybody who reaches port 4000 owns every task in the database. This chapter adds the first half of the fix — people. We build a users table, a POST /v1/users endpoint that registers an account, and a small Go type that makes it structurally impossible to store or log a password by accident. Chapter 11 (Stateful tokens) adds the second half: proving on later requests that you are who you said you were.

What you’ll be able to do by the end

  • Register an account over HTTP and see it appear as a row in Postgres.
  • Look at that row and confirm your password is genuinely not in it.
  • Explain in one sentence each what a hash is, what a salt does, and why slow is the point.
  • Register bob@example.com, be refused Bob@Example.COM, and know which part refused you.
  • Read the four-line Go pattern that turns a raw Postgres constraint error into a polite 422.

Time: ~35 minutes reading, ~30 minutes typing.

You need before starting: a working Chapter 9 (Listing, filtering, pagination) — the task endpoints, readJSON/writeJSON, the validator package, a running database. Run curl -i localhost:4000/v1/healthcheck: the first line should read HTTP/1.1 200 OK. Then make db/psql, and at the taskd=# prompt type \dt — you should see a listing containing tasks and schema_migrations. Type \q to leave.


1. The problem, in plain words

You are about to ask strangers for a password. That is a bigger favour than it looks.

People reuse passwords. The one they type into your task manager is, for many of them, the one guarding their email, and through their email, everything else. So the question that decides this chapter is not “how do I check a password?” It is: when somebody eventually steals a copy of my database — and one day somebody will — what exactly have they got?

If the users table contains passwords, they have got your users’ bank accounts.

The way out is older than the web. Do not store the password. Store something derived from it that answers one narrow question — “is this the same string?” — and nothing else. That derived thing is a hash.

Think of it like

Hashing is a blender. Put fruit in, get a smoothie out; the same fruit always gives the same smoothie. Now get the fruit back. You cannot — not because the recipe is secret, but because the operation genuinely destroys the arrangement. That one-way-ness is the whole trick.

A second, smaller problem hides in the same table. Someone signs up as bob@example.com, comes back two months later, types Bob@Example.com, and gets a second account: two task lists, two subscriptions, one support ticket. To a computer those are different strings. To every mail server on earth they are the same mailbox.

Skip this chapter and nothing has an owner: Chapter 11 has nobody to authenticate, Chapter 12 (Ownership) has nobody to attach tasks to, Chapter 15 (Stripe I) has nobody to bill.


2. New words in this chapter

Word What it means here
plaintext A password exactly as typed. Never stored, never logged, never returned.
hash / one-way function A scrambler that is easy to run forwards and hopeless to reverse.
bcrypt The password-hashing function used here: deliberately slow, with a cost knob.
cost / work factor bcrypt’s tuning number. Each increment doubles the work. 12 here; 4 in tests.
salt Random data mixed into each hash so identical passwords produce different hashes.
rainbow table A precomputed dictionary of hash → password. Salting makes it useless.
argon2id / scrypt Modern alternatives to bcrypt, designed to also be memory-hungry.
memory-hard / GPU-resistant Needs lots of RAM, so attackers can’t run thousands of guesses in parallel on graphics cards.
constant-time comparison Comparing secrets so the answer takes the same time either way.
timing side-channel Learning a secret by measuring how long an answer takes.
citext A Postgres column type whose comparisons ignore capitalisation.
extension (Postgres) An optional add-on giving Postgres new types or functions.
bytea Postgres’ type for raw bytes rather than text. Maps to Go’s []byte.
UNIQUE constraint The database’s own guarantee that no two rows share a value.
race condition A bug that appears only when two things happen at once, in the wrong order.
SQLSTATE / 23505 Postgres’ five-character error codes; 23505 is “unique constraint violated”.
user enumeration Discovering which emails have accounts by watching how responses differ.
regular expression A compact pattern language for “does this string look like this shape?”
byte vs character Go’s len() counts bytes. é is two, most emoji four. bcrypt’s limit is in bytes.
PII Personally Identifiable Information — names, emails, addresses. Logging it accumulates legal risk.

3. The goal

A users table with case-insensitive unique emails, a POST /v1/users registration endpoint, and passwords hashed with bcrypt behind a small password type that makes it impossible to accidentally store or log plaintext.


4. The thinking

Hashing algorithm

The paths: bcrypt, scrypt, argon2id. Argon2id is the modern recommendation (memory-hard, GPU-resistant); bcrypt is the 25-year veteran with a work-factor knob and zero known practical breaks at sane costs. Both are correct answers. What’s incorrect is SHA-256 (fast hashes are the vulnerability — attackers want fast), homemade salting schemes, or storing hints.

Option Why you’d pick it Why not here
bcrypt, cost 12 25 years of attention, one obvious knob, package next to the standard library 72-byte input limit; not memory-hard
argon2id Current recommendation; memory-hard, so graphics cards help attackers less More parameters to get wrong; newer
scrypt Also memory-hard, well studied Same tuning burden, less momentum
SHA-256 / MD5 Fast Fast is the flaw. Speed is the attacker’s budget
Roll your own You will get it wrong. Everyone does
New word

memory-hard — computing the hash needs a large chunk of RAM, not only CPU cycles. A graphics card has thousands of tiny processors but not thousands of large private memories, so memory-hardness blunts the cheapest mass-guessing hardware.

We use bcrypt at cost 12 for its battle-years and stdlib-adjacent package (golang.org/x/crypto/bcrypt); switching to argon2id later only touches one file, because we’re about to wrap hashing in a type. One quirk to respect: bcrypt silently uses only the first 72 bytes of input — so we validate max 72 bytes rather than let long passphrases be silently truncated. Truncation is worse than refusal because it is invisible: someone typing a 100-character passphrase believes they did something strong, and bcrypt would ignore the last 28 characters without saying so.

Email uniqueness

User@x.com and user@x.com must be the same account. Three paths:

Option Guarantee Cost
Lowercase in Go before every write Works until one code path forgets — a script, a migration, next year’s teammate Free, fragile
Functional unique index on lower(email) Correct; the database enforces it Every query must remember lower()
citext column type The type compares case-insensitively, so = and UNIQUE both do One CREATE EXTENSION line

We take citext: the database enforces the invariant no matter which future code path writes.

Remember this

Put an invariant where code cannot forget it. A rule enforced in one function is a rule until someone writes a second function.

The password struct trick

Straight from Alex Edwards (author of Let’s Go Further, the influence named in the preface who gives this codebase its structure), and worth stealing everywhere: a type holding plaintext *string and Hash []byte, where the only way in is Set() (which hashes) and the only comparison is Matches(). The plaintext pointer exists solely so validation can check length before hashing; it never leaves the package, never gets a JSON tag, never appears in a log.

Note

In Go a name starting with a lowercase letter is unexported: code outside the package cannot read it, and — the load-bearing part — neither can encoding/json. So plaintext physically cannot be serialised into a response. Hash is capitalised because it must cross the package boundary: the handler passes it to the database.

What we consciously defer — briefly

Email verification and password reset need an SMTP pipeline and background workers, which deserve their own focused treatment. They get it in Chapter 21 (Background work and email) and Chapter 22 (Password reset); here we mark the seam (activated boolean, defaulted true) so bolting them on later is a migration plus one middleware check, not a redesign.


5. A picture of it

The whole chapter in one diagram: the left half is what we build now, the right half is what Chapter 11 will call.

      REGISTER (this chapter)              LOGIN (Chapter 11)
      ────────────────────────             ──────────────────
        "pa55word123"                        "pa55word123"
              │                                    │
              ▼                                    ▼
      ┌───────────────────┐              ┌───────────────────┐
      │ bcrypt, cost 12   │              │ bcrypt compare    │◀── stored
      │ + 16 random bytes │              │ (constant time)   │    hash
      │   of fresh salt   │              └─────────┬─────────┘
      └─────────┬─────────┘                        │
                │ $2a$12$… (60 bytes)              ▼
                ▼                             true / false
      ┌───────────────────┐
      │ users.password_   │
      │        hash bytea │
      └───────────────────┘

      The plaintext is never written to disk, never logged, never
      returned. Nothing decrypts it, because nothing can.

(1) The client sends a plaintext password once, over HTTPS. (2) bcrypt mixes it with fresh random salt and grinds for a fraction of a second. (3) The 60-byte result goes into the row. (4) At login the same grinding happens on whatever was typed, and the results are compared.

And the type that enforces it — a valve with an inlet, a question, and no outlet:

                  ┌──────────────────────────────────────┐
    Set(plain) ──▶│  data.Password                       │
                  │    plaintext *string  ← lowercase:   │
                  │                         invisible    │
                  │                         outside the  │
                  │                         package      │
                  │    Hash      []byte   ← uppercase:   │
                  │                         crosses to   │
                  │                         the DB       │
  Matches(try) ──▶│  ............................▶ bool  │
                  └──────────────────┬───────────────────┘
                                     ▼  the only thing that leaves
                              password_hash column

6. The steps

Step 1 — Create the users table

A migration is a numbered SQL file describing one change to the database’s shape, applied in order by a tool (Chapter 5 set this up). This target creates the pair of empty files — one to apply the change, one to undo it.

make db/migrations/new name=create_users

You should see two paths printed, ending 000002_create_users.up.sql and 000002_create_users.down.sql. Fill in the first:

-- migrations/000002_create_users.up.sql

-- citext = case-insensitive text. With it, Bob@example.com and
-- bob@example.com are THE SAME value to the UNIQUE constraint below —
-- killing the duplicate-account-by-capitalization bug at the type level.
CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE users (
    id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    created_at    timestamptz NOT NULL DEFAULT now(),
    name          text        NOT NULL,
    email         citext      NOT NULL UNIQUE,   -- the constraint IS the dedupe logic
    password_hash bytea       NOT NULL,          -- bcrypt output: bytes, never text
    activated     boolean     NOT NULL DEFAULT true, -- flipped to false in ch. 21
    version       integer     NOT NULL DEFAULT 1      -- same optimistic lock as tasks
);
-- migrations/000002_create_users.down.sql
DROP TABLE IF EXISTS users;

What this code says, line by line

  • CREATE EXTENSION IF NOT EXISTS citext; — an extension teaches Postgres new types or functions. citext ships with Postgres but is off by default. IF NOT EXISTS makes the line safe to run twice.
  • bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY — the auto-numbering key tasks got in Chapter 5. “Always” means the application may not supply an id.
  • email citext NOT NULL UNIQUE — the two words doing the work. citext makes comparison ignore capitalisation; UNIQUE makes Postgres refuse any insert matching an existing row. Together, the database owns the “one email, one account” rule.
  • password_hash byteabytea is Postgres for “raw bytes”. bcrypt’s output is printable ASCII, so text appears to work; it also invites a character-set conversion later that silently corrupts the value.
  • activated boolean NOT NULL DEFAULT true — the seam. Chapter 21 flips the default to false and adds the emailed round-trip that turns it on.
  • version integer NOT NULL DEFAULT 1 — Chapter 8’s optimistic-locking counter, which lets two simultaneous editors detect each other instead of silently overwriting.

Apply it and regenerate the typed Go code:

make db/migrations/up sqlc

What you should see: the migration tool prints a line mentioning 2/u create_users, and sqlc generate prints nothing. Silence from sqlc is success — it writes files instead of talking.

sqlc now emits a User struct too. The original edition mentions this in passing; here it is:

// internal/db/models.go — generated by sqlc, never edited by hand
// (shown here for the first time; you do not type this file)

type User struct {
	ID           int64     `json:"id"`
	CreatedAt    time.Time `json:"created_at"`
	Name         string    `json:"name"`
	Email        string    `json:"email"`
	PasswordHash []byte    `json:"password_hash"`
	Activated    bool      `json:"activated"`
	Version      int32     `json:"version"`
}

Every column became a field: citextstring, bytea[]byte, integerint32. Note that PasswordHash is here and does have a JSON tag — which is why Step 4 takes care never to send this particular struct to a client.

Step 2 — The password type

Before the code, the one crypto lesson this chapter must land: passwords are never stored, only hashed. A hash function is a one-way scrambler — easy to compute forward, computationally hopeless to reverse. At login we don’t decrypt anything (there’s nothing to decrypt); we hash what the user typed and compare hashes. And for passwords specifically the hash must be deliberately slow: humans pick guessable passwords, so the defense is making each guess cost the attacker real compute. That’s bcrypt — its cost parameter (12 here) doubles the work per increment, landing around a quarter-second per hash. Painful for a cracker with a stolen database; unnoticeable for one legitimate login.

Do the arithmetic, because it is the argument. At a quarter-second per hash, trying one billion candidate passwords against one stolen row costs 250 million seconds — about eight years of one processor working flat out. Against a fast hash like SHA-256, ordinary graphics hardware computes billions of hashes per second and that same run finishes while you read this paragraph. Nothing about bcrypt is cleverer. It is slower on purpose, and the slowness is the product.

Cost Relative work If cost 12 is ~250 ms on your machine
4 ~1 ms
8 16× ~16 ms
10 64× ~60 ms
12 256× ~250 ms
14 1024× ~1 s

That last column is arithmetic from one anchor, not a measurement — real timings vary by machine. Exercise 3 measures yours.

Now the second half of the defence, which beginners routinely skip past — the salt:

Alice registers "hunter2" ─▶ 16 fresh random bytes ─▶ $2a$12$AAAA…xxxxxxxx
Bob   registers "hunter2" ─▶ 16 different ones     ─▶ $2a$12$ZZZZ…yyyyyyyy

Same password. Different rows. An attacker holding the dump cannot even
tell that Alice and Bob chose the same one.
New word

salt — random data mixed into each hash so identical passwords produce different hashes. rainbow table — a precomputed dictionary of hash → password, built once and reused against every stolen database. Salting kills it: the attacker would have to rebuild the dictionary separately for each user’s salt.

bcrypt generates the salt itself and stores it inside the resulting string, so the 60 bytes hold version, cost, salt and digest — verification needs nothing else from you.

// internal/data/users.go — new file
package data

import (
    "errors"

    "golang.org/x/crypto/bcrypt"

    "github.com/yourname/taskd/internal/validator"
)

// Password bundles a plaintext (kept unexported, so it can never be
// marshaled into JSON or logged by accident) with its bcrypt hash —
// the only part that touches the database.
type Password struct {
    plaintext *string
    Hash      []byte
}

// Set hashes a plaintext password. Cost 12: each unit doubles the work.
// bcrypt salts internally, so equal passwords still yield different
// hashes — rainbow tables are dead on arrival.
func (p *Password) Set(plaintext string) error {
    hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), 12)
    if err != nil {
        return err
    }
    p.plaintext = &plaintext
    p.Hash = hash
    return nil
}

// Matches re-hashes the candidate and compares — in constant time,
// inside bcrypt, so response timing leaks nothing. Note the THREE
// outcomes: match, clean mismatch (not an error — a wrong password is
// normal life), and actual failure (corrupt hash: a real 500).
func (p *Password) Matches(plaintext string) (bool, error) {
    err := bcrypt.CompareHashAndPassword(p.Hash, []byte(plaintext))
    switch {
    case err == nil:
        return true, nil
    case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
        return false, nil
    default:
        return false, err
    }
}

func ValidateEmail(v *validator.Validator, email string) {
    v.Check(email != "", "email", "must be provided")
    v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address")
}

func ValidatePasswordPlaintext(v *validator.Validator, password string) {
    v.Check(password != "", "password", "must be provided")
    v.Check(len(password) >= 8, "password", "must be at least 8 bytes long")
    v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long")
}

What this code says, line by line

  • func (p *Password) Set(...) — the (p *Password) makes this a method, called as pw.Set("…"). The * means it receives a pointer, so the assignments change the caller’s variable rather than a copy. A non-pointer Set would hash correctly and throw the result away.
  • []byte(plaintext) — a conversion. Go strings are read-only; the crypto packages work on byte slices. &plaintext on the next line takes the address, storing a pointer rather than a copy.
  • errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) — “is this that specific known error?”, looking through any wrapping. A wrong password is not a malfunction, so it becomes (false, nil). The default branch is the genuinely broken case — a corrupt or non-bcrypt value in the column — and deserves a 500.
  • len(password) <= 72len on a Go string counts bytes, which is the right unit because bcrypt’s limit is 72 bytes. A passphrase of 40 emoji is 160 bytes and is correctly refused.
New word

constant-time comparison — comparing secrets so the answer takes the same time whether they match or not. An ordinary == on byte slices stops at the first difference, so a guess sharing the first ten bytes takes measurably longer to reject; an attacker who can time your responses recovers the secret byte by byte. That is a timing side-channel. bcrypt.CompareHashAndPassword handles it internally, which is why you see no comparison above.

Step 3 — Teach the validator about email addresses

ValidateEmail referenced two things the validator package does not have yet.

// internal/validator/validator.go — change the import line, add these two items
package validator

import (
    "regexp"
    "slices"
)

var EmailRX = regexp.MustCompile(
    "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" +
    "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

func Matches(value string, rx *regexp.Regexp) bool { return rx.MatchString(value) }
Warning

Chapter 8 (CRUD done properly) wrote that file with import "slices" on a single line. You must widen it into the bracketed block above. Leaving it gives you ./validator.go:5:11: undefined: regexp — Go has no implicit imports, so a package you use and did not import is a compile error, not a warning.

New word

regular expression — a compact pattern language for describing string shapes. regexp.MustCompile turns the pattern into a matcher once, at start-up, and panics immediately if the pattern itself is malformed — better than discovering it on a Tuesday under load.

You do not need to read that pattern. In words: some run of ordinary characters, exactly one @, then a domain of dot-separated chunks that each start and end with a letter or digit. It accepts a.b+tag@sub.example.co.uk and rejects hello, a@, @b.com, a@b@c.com.

Email regexes are famously unwinnable; this W3C-derived one rejects obvious garbage and accepts everything sane. The real validation of an email is sending mail to it — see the deferred-activation note above.

Step 4 — Write the queries

sqlc reads plain .sql files and generates typed Go functions from them (Chapter 7).

-- sql/queries/users.sql — new file

-- name: CreateUser :one
INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, created_at, name, email, activated, version;

-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;

Deliberate asymmetry: CreateUser returns explicit columns without the hash (so the handler can’t leak it even lazily), while GetUserByEmail returns everything because login needs the hash. Choosing columns per-query is a place sqlc quietly encourages good hygiene.

Remember this

Safe by construction beats safe by discipline. CreateUser’s result type has no PasswordHash field at all, so no future careless writeJSON can leak it. Discipline forgets; a missing field cannot.

Run make sqlc — silence means success. Here is what it wrote, because the handler uses all three names:

// internal/db/users.sql.go — generated by sqlc, never edited by hand
// (shown here for the first time; you do not type this file)

type CreateUserParams struct {
	Name         string `json:"name"`
	Email        string `json:"email"`
	PasswordHash []byte `json:"password_hash"`
}

type CreateUserRow struct {
	ID        int64     `json:"id"`
	CreatedAt time.Time `json:"created_at"`
	Name      string    `json:"name"`
	Email     string    `json:"email"`
	Activated bool      `json:"activated"`
	Version   int32     `json:"version"`
}

func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)

CreateUserRow is the RETURNING list turned into a struct. Compare it to User from Step 1: no PasswordHash. That difference is the point of the asymmetry.

Step 5 — Build the registration handler

The four-beat handler shape from Chapter 8 — decode, validate, query, respond — with one extra beat between validate and query: hash.

// cmd/api/users.go — new file
package main

import (
    "errors"
    "net/http"

    "github.com/jackc/pgx/v5/pgconn"

    "github.com/yourname/taskd/internal/data"
    "github.com/yourname/taskd/internal/db"
    "github.com/yourname/taskd/internal/validator"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    v.Check(input.Name != "", "name", "must be provided")
    v.Check(len(input.Name) <= 100, "name", "must not be more than 100 characters")
    data.ValidateEmail(v, input.Email)
    data.ValidatePasswordPlaintext(v, input.Password)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    var pw data.Password
    if err := pw.Set(input.Password); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Insert — and let the DATABASE decide uniqueness. Postgres reports
    // a violated unique constraint as error code 23505; errors.As digs
    // the *pgconn.PgError out of pgx's wrapping so we can check it and
    // translate the raw constraint failure into a polite 422.
    user, err := app.q.CreateUser(r.Context(), db.CreateUserParams{
        Name: input.Name, Email: input.Email, PasswordHash: pw.Hash,
    })
    if err != nil {
        var pgErr *pgconn.PgError
        switch {
        case errors.As(err, &pgErr) && pgErr.Code == "23505": // unique_violation
            v.AddError("email", "a user with this email address already exists")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
}

What this code says, line by line

  • var input struct { … } — an anonymous struct declared inline, used once. The json:"name" tags tell the decoder which JSON key fills which field. Password is a plain string because it is only transport: three lines later it becomes a hash and is never touched again.
  • app.readJSON(w, r, &input) — Chapter 8’s hardened decoder: 1 MB body cap, unknown keys rejected, single JSON value enforced. Any complaint is the client’s fault, hence 400.
  • the run of v.Check — the validator collects all field errors instead of stopping at the first, so a signup form shows every problem at once. Note the early return after failedValidationResponse: forgetting it is Chapter 8’s classic bug.
  • pw.Set(input.Password) — the quarter-second. It can only fail for exceptional reasons (input over 72 bytes, already rejected by validation; or the system refusing randomness), so 500.
  • errors.As(err, &pgErr) — “is this error, or anything it wraps, a *pgconn.PgError? If so, put it in that empty variable.” pgx wraps its errors, so a direct type assertion would miss it. errors.Is asks which value; errors.As asks which type and hands it over.
  • pgErr.Code == "23505" — Postgres reports every error with a five-character SQLSTATE code; 23505 is unique_violation. Checking the code rather than the message text is what makes this robust across Postgres versions and locales.
  • envelope{"user": user} — Chapter 8’s response wrapper, so the body is {"user": {…}}. user is a CreateUserRow, which is why no hash can appear.

Now the route:

// cmd/api/routes.go — add one line inside the /v1 group
    r.Route("/v1", func(r chi.Router) {
        r.Get("/healthcheck", app.healthcheckHandler)
        r.Post("/users", app.registerUserHandler)     // ch. 10 — NEW
        r.Route("/tasks", func(r chi.Router) {
            r.Post("/", app.createTaskHandler)
            r.Get("/", app.listTasksHandler)
            r.Get("/{id}", app.showTaskHandler)
            r.Patch("/{id}", app.updateTaskHandler)
            r.Delete("/{id}", app.deleteTaskHandler)
        })
    })

The 23505 catch is the canonical pgx pattern for translating constraint violations into user-facing errors — memorize the shape; you’ll use it for every unique constraint you ever add.


7. Checkpoint: prove it works

go build ./cmd/api && echo BUILD-OK

You should see BUILD-OK. A failure here is almost always the regexp import from Step 3.

Start the server with make run/api, then in a second terminal:

curl -i -s -d '{"name":"Ada Lovelace","email":"ada@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users

You should see HTTP/1.1 201 Created, a Content-Type: application/json header, and a body of this shape (the id is 1 only if this is your first user; created_at will be your own timestamp):

{"user":{"id":1,"created_at":"2026-01-01T12:00:00Z","name":"Ada Lovelace","email":"ada@example.com","activated":true,"version":1}}

The thing to check is what is absent: no password_hash field, anywhere.

curl -i -s -d '{"name":"Ada Again","email":"ADA@EXAMPLE.COM","password":"pa55word123"}' \
  localhost:4000/v1/users

You should see HTTP/1.1 422 Unprocessable Entity and the body {"error":{"email":"a user with this email address already exists"}}. Different capitalisation, same account — and no Go code lowercased anything.

Then make db/psql, and at the taskd=# prompt:

SELECT id, email, activated,
       length(password_hash) AS hash_bytes,
       left(encode(password_hash, 'escape'), 7) AS prefix
FROM users;

You should see hash_bytes of 60 and prefix of $2a$12$ for every row — bcrypt announcing its own version and cost inside the hash. Search the row for pa55word123. It is not there, and never will be. Type \q to leave.

If you got something else

You got Cause Fix
{"error":"the requested resource could not be found"} on the register call The route line went outside the /v1 group, or the server was not restarted Re-check Step 5’s nesting; Ctrl+C, then make run/api
500, and relation "users" does not exist in the server log The migration was never applied make db/migrations/up, then retry
201 on the second, capitalised registration email is text, not citext — the extension line was missing when the table was built Fix the type in the migration and rebuild the table
hash_bytes other than 60 The column is text, not bytea, and something re-encoded the value Fix the column type and re-run against a clean table

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: the migration fails with a line containing type "citext" does not exist (SQLSTATE 42704). It means: CREATE TABLE ran before CREATE EXTENSION, or the extension line was omitted. Fix: put CREATE EXTENSION IF NOT EXISTS citext; above CREATE TABLE in the same file, clear the half-applied migration with the migrate tool’s force flag, and re-run.

Common mistake

You’ll see: ./validator.go:5:11: undefined: regexp It means: you added EmailRX but the file still has Chapter 8’s single-line import "slices". Fix: widen it to the bracketed block with "regexp" and "slices", as in Step 3.

Common mistake

You’ll see: crypto/bcrypt: hashedSecret too short to be a bcrypted password, at login in Chapter 11, long after this chapter looked fine. It means: the hash was stored in a text column and something converted the bytes on the way in or out, so the stored value is no longer a valid bcrypt hash. Fix: the column must be bytea. This is the entire reason the schema uses it.

Common mistake

You’ll see: a 500 on a duplicate signup, with duplicate key value violates unique constraint "users_email_key" (SQLSTATE 23505) in your log. It means: the errors.As / 23505 branch is missing or misspelled, so a normal user mistake fell through to serverErrorResponse. Fix: restore the switch from Step 5. Leaking that text to a client is worse than the 500 — it names your table and constraint.

Two more with no error message, which is what makes them expensive:

Symptom What it means Fix
Your test suite suddenly takes minutes Every test that registers a user pays 250 ms of deliberate slowness Chapter 20 (Testing) lowers the cost to 4 for tests only
Everything works; you added body-logging middleware Plaintext passwords are now in your log files, and log files get copied everywhere Remove it, rotate the logs. See the pitfall below

9. Pitfalls

Check-then-insert races. “SELECT to see if email exists, then INSERT” has a window where two concurrent signups both pass the check. The unique constraint + error translation above is the only race-free version. Let the database be the arbiter.

time ──▶
             request A                        request B
  t1   SELECT … email='x'  ──▶ 0 rows
  t2                                SELECT … email='x'  ──▶ 0 rows
  t3   "safe to insert"                "safe to insert"
  t4   INSERT x            ──▶ ok
  t5                                INSERT x            ──▶ ?

  Without UNIQUE: two accounts, same email, no error, nobody notices.
  With    UNIQUE: Postgres refuses at t5 with 23505 → our polite 422.
New word

race condition — a bug that only appears when two things happen at the same time in an unlucky order. You cannot fix one by staring harder at either request alone; the bug lives in the gap between them.

User enumeration trade-off. Our duplicate-email message tells attackers which emails have accounts. The alternative (a vague message + “check your email” flow) requires the email pipeline we deferred. Documented, accepted, revisit with the SMTP work.

New word

user enumeration — discovering which email addresses have accounts by watching how responses differ. It turns a generic phishing campaign into a targeted one: “unusual activity on your taskd account” is far more convincing when the attacker knows you have one.

Note

The original edition promises to revisit this alongside the SMTP work, and Chapters 21 and 22 do adopt a uniform “check your email” response for resend and reset — but registration is never changed. Treat it as a standing, deliberate trade-off rather than a forgotten to-do.

bcrypt cost drift. Cost 12 ~ 250 ms on current hardware — intentionally slow. Two consequences: never hash inside a hot loop or a test suite without lowering cost (use 4 in tests), and know that this endpoint is inherently rate-limit-worthy. Chapter 14 (Rate limiting) puts POST /v1/users behind a per-IP limiter for exactly this reason: an endpoint that burns a quarter-second of CPU on demand is a denial-of-service tool with a friendly name.

Logging request bodies. Generic body-logging middleware + this endpoint = plaintext passwords in your logs. We never log bodies; if you add such middleware later, this is the endpoint that must be excluded.

Warning

Log files are the least protected copy of your data — shipped to a third-party search service, pasted into tickets, read by whoever is on call. A password that reaches a log has escaped every protection this chapter built. The same goes for PII: names, emails, anything identifying a real person. Chapter 19 (Structured logging) sets the rule: log identifiers, never contents.


10. Check yourself — quiz

  1. Your database is stolen. What, precisely, does the attacker now know about a user’s password?
  2. Why is SHA-256 the wrong choice for hashing a password, given that it is a respected hash?
  3. Two users choose the same password. Why do their password_hash values differ, and what attack does that defeat?
  4. Why 72, and why bytes rather than characters?
  5. CreateUser lists its RETURNING columns; GetUserByEmail uses SELECT *. Why?
  6. A colleague replaces the errors.As block with a SELECT before the INSERT. Their tests pass. Name the failure and when it appears.
  7. What would break if Password.plaintext were renamed to Plaintext?
  8. Matches returns (false, nil) for a wrong password rather than an error. Why is that right?
Answers
  1. A 60-byte bcrypt hash and nothing else. To learn the password they must guess: pick a candidate, hash it with that row’s salt and cost, compare. At cost 12 each guess costs roughly a quarter-second of CPU. They have an expensive guessing game, one row at a time.

  2. Because it is fast, and speed is the attacker’s budget. SHA-256 makes each guess nearly free, so ordinary graphics hardware runs billions per second. This is a statement about passwords, not about SHA-256 — Chapter 11 uses SHA-256 correctly for tokens, because a 128-bit random token has nothing guessable about it.

  3. bcrypt generates 16 fresh random bytes of salt per call and stores them inside the hash. That defeats rainbow tables — precomputed hash → password dictionaries — because the attacker would have to rebuild one per salt. It also hides that the two users chose the same password.

  4. 72 because bcrypt silently ignores input past its first 72 bytes; validating is how we refuse loudly instead of truncating silently. Bytes because that is the unit bcrypt counts, and Go’s len() on a string counts bytes too — so len(password) <= 72 measures exactly the right thing.

  5. So the handler cannot leak the hash. CreateUser’s generated result type has no PasswordHash field at all, making envelope{"user": user} safe by construction. GetUserByEmail needs the hash because login must compare against it — and that value never leaves the server.

  6. The check-then-insert race: two simultaneous signups both see “no such user” before either inserts. It never shows up in tests, which are sequential. It shows up in production as either two accounts for one email or — once the unique constraint catches it, which it will — an untranslated 500 leaking users_email_key to the client.

  7. Two things. encoding/json would start including it, so any handler returning a data.Password would put the plaintext in a response body. And code outside the data package could read and write it directly, bypassing Set. The lowercase letter is not style; it is the enforcement.

  8. Because a wrong password is normal life, not a malfunction. Folding it in with real failures would force every caller to inspect the error to choose between “say 401” and “say 500, page somebody”. The three-way split puts that decision in the one place that can make it.


11. Practice

Exercise 1 — Prove citext and prove the salt (easy)

Verify two claims the chapter makes but never demonstrates: (a) Bob@Example.COM and bob@example.com are one account; (b) the same password registered twice produces different hashes.

Solution
curl -s -o /dev/null localhost:4000/v1/users \
  -d '{"name":"Bob","email":"bob@example.com","password":"pa55word123"}'
curl -s localhost:4000/v1/users \
  -d '{"name":"Bob2","email":"Bob@Example.COM","password":"pa55word123"}'

The second prints {"error":{"email":"a user with this email address already exists"}}. No Go code lowercased anything — the UNIQUE constraint on a citext column compares case-insensitively at the type level. Now the salt:

curl -s -o /dev/null localhost:4000/v1/users \
  -d '{"name":"C","email":"c@example.com","password":"identical-password"}'
curl -s -o /dev/null localhost:4000/v1/users \
  -d '{"name":"D","email":"d@example.com","password":"identical-password"}'

Then in make db/psql:

SELECT count(DISTINCT password_hash) FROM users
WHERE email IN ('c@example.com', 'd@example.com');

The answer is 2. Two identical passwords, two completely different hashes, because each Set call generated its own salt. An attacker with the dump cannot tell that C and D chose the same password, and a rainbow table built for one is worthless against the other. These are the two invariants people accidentally destroy later — by lowercasing in Go and “tidying” the column back to text, or by adding a “deterministic hash so we can index it”.

Exercise 2 — Break it on purpose: the check-then-insert race (medium)

Replace the 23505 handling with the “obvious” version, then break it with concurrency.

Solution
// cmd/api/users.go — the WRONG version, for this exercise only.
// Put this immediately before the CreateUser call.
if _, err := app.q.GetUserByEmail(r.Context(), input.Email); err == nil {
    v.AddError("email", "a user with this email address already exists")
    app.failedValidationResponse(w, r, v.Errors)
    return
}

Restart the server, then fire twenty registrations at once:

docker compose exec db psql -U taskd -d taskd -c "TRUNCATE users CASCADE;"
seq 20 | xargs -P20 -I{} curl -s -o /dev/null localhost:4000/v1/users \
  -d '{"name":"Race","email":"race@example.com","password":"pa55word123"}'
docker compose exec db psql -U taskd -d taskd -tAc "SELECT count(*) FROM users;"

xargs -P20 runs twenty curls in parallel. Several pass the SELECT before any of them inserts. Because the UNIQUE constraint is still there, what you observe is the constraint doing the work the Go code failed to do: one 201 and a scattering of raw 500s, with users_email_key in your server log. Drop the constraint too and you would get several rows.

Delete the pre-flight check and repeat: exactly one 201, nineteen clean 422s, count 1. The rule — let the database be the arbiter. A uniqueness check in application code is a suggestion; a unique constraint is a guarantee.

Exercise 3 — Measure the cost factor (harder)

The chapter claims each cost increment doubles the work. Measure it on your machine.

Solution

In a scratch folder outside the taskd module:

// scratch/main.go — a throwaway measurement, not part of taskd
package main

import (
    "fmt"
    "strings"
    "time"

    "golang.org/x/crypto/bcrypt"
)

func main() {
    for _, cost := range []int{4, 10, 12, 14} {
        start := time.Now()
        if _, err := bcrypt.GenerateFromPassword([]byte("pa55word123"), cost); err != nil {
            fmt.Println("cost", cost, "failed:", err)
            continue
        }
        fmt.Printf("cost %2d: %v\n", cost, time.Since(start))
    }
    _, err := bcrypt.GenerateFromPassword([]byte(strings.Repeat("a", 100)), 12)
    fmt.Println("100-byte password:", err)
}

Run go mod init scratch && go get golang.org/x/crypto && go run ..

You should see four durations that roughly double per step of cost — 10 → 12 about four times slower, 12 → 14 about four times slower again. Absolute numbers vary a lot by machine; the ratios are the point, and they are what let you re-tune the cost in five years without changing anything else. The last line prints bcrypt: password length exceeds 72 bytes — the reason ValidatePasswordPlaintext refuses long input before it ever reaches this function.


12. FAQ

Why can’t I encrypt the passwords instead? Encryption is reversible, and that is the wrong property. It needs a key, the key must live where the server can reach it, and whoever steals the database is standing next to the key. Hashing has no key to steal: no operation turns the stored value back into the password.

A user forgot their password. Can I look it up for them? No — and that is the feature working. Nobody at your company can read a user’s password, so nobody can be tricked or bribed into reading one. The real answer is a reset flow: prove control of the email address, then set a new password. Chapter 22 (Password reset) builds it.

Is bcrypt still defensible, or is it dated? Defensible. Argon2id is the current recommendation and would also be right; bcrypt at cost 12 has no practical break. What matters far more is that you picked a slow, salted password hash instead of a fast one — and because all hashing lives behind Set and Matches, swapping later is a one-file change.

Why is this so much code for “save a user”? Count what is here: seven columns, two queries, a 25-line type, one handler. The weight is not code volume, it is the number of decisions — which hash, which cost, which uniqueness mechanism, which error translation. Those decisions are permanent in a way the code is not.

Is this how real companies actually do it? Yes, with two common additions. Larger organisations often hand authentication to a dedicated provider so they never hold passwords at all; those that keep passwords usually re-hash at login when the stored cost is below current policy — possible precisely because the cost is recorded inside the hash ($2a$12$…). The mechanics on this page are the same mechanics.


13. Where we are

taskd has people. POST /v1/users takes a name, an email and a password; validates all three; hashes the password with a deliberately slow, salted algorithm; and lets Postgres — not Go — be the authority on “one email, one account”. A stolen database yields no passwords, and no response this endpoint can produce contains a hash.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go         # UPDATED: POST /v1/users
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── healthcheck.go
│   ├── tasks.go
│   └── users.go          # NEW: registerUserHandler
├── internal/
│   ├── data/
│   │   ├── filters.go
│   │   ├── tasks.go
│   │   └── users.go      # NEW: Password + the two validators
│   ├── db/               # sqlc output: users.sql.go and User are NEW
│   └── validator/
│       └── validator.go  # UPDATED: EmailRX, Matches, regexp import
├── migrations/           # NEW: 000002_create_users up + down
├── sql/queries/
│   ├── tasks.sql
│   └── users.sql         # NEW
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: register an account, be refused a duplicate in any capitalisation, and inspect the row to confirm the password is not in it.

What is still fake or missing:

  • Nobody can log in. Chapter 11 (Stateful tokens) builds the login endpoint and the middleware that reads a token on every request — and it calls Password.Matches, which we wrote here and have not yet used.
  • Tasks still belong to nobody. Every task endpoint is wide open; Chapter 12 (Ownership) adds the user_id column and the tenant filter.
  • activated is always true. The column exists, means nothing yet, and gets its meaning in Chapter 21 (Background work and email).
  • Registration is unlimited. An endpoint burning 250 ms of CPU per call needs a limiter; Chapter 14 (Rate limiting) adds it.

For your notes

Copy these into learnings/ch10.md, in your own words:

  1. Passwords are never stored, only hashed. Login decrypts nothing — there is nothing to decrypt. It re-hashes what was typed and compares.
  2. Slowness is the feature. Cost 12 doubles per increment: a quarter-second nobody notices for one honest login, eight years per billion guesses for an attacker.
  3. A salt makes identical passwords hash differently, killing precomputed rainbow tables and hiding which users share a password. bcrypt does it for you and stores the salt inside the hash.
  4. Let the database be the arbiter. A uniqueness check in Go is a suggestion with a race in it; a UNIQUE constraint is a guarantee. The four-line errors.As + 23505 translation turns that guarantee into a polite 422, and you will reuse it for every unique constraint you write.
  5. Safe by construction beats safe by discipline. A lowercase field cannot be JSON-encoded; a RETURNING list without password_hash cannot leak one. Design so the mistake is unavailable, not merely discouraged.

Chapter 11 — Stateful tokens: authentication without JWT drama

Chapter 10 gave taskd users: rows in a users table, with bcrypt-hashed passwords. But nothing in the API has ever asked who is calling. Anyone who can reach port 4000 can create, read and delete every task. This chapter closes that hole. We build a login endpoint that trades an email and password for a 26-character random string, a piece of middleware that turns that string back into a user on every subsequent request, and a gate that returns 401 to anyone who shows up without one.

What you’ll be able to do by the end

  • Register a user, log in, and get back a token you can copy.
  • Send that token on a request and have the server know which user you are.
  • Get a 401 — with the correct message and the correct headers — when you send no token, a malformed token, an expired token, or the wrong password.
  • Explain, out loud, why this book does not use JWTs, and name the one situation where JWTs win.
  • Read a row out of the tokens table in psql and confirm your plaintext token is nowhere in it.

Time: ~45 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 10 (Users and passwords) — the users table, POST /v1/users, and the data.Password type. Two commands prove it:

curl -i localhost:4000/v1/healthcheck

You should see a first line reading HTTP/1.1 200 OK. Then, with the database container running:

make db/psql

At the taskd=# prompt, type \dt and press Enter. You should see a table listing that includes tasks, users and schema_migrations. Type \q to leave.


1. The problem, in plain words

Think about how a nightclub handles the door. You show ID once, at the entrance. The bouncer checks it, decides you’re allowed in, and puts a stamp on your hand. For the rest of the night nobody re-examines your driving licence — they glance at the stamp. The stamp is worthless to anyone outside; inside, it means “this person was already checked”.

An HTTP API has the same problem and needs the same trick, for two reasons.

First: HTTP has no memory. Each request is a separate, complete conversation. The server handles it and forgets everything. There is no “connection” in which the server remembers you from one request to the next — Chapter 0d (How the web actually works) called this being stateless. So something in the request itself has to say who you are, every single time.

Second: you cannot send the password every time. You could. Some very old systems do. It is a bad idea for reasons that compound:

  • Checking a bcrypt password takes about a quarter of a second on purpose (Chapter 10 — the cost factor). Doing that on every request means every request is slow by design.
  • The password would be in flight, in logs, in proxy buffers and in shell history hundreds of times a day instead of once.
  • You could never log a device out without changing the password everywhere.

So we do what the nightclub does: check the expensive credential once, then hand out a cheap disposable stand-in. In HTTP that stand-in is called a token, and the client sends it in a header on every later request.

New word

credential — anything a caller presents to prove who they are: a password, a token, a certificate. token — a string that stands in for a credential after the real one has been checked once.

What breaks if we skip this chapter: the task endpoints stay open to the world, “your tasks” is a meaningless phrase (Chapter 12 needs a user to attach tasks to), billing has nobody to bill, and rate limiting has nobody to count. Authentication is the pin that every later chapter hangs on.


2. New words in this chapter

Word What it means here
authentication Establishing who the caller is.
authorization Deciding what that caller is allowed to do. Different question, later chapters.
bearer token A random string that proves who you are; whoever holds (“bears”) it is treated as that user, so it must be protected like a password.
Authorization: Bearer … The HTTP header the client puts the token in.
WWW-Authenticate The header a server sends with a 401 to say which kind of credential it wanted.
401 Unauthorized “I don’t know who you are.” Badly named — it really means unauthenticated.
403 Forbidden “I know who you are; you still can’t.” Arrives in Chapter 21.
JWT A self-contained signed token that can be verified without a database lookup — and therefore cannot be revoked, which is why this book doesn’t use one.
stateful token A token that means nothing by itself; the server looks it up in its own table, so deleting the row logs you out instantly.
revoke Making an existing credential stop working immediately.
deny-list A list of credentials that are no longer accepted.
federation / OIDC Arrangements where a third party vouches for a user’s identity so other systems don’t have to ask you (“Sign in with Google”).
algorithm-confusion attack A JWT-specific attack that tricks a verifier into accepting a token signed the wrong way.
signing-key rotation Periodically replacing the secret used to sign tokens, and the chore of supporting both during the change.
entropy A measure of how unguessable a secret is, counted in bits. 128 bits means guessing is hopeless.
crypto/rand vs math/rand crypto/rand produces unguessable randomness from the operating system; math/rand produces predictable numbers and must never generate secrets.
SHA-256 A fast standard hash; correct for hashing high-entropy tokens, wrong for hashing passwords.
preimage The original input to a hash; “brute-forcing the preimage space” means guessing inputs until the hash matches.
key stretching Deliberately slowing a hash down so each guess costs an attacker time — needed for human passwords, unnecessary for random tokens.
base32 An encoding that turns raw bytes into letters and digits that survive URLs, headers and copy-paste.
scope (token) A label on a token saying what it may be used for: authentication, activation, password-reset, email-change.
bytea Postgres’ type for raw bytes, as opposed to text. Maps to Go’s []byte.
foreign key / REFERENCES A column that must match a row in another table, so the database itself forbids orphans.
ON DELETE CASCADE Deleting the referenced row automatically deletes the rows that point at it.
INNER JOIN A SQL clause that combines rows from two tables by a matching column — here, token to user.
request context The per-request bag middleware uses to hand data (the logged-in user, later the request ID) forward to handlers.
context key collision Two packages accidentally using the same context key; prevented here by an unexported private type.
type assertion Asking a general-purpose value “are you really a T?”, getting the T and a yes/no back.
anonymous user A stand-in user object meaning “nobody logged in”, so handlers never have to check for nil.
Vary: Authorization A header telling caches that responses differ per user, so nobody is served someone else’s data.
CDN Content Delivery Network — a global cache sitting in front of your site; a reason Vary matters.
timing attack / side-channel Learning a secret by measuring how long the answer takes rather than by reading it.
constant-time comparison Comparing secrets in a way that takes the same time whether or not they match, so timing reveals nothing.
oracle (security) Any behaviour that answers an attacker’s question for free — “does this email exist?”
flame graph A profiling chart showing where a program spends its time.
cron The Unix scheduler that runs a command on a timetable — “every night at 03:00”.

That is a long list. You do not need to memorise it now; every one of them is defined again at the moment the chapter first uses it.


3. The goal

POST /v1/tokens/authentication exchanges email+password for a 26-character bearer token (24 h expiry, only its SHA-256 hash stored). Middleware authenticates every request, attaches the user to the request context, and requireAuthenticatedUser guards routes.


4. The thinking

The great auth debate, honestly: JWTs vs stateful tokens

Before the argument, the thing being argued about.

New word

JWT (JSON Web Token, pronounced “jot”) — a token that contains the facts about you rather than pointing at them. It is three base64 chunks joined by dots: a header, a payload of “claims” ({"user_id": 42, "exp": 1767225600}), and a signature made with a secret only the server knows. A server can verify a JWT with maths alone — no database, no lookup.

That sounds strictly better. It is not, and the reason is one word.

JWTs are self-contained and verifiable without a DB hit — which is precisely their weakness: you cannot revoke one.

New word

revoke — make an existing credential stop working immediately. Not “when it expires” — now.

A JWT is a signed permission slip. Once you have written it, it is valid until its expiry, and nothing you do on your server changes that. Now list the ordinary things a real product must do:

The product needs to With a stateful token With a JWT
Log out DELETE the row Nothing happens; the token still verifies
“Log out everywhere” DELETE the user’s rows Nothing happens
Ban a user instantly DELETE the rows Nothing happens
Respond to a leaked token DELETE that row Nothing happens

So in practice every JWT deployment grows a deny-list: a table of tokens that must no longer be accepted, checked on every request.

New word

deny-list — a list of credentials that are no longer accepted, consulted on every request.

Look at what that is. A table. Consulted on every request. You’ve rebuilt stateful tokens with extra parsing, an algorithm-confusion attack surface, and a signing-key rotation chore.

New word

algorithm-confusion attack — a family of JWT bugs where an attacker changes the alg field in the header (for example to none, or from RSA to HMAC) and tricks a sloppy verifier into accepting a token it should reject. signing-key rotation — periodically replacing the secret used to sign tokens, and the chore of accepting both the old and new key during the change. Neither problem exists if there is no signature.

JWTs earn their complexity when third parties must verify identity without calling you — federation, microservice meshes, OIDC.

New word

federation / OIDC — arrangements where one system vouches for a user’s identity so other systems don’t have to ask the original service. “Sign in with Google” is federation: your app trusts a token Google minted, and cannot phone Google on every request.

We are one binary talking to its own database. The lookup JWT-avoidance saves is a primary-key read — the database’s fastest possible operation, because tokens.hash is the primary key.

Note

The original edition adds “…that Chapter 13’s cache can absorb if it ever shows up in a flame graph.” Chapter 13 (Caching) then decides not to cache authentication, precisely because this is a primary-key read and caching identity is how stale bans happen. Take the standing rule as: we do not cache auth in this book. (A flame graph is a profiling chart showing where a program spends its time — the tool you would use to discover a lookup was actually costing you.)

Stateful tokens. This is simultaneously the Edwards choice and the Nadh choice, which should tell you something.

New word

Alex Edwards wrote Let’s Go Further, the book this codebase takes its structure from. Kailash Nadh is the CTO of Zerodha and the author of listmonk and koanf; the preface introduced both. They disagree about plenty. When the careful-structure person and the minimum-machinery person land on the same answer, the answer is usually not controversial.

Think of it like

A stateful token is a hotel key card. The card itself is a meaningless magnetic smear; the front desk holds the table that says which room it opens and until when. Lose it, and one phone call kills it. A JWT is a signed letter from the manager saying “the bearer may enter room 412 until Friday”. Verifiable by any door, cancellable by nobody.

Token mechanics that must be exactly right

Four decisions, each with a specific failure if you get it wrong.

Generate 16 bytes from crypto/rand (never math/rand) → base32-encode, no padding → 26 URL-safe chars the client holds.

New word

crypto/rand draws its randomness from the operating system’s secure source, seeded by physical noise; nobody can predict its output. math/rand is a formula with a starting number (“seed”): fast, repeatable, and if you know the seed you know every number it will ever produce. Dice from a casino versus dice a magician handed you.

Warning

If you generate tokens with math/rand, nothing fails. No error, no warning, all tests pass. An attacker who observes a handful of your tokens can compute every token you will ever issue. This is the single most dangerous line in the chapter, and it looks completely ordinary.

Store only sha256(token). A database leak then leaks nothing usable.

New word

SHA-256 — a hash function: it takes any input and produces 32 fixed bytes, quickly, and cannot be run backwards. Same input always gives the same 32 bytes; a one-character change gives completely different ones.

Now the part that will feel like a contradiction. Chapter 10 was emphatic: never hash passwords with SHA-256, it is far too fast. This chapter hashes tokens with SHA-256 on purpose. Both statements are true, and one line reconciles them:

Remember this

Slow hash for low-entropy human secrets. Fast hash for high-entropy random ones.

Here is the arithmetic that makes it more than a slogan.

New word

entropy — how many genuinely unpredictable choices a secret contains, measured in bits. n bits of entropy means 2ⁿ equally likely possibilities. Preimage — the original input to a hash; “brute-forcing the preimage space” means guessing inputs until one hashes to the value you stole.

A human password has perhaps 30 bits of real entropy — people reuse words, patterns and years. So there are about 2³⁰ ≈ 1.07 billion likely candidates. With a fast hash and ordinary hardware an attacker checks those in seconds. That is why bcrypt exists: at cost 12 each guess costs about a quarter-second, so 1.07 billion guesses cost roughly 2.7 × 10⁸ seconds — about eight and a half years on one core. Slowing the hash down deliberately is called key stretching, and it is the only thing standing between a stolen users table and everyone’s password.

A token from crypto/rand has 128 bits — 16 bytes × 8 bits. That is 2¹²⁸ ≈ 3.4 × 10³⁸ possibilities. Suppose an attacker can compute a trillion SHA-256 guesses per second (10¹², which is generous). Working through half the space takes about 1.7 × 10²⁶ seconds. A year is about 3.15 × 10⁷ seconds, so that is roughly 5 × 10¹⁸ years — several hundred million times the current age of the universe. Nobody is brute-forcing a 128-bit random token, no matter how fast the hash is. Stretching would buy nothing and cost a quarter-second on every single request.

Common mistake

The wrong lesson to take from Chapter 10 is “SHA-256 is bad”. SHA-256 is excellent — for high-entropy inputs. The right lesson is that a fast hash is bad when the input is guessable.

Scope column (authentication for now) so the same table later serves password-reset or activation tokens without a redesign.

New word

scope — a text label on a token saying what it may be used for. A password-reset token must not work as a login token, so the lookup always asks for hash and scope together.

This is forward design done cheaply: one text column today buys three whole features later. Chapter 21 (Background work and email) adds activation, and Chapter 22 (Password reset) adds password-reset and email-change — no migration, no new table.

Look up user by token in one SQL join, with the expiry check in the WHERE clause — the database evaluates expiry > now(), leaving no code path that forgets it.

That last clause is the whole point of the design. An expiry check written in Go is a check some future refactor can skip. An expiry check written in the WHERE clause is not a check at all — the row is not returned at all.

Context-passing pattern

Middleware resolves the user once, stores it in the request context under an unexported key type (collision-proof by construction), and handlers retrieve it with a helper that panics if missing — because a handler asking for a user on an unguarded route is a programmer error we want loud, not a nil to limp along with.

That paragraph contains three ideas we will unpack in full in Step 5. Hold on to the shape: resolve once, carry it in the request, take it out where you need it.


5. A picture of it

The whole flow, once

Three lanes: what the client does, what taskd does, what Postgres does. Read it top to bottom.

  CLIENT                    taskd                       POSTGRES
    |                         |                            |
    | POST /v1/tokens/        |                            |
    |   authentication        |                            |
    |  {email,password} ─────▶|                            |
    |                         | SELECT * FROM users        |
    |                         |   WHERE email = $1 ───────▶|
    |                         |◀───── row, incl. bcrypt ───|
    |                         |       password_hash        |
    |                         | bcrypt compare (~250 ms)   |
    |                         | 16 random bytes            |
    |                         |   → base32 → 26 chars      |
    |                         | sha256(those 26 chars)     |
    |                         |   → 32 bytes               |
    |                         | INSERT INTO tokens ───────▶|
    |                         |   (hash, user_id,          |
    |                         |    expiry, scope)          |
    |◀─ 201 {"token":"FKF6…"} |                            |
    |   the ONLY moment the   |                            |
    |   plaintext leaves      |                            |
    |   this program          |                            |
    |                         |                            |
    | GET /v1/tasks           |                            |
    | Authorization:          |                            |
    |   Bearer FKF6…  ───────▶|                            |
    |                         | sha256(what they sent)     |
    |                         | SELECT user JOIN token ───▶|
    |                         |   WHERE hash=$1            |
    |                         |     AND scope=$2           |
    |                         |     AND expiry > now()     |
    |                         |◀────────── user row ───────|
    |◀─ 200 {"tasks":[…]} ────|                            |
  1. The password is checked once, at login, and the slow bcrypt comparison happens only there.
  2. The 26-character plaintext exists in exactly one response body, ever. It is never written to the database and never logged.
  3. On every later request the server hashes what the client sent and looks up that. The plaintext never reaches Postgres.
  4. The expiry is enforced by the WHERE clause, not by Go.

The two rings

Two pieces of middleware do two different jobs. This is the mental model to keep.

 ┌─────────────────────────────────────────────────────────────┐
 │  r.Use(app.authenticate)  —  THE GREETER                    │
 │  Runs on every request. No header at all? Fine — you are     │
 │  the AnonymousUser and the request continues.                │
 │                                                             │
 │  PUBLIC ROUTES (outside the door, deliberately):             │
 │      GET  /v1/healthcheck                                    │
 │      POST /v1/users                  <- mints the account    │
 │      POST /v1/tokens/authentication  <- mints the token      │
 │                                                             │
 │  ┌───────────────────────────────────────────────────────┐  │
 │  │  r.Use(app.requireAuthenticatedUser) — THE DOOR       │  │
 │  │  Anonymous? 401 and stop. Otherwise carry on.         │  │
 │  │                                                       │  │
 │  │      POST   /v1/tasks                                 │  │
 │  │      GET    /v1/tasks                                 │  │
 │  │      GET    /v1/tasks/{id}                            │  │
 │  │      PATCH  /v1/tasks/{id}                            │  │
 │  │      DELETE /v1/tasks/{id}                            │  │
 │  └───────────────────────────────────────────────────────┘  │
 └─────────────────────────────────────────────────────────────┘

The greeter identifies. The door gates. Keeping them separate is what lets Chapter 17 (Entitlements) add a plan-based gate later without touching identification.

Notice which routes sit outside the door: the two that create the very credentials the door demands. They have to be public — you cannot require a token to get a token. Chicken, meet egg, resolved.


6. The steps

Three parts: A builds the machinery that mints and stores a token, B builds the machinery that turns a token back into a user, C proves the whole thing from a terminal.

Part A — mint a token and store its fingerprint

Step 1 — Create the tokens table

Same two-command rhythm as Chapter 10. First ask the migration tool for an empty pair of files:

make db/migrations/new name=create_tokens
New word

migration — a numbered SQL file that changes the database’s structure, applied in order, so every machine’s database ends up identical. Chapter 5 (PostgreSQL and migrations) built this workflow.

That creates two empty files, migrations/000003_create_tokens.up.sql (the change) and migrations/000003_create_tokens.down.sql (the undo). Fill in the up file:

-- migrations/000003_create_tokens.up.sql — new file

CREATE TABLE tokens (
    hash    bytea PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
    expiry  timestamptz NOT NULL,
    scope   text NOT NULL
);

What this code says, line by line

  • hash bytea PRIMARY KEYbytea is Postgres’ type for raw bytes, as opposed to text. A SHA-256 output is 32 bytes of arbitrary binary; storing it as text would mean encoding it first, for no benefit. Making it the primary key does two jobs at once: it guarantees no two rows share a hash, and it gives us the fastest possible lookup, because Postgres automatically indexes a primary key. We only ever search this table by hash, so the key and the index are the same thing.
  • user_id bigint NOT NULL REFERENCES users — a foreign key: a column whose value must match an id in the users table. The database itself refuses to store a token belonging to a user who doesn’t exist.
  • ON DELETE CASCADE — if a user row is deleted, Postgres deletes that user’s token rows too, automatically. Without it, deleting a user would fail with a foreign-key error because tokens still point at them.
  • expiry timestamptz NOT NULL — a moment in time with a time zone, so “24 hours from now” means the same instant everywhere on earth.
  • scope text NOT NULL — the label described in The thinking. Today it always holds "authentication".
Note

The original edition prints this table but never names the file, and never shows the paired .down.sql. Both are supplied here for the first time, taken from the finished repository.

-- migrations/000003_create_tokens.down.sql — new file

DROP TABLE IF EXISTS tokens;

IF EXISTS means “don’t error if it’s already gone”, which makes re-running the undo safe.

Apply it:

make db/migrations/up

What you should see — one line per applied migration, ending with a line mentioning 3/u create_tokens and a duration. If the database was already up to date you get no change instead.

Step 2 — Write the token generator

This is the security heart of the chapter: 27 lines that decide whether your authentication is real.

// internal/data/tokens.go — new file
package data

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base32"
    "time"
)

const ScopeAuthentication = "authentication"

type Token struct {
    Plaintext string    `json:"token"`
    Hash      []byte    `json:"-"`
    UserID    int64     `json:"-"`
    Expiry    time.Time `json:"expiry"`
    Scope     string    `json:"-"`
}

func GenerateToken(userID int64, ttl time.Duration, scope string) (*Token, error) {
    // 16 bytes = 128 bits of CRYPTOGRAPHIC randomness. crypto/rand pulls
    // from the OS's secure source; math/rand is predictable and would
    // make every token guessable. This distinction is non-negotiable.
    randomBytes := make([]byte, 16)
    if _, err := rand.Read(randomBytes); err != nil {
        return nil, err
    }

    t := &Token{
        UserID: userID,
        Expiry: time.Now().Add(ttl),
        Scope:  scope,
    }

    // base32 turns the raw bytes into 26 characters of A-Z2-7 — safe in
    // URLs, headers, and human copy-paste (no padding '=' to mangle).
    // This string is what the CLIENT holds. We will never see it again.
    t.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).
        EncodeToString(randomBytes)

    // What WE store is only its SHA-256. Note the json tags on the
    // struct: Plaintext serializes to the client; Hash is json:"-",
    // structurally incapable of appearing in a response.
    hash := sha256.Sum256([]byte(t.Plaintext))
    t.Hash = hash[:] // [32]byte array → []byte slice
    return t, nil
}

What this code says, line by line

  1. const ScopeAuthentication = "authentication" — a named constant instead of typing the string in three places. A typo in a constant name is a compile error; a typo in a string literal is a silent bug where nothing ever matches.

  2. The struct tags — the backtick text after each field. A struct tag is metadata attached to a field that libraries read at runtime; encoding/json reads these to decide field names. json:"token" means “call this token in JSON”. json:"-" means “never include this field in JSON at all.” Three of the five fields are marked -, so a Token handed to writeJSON is structurally incapable of leaking the hash, the user id or the scope. That is a much stronger guarantee than remembering not to print them.

  3. ttl time.Duration — TTL is “time to live”. time.Duration is Go’s type for a length of time; the caller will pass 24*time.Hour.

  4. make([]byte, 16) — creates a slice of 16 bytes, all zero. A slice is Go’s growable list; here it is just a 16-byte buffer waiting to be filled.

  5. rand.Read(randomBytes) — fills those 16 bytes with cryptographic randomness. Because of the crypto/rand import at the top, rand here is the secure package. If you ever change that import to math/rand the code still compiles and your product is finished. It returns (n int, err error); we discard the count with _ and check only the error.

  6. base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(...) — encoding, not encryption and not hashing. Three words beginners fuse into one:

    Reversible? Purpose
    Encoding (base32) yes, by anyone make bytes safe to transport
    Encryption yes, with the key make data secret
    Hashing (SHA-256) no, by anyone make a fixed-size fingerprint

    Raw random bytes include values that are not printable characters and would be mangled by a header, a URL or a copy-paste. Base32 rewrites them using only AZ and 27. Each base32 character carries 5 bits, so 128 bits ÷ 5 = 25.6, rounded up to 26 characters. That is where the number in the goal comes from. WithPadding(NoPadding) suppresses the trailing = signs base32 normally adds, because = is awkward in URLs.

  7. sha256.Sum256([]byte(t.Plaintext)) — hashes the 26-character string. Note what is hashed: the printable token, not the original random bytes. Generation and verification must hash the same thing, and the middleware will only ever have the printable string.

  8. t.Hash = hash[:] — the one line of Go syntax that stops beginners. Sum256 returns [32]byte: a fixed-size array, a distinct type whose length is part of the type. Our struct field is []byte: a slice, a view into an array with a length that can vary. hash[:] means “a slice covering the whole of this array” and converts one to the other. Go will not do it implicitly, which is why the line exists.

Remember this

The plaintext token is created, sent once, and forgotten. If a user loses it, there is no “resend” — there is only “log in again and get a new one”. That is not a limitation, it is the feature.

Step 3 — Write the three queries

taskd never hand-writes database code; Chapter 7 (sqlc) set up a tool that reads SQL files and generates typed Go functions from them. Add a new query file:

-- sql/queries/tokens.sql — new file

-- name: InsertToken :exec
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4);

-- One query answers the whole auth question: "given this token hash,
-- which user is it — IF it's the right scope and not expired?" The
-- expiry check lives in SQL so no Go code path can forget it, and the
-- SELECT list deliberately omits password_hash: the middleware's user
-- object physically cannot leak it.
-- name: GetUserForToken :one
SELECT u.id, u.created_at, u.name, u.email, u.activated, u.version
FROM users u
INNER JOIN tokens t ON t.user_id = u.id
WHERE t.hash = $1 AND t.scope = $2 AND t.expiry > now();

-- name: DeleteTokensForUser :exec
DELETE FROM tokens WHERE user_id = $1 AND scope = $2;

What this code says

  • -- name: InsertToken :exec — the comment is the interface. sqlc turns this into a Go method called InsertToken; :exec means “returns no rows, only success or failure”. :one means “returns exactly one row”.
  • $1, $2, $3, $4 — numbered placeholders. The values are sent to Postgres separately from the query text, which is why SQL injection is impossible here.
  • INNER JOIN — this is the book’s first join, so here is the whole idea.
New word

JOIN — a SQL clause that combines rows from two tables by a matching column. FROM users u INNER JOIN tokens t ON t.user_id = u.id says: pair every users row with every tokens row where the token’s user_id equals the user’s id. INNER means unmatched rows on either side are dropped. u and t are short aliases so the rest of the query can say u.email instead of users.email.

Think of it like

A cloakroom. One pile of tickets, one rail of coats, each ticket carrying a coat number. A join is walking down the rail once and handing each ticket to its coat. Tickets with no matching coat — and coats with no matching ticket — end up in nobody’s hands.

The WHERE clause then filters the joined pairs three ways: the hash must match, the scope must match, and t.expiry > now() — Postgres’ own clock decides whether the token is still alive.

  • The SELECT list omits password_hash. Read that list again: id, created_at, name, email, activated, version. Every request in the system will be carrying the object built from this query. If password_hash were in it, one careless writeJSON(w, 200, envelope{"user": user}) in some future handler would publish everyone’s bcrypt hash. It isn’t in the list, so that bug is unavailable.
  • DeleteTokensForUser — not used by any handler in this chapter. It exists because “log out everywhere” and “revoke all sessions after a password reset” are three lines away once you have it, and Chapter 22 (Password reset) calls it.

Now regenerate the Go code:

make sqlc

What you should see — no output at all. sqlc is quiet on success. It has written a new file, internal/db/tokens.sql.go. You never edit that file, but you do need to know what it gave you, because the next step uses those names:

// internal/db/tokens.sql.go — generated by sqlc; never edit by hand
// (shown here so you can see where the names in the next step come from)

type InsertTokenParams struct {
    Hash   []byte    `json:"hash"`
    UserID int64     `json:"user_id"`
    Expiry time.Time `json:"expiry"`
    Scope  string    `json:"scope"`
}

type GetUserForTokenParams struct {
    Hash  []byte `json:"hash"`
    Scope string `json:"scope"`
}

type GetUserForTokenRow struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    Activated bool      `json:"activated"`
    Version   int32     `json:"version"`
}

Three things worth noticing. Each query with more than one parameter gets its own …Params struct, so you cannot swap two arguments by accident. GetUserForToken gets a …Row struct because its SELECT list is a subset of the users columns — this type is not db.User, and that difference is deliberate: GetUserForTokenRow has no PasswordHash field to leak. And the timestamptz columns arrive as plain time.Time, which is what the override in Chapter 7’s sqlc.yaml is for.

Common mistake

You’ll see: cannot use token.Expiry (variable of type time.Time) as pgtype.Timestamptz value in struct literal It means: your sqlc.yaml is missing the timestamptz → time.Time override for NOT NULL columns; by default the pgx driver maps every timestamptz to pgtype.Timestamptz. Fix: go back to Chapter 7 (sqlc), add the unguarded db_type: "timestamptz" override alongside the nullable: true one, and run make sqlc again.

Step 4 — Build the login endpoint

This handler follows the same four-beat pattern as every other handler in the book: decode → validate → query → respond. What is new is that it does the query beat three times.

// cmd/api/tokens.go — new file
func (app *application) createAuthTokenHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    data.ValidateEmail(v, input.Email)
    data.ValidatePasswordPlaintext(v, input.Password)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user, err := app.q.GetUserByEmail(r.Context(), input.Email)
    if err != nil {
        switch {
        case errors.Is(err, pgx.ErrNoRows):
            app.invalidCredentialsResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    pw := data.Password{Hash: user.PasswordHash}
    match, err := pw.Matches(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    token, err := data.GenerateToken(user.ID, 24*time.Hour, data.ScopeAuthentication)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
        Hash: token.Hash, UserID: token.UserID,
        Expiry: token.Expiry, Scope: token.Scope,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil)
}

This file needs a package main line and imports: errors, net/http, time, github.com/jackc/pgx/v5, and the three internal packages data, db and validator.

What this code says, beat by beat

  • Decode. var input struct{…} is an anonymous struct — a one-off type declared exactly where it is used, because this shape has no life outside this function. app.readJSON (Chapter 8) fills it and translates every possible malformed-body failure into a readable message.
  • Validate. data.ValidateEmail and data.ValidatePasswordPlaintext are the same functions registration uses (Chapter 10). Reusing them means the rules cannot drift apart.
  • Query 1: find the user. errors.Is(err, pgx.ErrNoRows) asks “is this the driver’s specific ‘no rows matched’ error?” — as opposed to “the database is on fire”, which is a 500. An unknown email is a 401, not a 500, because it is a normal thing for a user to do.
  • Query 2 (in memory): check the password. data.Password{Hash: user.PasswordHash} builds Chapter 10’s password type around the stored hash and calls Matches, which re-hashes the candidate with bcrypt and compares inside the library, in constant time. Note the three outcomes: err != nil is a corrupt stored hash (a real 500), !match is a wrong password (a 401), and the fall-through is success.
  • Query 3: store the token. GenerateToken(user.ID, 24*time.Hour, data.ScopeAuthentication) then InsertToken. The struct literal is spread over two lines purely for width; field order in a keyed struct literal does not matter.
  • Respond. 201 Created because a new resource — the token — now exists. The envelope type (Chapter 8) wraps every body in a named key, so the client reads response.authentication_token.token rather than guessing at a bare object.
Important

Same message (“invalid authentication credentials”) for unknown email and wrong password — don’t hand attackers an oracle.

New word

oracle (in security) — any behaviour that answers an attacker’s question for free. If an unknown email produced “no such user” and a wrong password produced “wrong password”, an attacker with a list of ten million leaked addresses could discover which of them have taskd accounts without guessing a single password. One message for both cases answers nothing.

Add the two new responses to errors.go (the file where every non-2xx the API can emit is defined, from Chapter 8):

// cmd/api/errors.go — add these three functions
func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) {
    app.errorResponse(w, r, http.StatusUnauthorized, "invalid authentication credentials")
}

func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("WWW-Authenticate", "Bearer")
    app.errorResponse(w, r, http.StatusUnauthorized,
        "invalid or missing authentication token")
}

func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) {
    app.errorResponse(w, r, http.StatusUnauthorized,
        "you must be authenticated to access this resource")
}

Three functions, three distinct situations, all 401:

Function Sent when Extra header
invalidCredentialsResponse login with a bad email or password
invalidAuthenticationTokenResponse the Authorization header is present but wrong WWW-Authenticate: Bearer
authenticationRequiredResponse a guarded route was reached with no token
New word

WWW-Authenticate: Bearer — the header a server sends alongside a 401 to say which kind of credential it wanted. It is part of the HTTP specification: a well-behaved client reads it and knows to attach a bearer token rather than, say, a username and password. We set it only for the “you tried and got it wrong” case, which is the case a client can act on.

Note

The original edition says “add the two new responses” and then prints three. All three are needed; the count is a slip, not a missing function.

Part B — turn a token back into a user

Step 5 — Context plumbing

What is this “request context”? Every *http.Request carries a context.Context — a per-request bag that flows through middleware and handler untouched by other requests. It carries two things: cancellation (you’ve used that — queries stop when clients disconnect) and values. Values are how middleware hands data forward: authenticate resolves the user once, tucks it into the context, and every later handler pulls it out. The unexported contextKey type makes collisions impossible — no other package can even construct our key.

Think of it like

A courier’s job docket. It travels with the parcel, it says who the parcel is for, and it can be stamped CANCELLED mid-route. Chapter 6 (Connecting with pgx) used the cancellation stamp. This chapter uses the “who it’s for” line.

   request arrives
        |
        v
   ┌──────────────────────────────────────────────────────┐
   │ *http.Request                                        │
   │   Method  URL  Header  Body                          │
   │   ctx ──▶ ┌────────────────────────────────────┐     │
   │           │ context values                     │     │
   │           │   userContextKey ──▶ *user row     │     │
   │           └────────────────────────────────────┘     │
   └──────────────────────────────────────────────────────┘
        |                ^                       |
        |                | contextSetUser        | contextGetUser
        v                |                       v
   authenticate ─────────┘               listTasksHandler

Two things to notice. The context travels inside the request, so nothing global is involved and two simultaneous requests cannot see each other’s user. And the value goes in exactly once, in one place, and comes out wherever it is needed.

// cmd/api/context.go — new file
package main

import (
    "context"
    "net/http"

    "github.com/yourname/taskd/internal/db"
)

type contextKey string

const userContextKey = contextKey("user")

// AnonymousUser lets every request carry *some* user, so handlers
// never nil-check; they ask IsAnonymous instead.
var AnonymousUser = &db.GetUserForTokenRow{}

func isAnonymous(u *db.GetUserForTokenRow) bool { return u == AnonymousUser }

func (app *application) contextSetUser(r *http.Request, user *db.GetUserForTokenRow) *http.Request {
    return r.WithContext(context.WithValue(r.Context(), userContextKey, user))
}

// contextGetUser panics rather than returning an error, deliberately:
// a handler asking for a user on a route the middleware never guarded
// is a PROGRAMMER mistake (wrong route wiring), not a runtime
// condition — and recoverPanic (ch. 4) turns it into a logged 500
// that's impossible to miss in development.
func (app *application) contextGetUser(r *http.Request) *db.GetUserForTokenRow {
    user, ok := r.Context().Value(userContextKey).(*db.GetUserForTokenRow)
    if !ok {
        panic("missing user value in request context")
    }
    return user
}

What this code says, line by line

  1. type contextKey string — a brand-new type whose underlying representation is a string. The name starts with a lowercase letter, which in Go means unexported: no other package can refer to it. Context values are stored in a map keyed by any, and two keys are equal only if their type and value both match. A string "user" from someone else’s library is not equal to our contextKey("user"), because the types differ. This is what “collision-proof by construction” means: not a convention, a compile-time impossibility.

  2. var AnonymousUser = &db.GetUserForTokenRow{} — one shared, empty user object with a fixed address in memory. The & makes it a pointer.

  3. func isAnonymous(u *db.GetUserForTokenRow) bool { return u == AnonymousUser } — this compares pointers, not contents. It asks “is this the exact same object in memory as the one I created at startup?” Another empty &db.GetUserForTokenRow{} created somewhere else would have identical contents and still be a different address, so isAnonymous would say false. That is precisely the behaviour we want: only the one designated object counts as “nobody”.

    Think of it like

    Two identical blank forms are still two different pieces of paper. == on pointers asks “is this the same piece of paper?”, not “does it say the same thing?”

  4. r.WithContext(context.WithValue(r.Context(), userContextKey, user)) — read it inside out. r.Context() gets the current context. context.WithValue(parent, key, value) returns a new context that is the parent plus one entry. r.WithContext(...) returns a new request wearing that context. Contexts and requests are immutable here — you never modify one, you replace it, which is why the middleware must reassign r = app.contextSetUser(r, ...).

  5. r.Context().Value(userContextKey).(*db.GetUserForTokenRow) — the trailing .(T) is a type assertion. Value returns any (a value of unknown type); the assertion asks “are you really a *db.GetUserForTokenRow?” The two-result “comma-ok” form gives you the value and a boolean instead of crashing when the answer is no.

    New word

    type assertionv, ok := x.(T) means: if x is really a T, v is it and ok is true; otherwise v is T’s zero value and ok is false. Written without the ok (v := x.(T)) it panics on failure instead.

  6. panic("missing user value in request context") — deliberately fatal. Ask yourself when ok can be false: only if this handler ran on a route the authenticate middleware never covered. That is a wiring mistake in your own routing table, not something a user did. A returned error would be quietly ignored somewhere and produce a subtly wrong response for months; a panic is caught by recoverPanic (Chapter 4), logged with a stack trace, and turned into a 500 you cannot fail to notice on the first request in development.

Remember this

Panic for programmer errors, return errors for user errors. contextGetUser panicking is not sloppiness — it is a load-bearing decision about which failures should be loud.

Step 6 — The two middleware

New word

middleware — a function that wraps a handler: it runs, decides whether to call the handler it wrapped, and can run more code on the way back out. Chapter 4 (A server that dies well) built the first two. Airport security: everyone passes the same checks, in the same order, before reaching the gate.

// cmd/api/middleware.go (additions)
func (app *application) authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Tell caches responses differ per Authorization header —
        // see Pitfalls for the incident this line prevents.
        w.Header().Add("Vary", "Authorization")

        // No header at all is FINE: this middleware identifies, it
        // doesn't gate. The request proceeds as the AnonymousUser and
        // requireAuthenticatedUser decides later whether that's enough.
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            r = app.contextSetUser(r, AnonymousUser)
            next.ServeHTTP(w, r)
            return
        }

        // A PRESENT but malformed header is not fine — reject it
        // rather than quietly treating garbage as anonymous.
        // Expected shape: "Bearer <26-char-token>".
        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" || len(parts[1]) != 26 {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Hash the presented token and look THAT up — mirroring
        // generation. The plaintext never touches the database.
        hash := sha256.Sum256([]byte(parts[1]))
        user, err := app.q.GetUserForToken(r.Context(), db.GetUserForTokenParams{
            Hash: hash[:], Scope: data.ScopeAuthentication,
        })
        if err != nil {
            switch {
            case errors.Is(err, pgx.ErrNoRows):
                app.invalidAuthenticationTokenResponse(w, r)
            default:
                app.serverErrorResponse(w, r, err)
            }
            return
        }

        r = app.contextSetUser(r, &user)
        next.ServeHTTP(w, r)
    })
}

func (app *application) requireAuthenticatedUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if isAnonymous(app.contextGetUser(r)) {
            app.authenticationRequiredResponse(w, r)
            return
        }
        next.ServeHTTP(w, r)
    })
}

These additions need four more imports in middleware.go: crypto/sha256, errors, strings, and github.com/jackc/pgx/v5, plus the internal data and db packages.

What this code says, line by line

  1. w.Header().Add("Vary", "Authorization") — the first thing, before any decision, so it is set on every response including the error ones. What it does is in Pitfalls, and it is worth reading.

  2. r.Header.Get("Authorization") — reads the header. Get returns "" when the header is absent, so the empty string covers “no header at all”.

  3. The empty-header branch sets AnonymousUser and continues. This is the design decision the two-rings diagram illustrates: authenticate never rejects an anonymous request, because GET /v1/healthcheck and POST /v1/users are supposed to work without a token. It merely records “nobody”.

  4. strings.Split(authHeader, " ") — splits the header value on spaces. A correct header is Bearer FKF6…, which splits into exactly two parts. The three conditions that follow are deliberately strict:

    • len(parts) != 2 — rejects FKF6… alone, and rejects Bearer FKF6… with two spaces (which splits into three parts, the middle one empty).
    • parts[0] != "Bearer" — rejects Basic, bearer (case matters), or anything else.
    • len(parts[1]) != 26 — every token this system issues is exactly 26 characters. Anything else cannot be one of ours, so we reject it without touching the database. This also catches a trailing newline or a pair of quotes picked up while copying.
  5. sha256.Sum256([]byte(parts[1])) then Hash: hash[:] — hash what they sent, look up the hash. Exactly mirroring Step 2. The plaintext the client holds is never compared against anything stored, because nothing plaintext is stored.

  6. Scope: data.ScopeAuthentication — pinned to authentication. A password-reset token, when Chapter 22 adds them, is a real row in this same table with a real unexpired hash. This one word stops it from working as a login.

  7. errors.Is(err, pgx.ErrNoRows)invalidAuthenticationTokenResponse — “no row matched” covers three different situations with one answer: the token was never real, the token was deleted (revoked), or the token has expired. The client is told the same thing in all three cases, which is both simpler and less informative to an attacker.

  8. r = app.contextSetUser(r, &user)user is a value (GetUserForTokenRow); &user takes its address, because the context stores a pointer. The reassignment to r matters: the old r still has the old context, so next.ServeHTTP(w, r) must be handed the new one.

  9. requireAuthenticatedUser is nine lines and does one thing. It calls contextGetUser (which panics if authenticate never ran — the loud failure from Step 5), asks whether the user is the anonymous stand-in, and either sends a 401 or calls the next handler.

authenticate runs globally (it identifies, tolerating anonymity); requireAuthenticatedUser runs per route-group (it gates). Separating identification from authorization is the hinge that lets Chapter 17 (Entitlements) add plan-based gates cleanly.

Step 7 — Wire the routes

In the route listing below, read the r.Group as drawing a ring: everything registered inside it passes through the gate; everything outside (healthcheck, register, login) stays public — and the endpoints that MUST stay public are exactly the ones that create the credentials the gate demands. Chicken, meet egg, resolved.

// cmd/api/routes.go — the body of routes(), replacing what Chapter 8 left
r.Use(app.recoverPanic)
r.Use(app.logRequest)
r.Use(app.authenticate)

r.Route("/v1", func(r chi.Router) {
    r.Get("/healthcheck", app.healthcheckHandler)
    r.Post("/users", app.registerUserHandler)
    r.Post("/tokens/authentication", app.createAuthTokenHandler)

    r.Group(func(r chi.Router) {
        r.Use(app.requireAuthenticatedUser)
        r.Route("/tasks", func(r chi.Router) {
            r.Post("/", app.createTaskHandler)
            r.Get("/", app.listTasksHandler)
            r.Get("/{id}", app.showTaskHandler)
            r.Patch("/{id}", app.updateTaskHandler)
            r.Delete("/{id}", app.deleteTaskHandler)
        })
    })
})

What this code says

  • r.Use(...) order is wrapping order, outermost first. recoverPanic is outermost so it also catches panics thrown inside logRequest and authenticate — including the deliberate panic in contextGetUser. authenticate is last of the three, so by the time any handler runs, a user is in the context.
  • r.Group(func(r chi.Router) {...}) — chi’s way of saying “these routes, and only these, get this extra middleware”. It creates no URL prefix; it only draws the ring. Compare with r.Route("/tasks", ...), which does add a prefix.
  • The public three are inside /v1 but outside the group. They pass through authenticate (everything does) but never meet requireAuthenticatedUser.
  • r.Get("/", app.listTasksHandler) joins the four handlers Chapter 8 registered; this is the listing endpoint from Chapter 9 (Listing at scale), now finally behind a login.
Note

The original edition titles this step “Routes take their final shape”. They do not. Chapters 14, 15, 16, 17, 18, 21, 22, 23 and 24 add roughly a dozen more routes and several more middleware — a rate-limiting group around the two public POSTs, a third ring for activated users, the Stripe webhook, /metrics, /docs. What is final is the shape: r.Use for global middleware, r.Group for rings, r.Route for prefixes. Appendix F prints the complete final routes.go on one screen.

Part C — prove it

Step 8 — End-to-end

Start the server in one terminal (make run/api) and run these in another.

curl -d '{"name":"Sain","email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users
curl -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication
# {"authentication_token":{"token":"FKF6...","expiry":"..."}}

TOKEN=FKF6...
curl -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks
curl localhost:4000/v1/tasks     # 401 without it

What these commands say

  • curl -d '{...}' <url>-d sends a request body, and its presence makes the request a POST. The single quotes stop your shell from interpreting the JSON.
  • The backslash at the end of a line continues the command onto the next line. Type it or join the lines; both work.
  • TOKEN=FKF6... — a shell variable assignment. No spaces around the = (TOKEN = x is a different, broken command). Afterwards $TOKEN in any command is replaced by the value. You must paste your own token here — FKF6... is a placeholder for the 26 characters the previous command printed.
  • -H "Authorization: Bearer $TOKEN"-H adds a request header. Double quotes are required: inside single quotes the shell would not expand $TOKEN.
Tip

If you have jq (a command-line JSON tool) you can skip the copy-paste entirely:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

-r means “raw” — print the string without its surrounding quotes. Without -r you would capture "FKF6…" including the quote characters, making the value 28 characters long, and the middleware’s len(parts[1]) != 26 check would reject it. If you do not have jq, copy by hand and take only the 26 characters between the quotes.

What you should see

  1. The first command returns 201 with a JSON body under a "user" key containing id, created_at, name, email, activated and version — and no password field of any kind. If you have run this before, you get 422 with {"error":{"email":"a user with this email address already exists"}}; either use a different email or skip to the login.
  2. The second returns 201 with exactly the shape in the comment: an authentication_token object holding a 26-character token and an expiry timestamp roughly 24 hours in the future.
  3. The third returns 200 with a "tasks" array and a "metadata" object — Chapter 9’s listing response.
  4. The fourth returns 401 with {"error":"you must be authenticated to access this resource"}.
Note

From Chapter 21 (Background work and email) onward this exact sequence stops working: a migration flips the activated default to false and a third middleware ring requires an activated account, so step 3 will return 403 with {"error":"your account must be activated to access this resource"}. That chapter shows the new flow. It is noted here so that if you ever come back to re-seed data, you know why.


7. Checkpoint: prove it works

Run these four commands in order. Each one checks a different half of the system.

# 1. the server compiles and starts
go build ./cmd/api && echo BUILD-OK

You should see BUILD-OK. A compile error here is almost always a missing import — see Common mistakes.

# 2. a guarded route with no token
curl -i -s localhost:4000/v1/tasks | head -1

You should see a line beginning HTTP/1.1 401 Unauthorized.

# 3. a guarded route with a deliberately malformed token
curl -i -s -H "Authorization: Bearer nope" localhost:4000/v1/tasks | head -12

Among the header lines you should see WWW-Authenticate: Bearer and Vary: Authorization, a status line of HTTP/1.1 401 Unauthorized, and a body of {"error":"invalid or missing authentication token"}. (nope is four characters, so the length check rejects it before any database work happens.)

# 4. the plaintext token is genuinely not stored
make db/psql

At the taskd=# prompt:

SELECT encode(hash, 'hex'), user_id, scope, expiry FROM tokens;

You should see one row per successful login. encode(hash,'hex') prints the raw bytes as 64 hexadecimal characters, because bytea is unreadable otherwise. Look for your 26-character token in that output. It is not there, and it never will be. Type \q to leave.

If you got something else

You got Cause Fix
200 from command 2 requireAuthenticatedUser is not wrapping the /tasks routes — most likely they are registered outside the r.Group Re-check the nesting in Step 7: r.Group opens, r.Use immediately inside it, then r.Route("/tasks", …)
500 and a log line containing missing user value in request context A guarded handler ran on a router where authenticate was never mounted r.Use(app.authenticate) must be on the top-level router, before r.Route("/v1", …)
ERROR: relation "tokens" does not exist from command 4 The migration never ran make db/migrations/up, then log in again to create a row
401 even with a token you just received The token string is wrong — usually a trailing newline, a pair of quotes, or a truncated copy Re-run login, count the characters (26), or use the jq -r form above

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: ./middleware.go:44:11: undefined: sha256 It means: you added the middleware but not its imports. Go refuses to compile a file that uses a package it did not import — there is no implicit lookup. Fix: add "crypto/sha256", "errors", "strings", "github.com/jackc/pgx/v5" and the data/db internal packages to middleware.go’s import block. Your editor’s Go plugin will do this on save if gopls is installed.

Common mistake

You’ll see: {"error":"invalid or missing authentication token"} when you are certain the token is right. It means: the header shape is wrong. curl -H "Authorization: $TOKEN" — without the word Bearer — is the most common version; parts[0] != "Bearer" rejects it. Fix: curl -H "Authorization: Bearer $TOKEN". Try the broken version once on purpose so you recognise the message later.

Common mistake

You’ll see: the same invalid or missing authentication token, after copying the token out of the JSON output. It means: you captured the quote characters or a trailing newline, so the string is 27 or 28 characters and len(parts[1]) != 26 rejects it. The message does not say so, which is why this one costs people twenty minutes. Fix: take only the 26 characters between the quotes, or use jq -r as shown in Step 8.

Common mistake

You’ll see: panic: missing user value in request context in the server log, and a 500 with the server encountered a problem and could not process your request at the client. It means: exactly what it says, and it is the design working. A handler called contextGetUser on a route that authenticate never wrapped. Fix: check that r.Use(app.authenticate) is registered on the router that owns the route. This is not a bug to work around by returning nil — the panic is the alarm.

Two more that produce no error at all, which makes them the dangerous ones:

Symptom What it means Fix
Everything works; you used math/rand because it is the package you had heard of Every token you will ever issue is computable by anyone who sees a few of them The import must be crypto/rand. Check the import block, not the call site — the call reads rand.Read either way
Everything works; you left out w.Header().Add("Vary", "Authorization") Nothing fails until a shared cache serves one user’s data to another Add the line. It is one line and it prevents an incident you cannot debug from logs

9. Pitfalls

  • Comparing token hashes in Go. We don’t — the DB does the lookup by hash — but if you ever compare secrets in application code, use crypto/subtle.ConstantTimeCompare. == on secrets is a timing side-channel.

    New word

    timing side-channel — learning a secret by measuring how long an answer takes instead of reading it. Go’s == on byte slices stops at the first differing byte, so a guess sharing the first ten bytes takes measurably longer to reject than one differing immediately. An attacker who can time your responses recovers the secret one byte at a time. crypto/subtle.ConstantTimeCompare examines every byte regardless, so the time reveals nothing. Chapter 16 (Stripe webhooks) uses this idea for signature checking.

  • Expired-token buildup. Rows outlive their expiry; the join ignores them but the table grows. A nightly DELETE FROM tokens WHERE expiry < now() (cron, or a ticker goroutine) is the boring correct answer.

    New word

    cron — the Unix scheduler that runs a command on a timetable (“every night at 03:00”). ticker goroutine — the in-process equivalent: a small concurrent task that wakes on a timer and does the work itself, with no external scheduler to configure.

    Note

    The original leaves this as the reader’s first solo exercise. Chapter 21 (Background work and email) then writes it for you, as a DeleteExpiredTokens query driven by a janitor goroutine. Do it now anyway — Exercise 1 below walks you through it. Writing it yourself and then seeing the book’s version is worth more than either alone.

  • Vary: Authorization looks pedantic until a CDN or shared proxy caches an authenticated response and serves user A’s tasks to user B. One header line; set it.

    New word

    CDN — Content Delivery Network: a fleet of caching servers placed around the world in front of your service, so responses come from a machine near the user. Company proxies and some mobile networks cache the same way.

    Warning

    Here is the incident, concretely. A cache stores responses keyed by method and URL. User A requests GET /v1/tasks with their token; the CDN stores the body under the key GET /v1/tasks. User B requests the same URL with a different token. The cache sees a key it already has and returns A’s tasks — without the request ever reaching your server, so nothing appears in your logs and no amount of reading your Go code explains it. Vary: Authorization tells the cache “this response depends on the Authorization header too, so include it in the key.” One line, added before any branch in the middleware so it is on every response.

  • Context values as a junk drawer. Context is for request-scoped identity-ish data the framework layer resolves — not a general parameter bus. One key, typed, with panicking accessors. Resist expansion.

    Remember this

    If a handler needs a value, pass it as a function argument where the compiler can check it. Context values are invisible to the compiler; every one you add is a runtime surprise waiting for someone. taskd ends the book with exactly two: the user, and the request ID that Chapter 19 (Logging) adds.

The token’s life, start to finish

   GenerateToken()                       DeleteTokensForUser()
        |                                   (logout / password reset)
        v                                          |
   ┌───────────┐   INSERT   ┌──────────────────┐   |
   │ MINTED    │───────────▶│ VALID            │───┴──▶ REVOKED
   │ 26 chars  │            │ expiry > now()   │        (row gone,
   │ sent once │            │ scope matches    │         401 at once)
   └───────────┘            └────────┬─────────┘
                                     │ 24 hours pass
                                     v
                            ┌──────────────────┐
                            │ EXPIRED          │
                            │ the JOIN no      │
                            │ longer matches   │──▶ 401, row still there
                            └────────┬─────────┘
                                     │ DELETE (ch. 21's janitor)
                                     v
                                    gone

The gap between EXPIRED and gone is the point of the second pitfall: the token stops working the instant the clock passes expiry, but the row sits there until something deletes it.


10. Check yourself — quiz

  1. In one sentence, why does this book not use JWTs — and name the one situation where a JWT is the right answer.
  2. What does crypto/rand give you that math/rand does not, and what is the visible symptom if you use the wrong one?
  3. Why is the token 26 characters long? Where does that number come from?
  4. Chapter 10 said SHA-256 is the wrong hash for passwords. This chapter uses SHA-256 for tokens. Reconcile the two in one rule.
  5. Why does t.expiry > now() live in the SQL WHERE clause instead of an if statement in Go?
  6. authenticate lets a request with no Authorization header through. Isn’t that a security hole? What would break if it returned 401 instead?
  7. Read this line: func isAnonymous(u *db.GetUserForTokenRow) bool { return u == AnonymousUser }. If a future handler created its own &db.GetUserForTokenRow{} and passed it in, what would isAnonymous return, and why?
  8. contextGetUser panics instead of returning an error. Give the argument for that choice, and say what the reader sees when it fires.
Answers
  1. A JWT cannot be revoked: logout, “log out everywhere”, instant ban and stolen-token response all require server state, at which point you have rebuilt stateful tokens with extra parsing, an algorithm-confusion attack surface and key rotation on top. JWTs win when third parties must verify identity without calling you — federation, OIDC, a microservice mesh where the issuing service is not in the request path.

  2. crypto/rand draws from the operating system’s secure randomness source and is unpredictable; math/rand is a formula whose entire output stream follows from its seed. The visible symptom is none. No error, no warning, no failing test — just tokens an attacker can compute. That is why the import line matters more than almost any other line in the chapter.

  3. Sixteen random bytes are 128 bits. Base32 encodes 5 bits per character, and 128 ÷ 5 = 25.6, which rounds up to 26 characters. The middleware’s len(parts[1]) != 26 check is that same arithmetic used as a cheap early filter.

  4. Slow hash for low-entropy human secrets, fast hash for high-entropy random ones. A password has perhaps 30 bits of entropy — about a billion likely candidates — so each guess must be made expensive. A token has 128 bits from crypto/rand; at a trillion guesses a second the search takes on the order of 10¹⁸ years, so slowing the hash buys nothing and costs a quarter-second per request.

  5. Because a check in the WHERE clause is not a check that code can forget. Postgres evaluates expiry > now() and does not return the row, so every present and future code path that uses this query is correct by construction. An if in Go is one refactor away from being dropped.

  6. It is not a hole, because authenticate only identifies — the gating is done separately by requireAuthenticatedUser on the routes that need it. If authenticate returned 401 for a missing header, GET /v1/healthcheck, POST /v1/users and POST /v1/tokens/authentication would all become unreachable, and there would be no way to obtain the token the gate requires.

  7. false. The comparison is between pointers, not contents: it asks whether the argument is the exact object created by var AnonymousUser = &db.GetUserForTokenRow{} at startup. A different empty struct has identical contents at a different address, so it is not the anonymous user — which is the behaviour we want, since only the designated sentinel means “nobody”.

  8. The only way contextGetUser can fail is if the handler is mounted on a route that authenticate never wrapped — a mistake in your own routing table, not something a client did. Returning an error invites a caller to ignore it and ship a subtly wrong response; a panic is caught by recoverPanic, logged with a stack trace, and returned as a 500 reading “the server encountered a problem and could not process your request” — impossible to miss on the very first request in development.


11. Practice

Exercise 1 — Clean up expired tokens (easy)

Expired rows accumulate forever. Write the cleanup query, then run the raw SQL by hand to see it work.

Task. Add a DeleteExpiredTokens query to sql/queries/tokens.sql, regenerate, and confirm by hand in psql that expired rows are removable.

Verify. make sqlc succeeds, and grep -n DeleteExpiredTokens internal/db/tokens.sql.go finds a generated function.

Solution

Add one query to the bottom of the file:

-- sql/queries/tokens.sql — add at the end

-- name: DeleteExpiredTokens :exec
DELETE FROM tokens WHERE expiry < now();

Then regenerate:

make sqlc
grep -n "func (q \*Queries) DeleteExpiredTokens" internal/db/tokens.sql.go

You should see one matching line, showing sqlc generated func (q *Queries) DeleteExpiredTokens(ctx context.Context) error. Note it takes no parameters beyond the context, because the query has none.

To watch the SQL itself work, open make db/psql and run:

SELECT count(*) FROM tokens;
DELETE FROM tokens WHERE expiry < now();
SELECT count(*) FROM tokens;

If none of your tokens have expired yet, the DELETE reports DELETE 0 and both counts match — which is itself the correct result. Exercise 3 gives you an expired token to try it against.

Nothing calls this function yet. Chapter 21 (Background work and email) adds the janitor goroutine that runs it on a ticker; you have just built the piece it needs.

Exercise 2 — Add a logout endpoint (medium)

DeleteTokensForUser exists and nothing calls it. Give it a route.

Task. Add DELETE /v1/tokens/authentication, inside the authenticated group, that deletes all of the calling user’s authentication tokens and returns 200 with a plain confirmation message. After calling it, the token you used must stop working.

Hints. The handler needs the current user — that is what app.contextGetUser(r) is for. The query needs a db.DeleteTokensForUserParams. Use envelope and writeJSON like every other handler.

Verify. Log in, call the new endpoint with the token, then call GET /v1/tasks with the same token and get a 401.

Solution
// cmd/api/tokens.go — add this handler
func (app *application) deleteAuthTokensHandler(w http.ResponseWriter, r *http.Request) {
    user := app.contextGetUser(r)

    err := app.q.DeleteTokensForUser(r.Context(), db.DeleteTokensForUserParams{
        UserID: user.ID, Scope: data.ScopeAuthentication,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusOK, envelope{"message": "all authentication tokens revoked"}, nil)
}
// cmd/api/routes.go — add inside the r.Group that uses requireAuthenticatedUser
r.Delete("/tokens/authentication", app.deleteAuthTokensHandler)

It must go inside the guarded group: logging out requires knowing who you are, and contextGetUser would still return the anonymous user on a public route, deleting nobody’s tokens.

Verify:

TOKEN=<your 26 characters>
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks | head -c 40
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
  localhost:4000/v1/tokens/authentication
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

The first command prints the start of a task listing. The second prints {"message":"all authentication tokens revoked"}. The third prints {"error":"invalid or missing authentication token"} — the row is gone, so the join matches nothing.

That third response is the entire argument of this chapter, demonstrated in one command. With a JWT, the token would still verify.

Exercise 3 — Prove the database enforces the expiry (harder)

Do not take it on trust that expiry > now() works. Make a token expire while you watch.

Task. Temporarily change the login handler’s TTL from 24 hours to 30 seconds. Log in, use the token successfully, wait, use it again, and observe the 401. Then put the TTL back.

Verify. The same token and the same command give 200 before the wait and 401 after, with no code change in between.

Solution

One character group changes, in createAuthTokenHandler:

// cmd/api/tokens.go — TEMPORARY: 30 seconds instead of 24*time.Hour
token, err := data.GenerateToken(user.ID, 30*time.Second, data.ScopeAuthentication)

Restart the server, then:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks | head -c 40
sleep 35
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

(Without jq, copy the 26 characters by hand and set TOKEN= yourself.)

Before the wait you get the start of a task listing. After it you get {"error":"invalid or missing authentication token"}.

Now the part that makes the point. Open make db/psql and run:

SELECT user_id, scope, expiry, expiry > now() AS still_valid FROM tokens;

The row is still there. Its still_valid column reads f (false). Nothing deleted it and no Go code examined it — the token stopped working because a WHERE clause stopped matching. That is what “the expiry check lives in SQL so no Go code path can forget it” means in practice, and it is also the pitfall about buildup, visible: expired but present.

Change 30*time.Second back to 24*time.Hour and restart before continuing.


12. FAQ

Isn’t a database query on every single request slow?

It is one indexed lookup on a primary key — the cheapest thing a relational database does, typically well under a millisecond on the same machine. Compare it with what the request is about to do anyway: query tasks, serialise JSON, cross the network. The honest framing is that avoiding this lookup is an optimisation, and you should only make optimisations you have measured. Chapter 13 (Caching) looks at exactly this lookup and decides to leave it alone, because caching identity is how a banned user stays logged in for another five minutes.

Why not JWT? Everyone uses JWT.

Everyone uses JWT because every tutorial uses JWT, and every tutorial uses JWT because it demonstrates well in a blog post with no database. The moment your product needs a logout button that actually logs someone out, you add a table of revoked tokens — and now you have a database lookup on every request plus signature verification, key rotation and a parsing attack surface. Start where you were going to end up. If you later build a system where another company’s servers must verify your users without calling you, use JWTs there; that is what they are for.

What happens if someone steals a token?

They are that user until the token expires or you delete the row. This is what “bearer” means: whoever bears it is treated as the holder. Three things reduce the damage, and all three are in this design: a 24-hour expiry limits the window, DELETE FROM tokens WHERE user_id = … ends it immediately (Exercise 2 gives you the button), and storing only the hash means a leak of your database leaks no usable tokens at all. The remaining exposure is a token in transit or in a client’s storage, which is why Chapter 27 (Going live) puts TLS in front of everything.

Why can’t I see my token again after logging in?

Because nothing that could show it to you exists. The 26-character string is created in memory, written into one HTTP response, and discarded; only its SHA-256 hash reaches the database, and that cannot be reversed. This is the same design as a well-built password reset, and it is deliberate: “show me my token again” is also the feature an attacker with brief access to your account would use. Lost your token? Log in again and get a new one — the old one keeps working until it expires, which is fine, or you can call the logout endpoint from Exercise 2 first.

What’s the difference between authentication and authorization?

Authentication answers who are you; authorization answers what may you do. This chapter is entirely about the first. requireAuthenticatedUser looks like authorization but is not — it only insists that the answer to “who are you” is not “nobody”. Real authorization arrives in Chapter 12 (Ownership: you may only see your own tasks), Chapter 17 (Entitlements: your plan permits this many tasks) and Chapter 21 (Activation: your email must be confirmed first). Keeping the two separated is why each of those chapters is short.

Should the token go in a cookie instead of a header?

If your client is a browser rendering pages on your own domain, a cookie with HttpOnly, Secure and SameSite set is a defensible choice, because JavaScript cannot read an HttpOnly cookie. The cost is that browsers send cookies automatically, which opens the door to cross-site request forgery and means you need CSRF tokens on top. taskd is an API consumed by clients that attach headers deliberately — mobile apps, scripts, other servers — so Authorization: Bearer is the right fit, and it sidesteps CSRF entirely. The server-side design in this chapter is unchanged either way; only where the client keeps the string differs.


13. Where we are

The API has a front door. POST /v1/users creates an account, POST /v1/tokens/authentication trades a password for a 26-character bearer token, and the five task endpoints are behind a gate that returns 401 to anyone without one. The password is checked once, slowly, on purpose; every later request costs one indexed lookup. Nothing in the database can be replayed as a credential.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go          # UPDATED: authenticate + the guarded group
│   ├── config.go
│   ├── db.go
│   ├── middleware.go      # UPDATED: authenticate, requireAuthenticatedUser
│   ├── helpers.go
│   ├── errors.go          # UPDATED: three 401 responses
│   ├── context.go         # NEW: contextKey, AnonymousUser, set/get user
│   ├── healthcheck.go
│   ├── tasks.go
│   ├── users.go
│   └── tokens.go          # NEW: createAuthTokenHandler
├── internal/
│   ├── data/
│   │   ├── filters.go
│   │   ├── tasks.go
│   │   ├── users.go
│   │   └── tokens.go      # NEW: Token, GenerateToken, ScopeAuthentication
│   ├── db/                # sqlc output — tokens.sql.go is NEW
│   └── validator/
├── migrations/            # NEW: 000003_create_tokens up + down
├── sql/queries/           # NEW: tokens.sql
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: register, log in, receive a token, use it, be rejected without it, and be rejected with an expired or revoked one.

What is still fake or missing:

  • Tasks are not yet owned by anyone. Every authenticated user sees and edits everyone’s tasks, because tasks has no user_id column yet. Chapter 12 (Ownership) fixes this, and it is the most important security chapter in the book.
  • Nothing limits login attempts. The endpoint that does a bcrypt comparison is also the one an attacker would hammer. Chapter 14 (Rate limiting) puts a per-IP limiter around it.
  • Expired token rows accumulate. Chapter 21 adds the janitor; Exercise 1 built the query.
  • activated is always true. Chapter 21 flips the default and adds the email round-trip that makes it mean something.

For your notes

Copy these into learnings/ch11.md, in your own words:

  1. Slow hash for low-entropy human secrets; fast hash for high-entropy random ones. bcrypt for passwords because guesses are cheap and must be made expensive; SHA-256 for tokens because there is nothing to guess.
  2. A JWT’s strength — no lookup needed — is exactly its weakness: no revocation. Anything that restores revocation restores the lookup. Choose statefulness on purpose.
  3. Put the safety check where code cannot forget it. expiry > now() in the WHERE clause is stronger than any if statement, because there is no path around it.
  4. Identification and gating are two different middleware. authenticate tolerates anonymity; requireAuthenticatedUser refuses it. Separating them is what makes later gates cheap to add.
  5. Panic for programmer errors, return errors for user errors. contextGetUser panicking on a badly wired route is a feature: it fails loudly on the first request instead of quietly for months.

Chapter 12 — Ownership: making it multi-tenant

Chapter 11 (Stateful tokens) put a lock on the front door. Every request to /v1/tasks now arrives with a token, and the server knows which user sent it. It does not care. The tasks table has no idea who owns what, so any logged-in person can read, edit and delete every task in the database — including yours. This chapter closes that gap by adding one column, user_id, and then putting it into the WHERE clause of every single query that touches tasks. It is the shortest chapter in the book and the most important one for anybody who will ever have two customers.

What you’ll be able to do by the end

  • Explain what multi-tenant means, and why one shared database is the normal way to build a SaaS.
  • Add a required column to a table that already has rows, and say out loud why production does it in three steps instead of one.
  • Register two users, create tasks as each, and prove that neither can see or touch the other’s.
  • Explain why the answer to “may I have someone else’s task?” is 404 and not 403.
  • Read a wall of compiler errors as a to-do list rather than a disaster.

Time: ~35 minutes reading, ~30 minutes typing.

You need before starting: a working Chapter 11 (Stateful tokens). Prove it in two commands — log in, then use the token:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

You should get a JSON body containing a "metadata" key. If you get {"error":"invalid authentication credentials"}, register that user first with the POST /v1/users command from Chapter 11.

New word

jq — a small command-line tool that pulls one value out of a JSON blob. jq -r .a.b means “print the string at key b inside key a, raw, without quotes”. Install it with brew install jq or apt install jq.


1. The problem, in plain words

Imagine an apartment block. One building, one front door, one landlord, one set of pipes — and forty separate locked flats. The tenants share almost everything: the structure, the plumbing, the bins. What they emphatically do not share is each other’s living rooms.

That is how essentially every online product you pay for is built. There is one database, one running program, one set of tables — and inside those tables, rows belonging to thousands of different customers, sitting next to each other. The system’s entire job is to make sure that Alice’s rows and Bob’s rows never meet.

New word

tenant — one customer’s data inside a shared system. multi-tenant — many customers share one database and one running program, with strict separation enforced by the software. The alternative, one database per customer, is called single-tenant and costs far more to run.

Right now taskd is a building with one enormous unlocked room in it. Watch:

Alice logs in, creates task 1.        Bob logs in, asks for task 1.
                                      Bob gets task 1.

Nothing in the code stops that, because nothing in the code knows task 1 is Alice’s. The tasks table from Chapter 5 (PostgreSQL and migrations) has id, title, notes, status, priority, due_at, version — and no owner.

What breaks if we skip this chapter is not subtle. There is no product. “Your tasks” is a phrase with no meaning. Billing has nothing to meter, because everyone’s usage is everyone’s. And the first time two real people use the service, one of them reads the other’s private notes.

There is a second, quieter reason this chapter matters. Almost every serious security incident in a SaaS product is not a clever cryptographic break. It is one endpoint, written on a Friday, that forgot to check who was asking. The whole of this chapter is about arranging the code so that forgetting is not possible.


2. New words in this chapter

Word What it means here
tenant One customer’s data inside a shared system.
multi-tenant Many customers share one database and one program, kept apart by software.
tenant filter / scoping Putting AND user_id = $2 inside every query, so the safe path is the only path.
fail closed When something goes wrong, deny by default — return nothing rather than everything.
fail open When something goes wrong, allow by default. Sometimes right (fairness), usually wrong (security).
oracle (security) Any behaviour that answers an attacker’s question for free.
enumeration Walking through IDs or emails one by one to discover which ones exist.
foreign key / REFERENCES A column that must match a row in another table, so the database itself forbids orphans.
ON DELETE CASCADE Deleting the referenced row automatically deletes the rows pointing at it.
ALTER TABLE The SQL statement that changes the shape of an existing table.
TRUNCATE Deletes every row in a table, fast, with no WHERE clause and no undo.
backfill Filling in values for existing rows when a new required column is added.
index An extra sorted structure the database keeps so it can find matching rows without reading them all.
sequential scan Reading every row in a table because no usable index exists; gets linearly slower as the table grows.
composite index An index over two columns together, e.g. (user_id, created_at).
EXPLAIN ANALYZE The command that shows how Postgres actually ran your query, and how long each part took.
GDPR European data-protection law; the reason “delete my account” has to really delete data.
row-level security (RLS) A Postgres feature that attaches the tenant filter to the table itself. Discussed in the FAQ; not used here.
soft delete Marking a row deleted with a deleted_at column instead of removing it.

3. The goal

Every task belongs to a user; every query is scoped to the requesting user; cross-user access is a 404 — not even a 403, because other people’s task IDs should be indistinguishable from nonexistent ones.


4. The thinking

The one discipline

The security model of almost every SaaS reduces to one discipline:

Remember this

The tenant filter lives inside every query, not in handler-level checks.

There is an obvious-looking alternative, and most beginners reach for it first because it reads better in Go:

// The tempting version. Do not write this.
task, err := app.q.GetTask(r.Context(), id)
if task.UserID != user.ID {
    app.notFoundResponse(w, r)
    return
}

That works. It works right up until one new endpoint forgets the comparison — and that endpoint is the breach. Compare the two designs by asking a single question: what happens when a tired person forgets?

Check in Go (if task.UserID != user.ID) Filter in SQL (AND user_id = $2)
Forgetting it Returns everyone’s data Returns nothing
How you find out A customer, or the press Your own test, in five minutes
Cost of the mistake Data breach Bug report
Who enforces it Your memory The query
Does the compiler help? No — the code compiles perfectly Yes, for most call sites (Step 4)

The right-hand column fails closed. The left-hand column fails open.

New word

fail closed / fail open — when something breaks, does the system deny by default or allow by default? A door that locks in a power cut fails closed; one that unlocks fails open. Both are correct choices for different doors. For access to other people’s data, closed is the only answer.

Think of it like

A tenant filter is not a security guard checking a list at the door. It is the flat’s own lock: the wrong key produces no argument, no explanation and no entry — it does not turn.

Why sqlc turns this into a checklist

Chapter 7 (sqlc: SQL in, type-safe Go out) made a bet: write plain SQL, and let a code generator turn each query into a typed Go function. This chapter is where that bet pays.

Adding user_id to the queries changes their generated signatures. GetTask stops taking a bare int64 and starts taking a db.GetTaskParams struct with two fields. That is a type change, so the compiler refuses to build the program until every call site is updated — and the list of errors it prints is, quite literally, the list of endpoints that are not yet tenant-scoped.

The schema change is the security review checklist.

Note

The original edition says the compiler lists “every call site that isn’t tenant-scoped yet”. That is true for three of the six call sites, and it is worth knowing exactly which three and why. Go allows a struct literal to name only some fields — db.CreateTaskParams{Title: ...} still compiles after a UserID field is added, with UserID left at its zero value, 0. Step 4 walks all six and shows why even the silent three still fail closed. Do not let a green build be your only proof; the two-user drill in Step 5 is.

Honest schema evolution

We also get to practise something every working engineer eventually has to do: adding a NOT NULL foreign-key column to a table that already has rows.

You cannot do it in one statement. NOT NULL means “no row may leave this blank”, and the rows already there have nothing to put in it. Postgres refuses:

ERROR: column "user_id" of relation "tasks" contains null values (SQLSTATE 23502)

The production pattern is three steps, because each of the three is individually safe and fast, and none of them locks the table long enough to be an outage. Here it is beside the shortcut this book actually takes:

  PRODUCTION — the table holds             DEV — the rows are yours,
  customers' rows                          created five minutes ago
 ┌───────────────────────────────┐        ┌──────────────────────────┐
 │ 1. ADD COLUMN user_id bigint  │        │ TRUNCATE tasks;          │
 │    (nullable — instant, safe) │        │                          │
 ├───────────────────────────────┤        │ ADD COLUMN user_id       │
 │ 2. UPDATE tasks SET user_id=… │        │   bigint NOT NULL        │
 │    in batches — the BACKFILL  │        │                          │
 ├───────────────────────────────┤        └──────────────────────────┘
 │ 3. ALTER COLUMN user_id       │         one statement, no downtime,
 │    SET NOT NULL               │         because there is nothing
 └───────────────────────────────┘         left to lose
   three migrations, no outage,
   nobody's data touched

In development our rows are throwaway test data, so we take the honest shortcut: delete them all and add the column in its final form. We say so in a comment in the migration, out loud, so nobody ever copies that file into a live system.

New word

backfill — filling in values for rows that already exist, so a newly-required column has something legal in it before you make it required.


5. A picture of it

The filter, drawn

Here is the entire chapter in one diagram: the same table, the same request, with and without four extra words in the WHERE clause.

tasks, after this chapter — three customers' rows in one table
┌────┬─────────┬────────────────────────┐
│ id │ user_id │ title                  │
├────┼─────────┼────────────────────────┤
│  1 │    7    │ Alice: pay invoice     │
│  2 │    7    │ Alice: call the bank   │
│  3 │   12    │ Bob: rewrite CV        │
│  4 │   31    │ Carol: book flights    │
└────┴─────────┴────────────────────────┘

Bob (user_id 12) sends: GET /v1/tasks/1

     WITHOUT the filter                  WITH the filter
  ┌────────────────────────┐        ┌──────────────────────────────┐
  │ SELECT * FROM tasks    │        │ SELECT * FROM tasks          │
  │ WHERE id = 1;          │        │ WHERE id = 1 AND user_id = 12│
  └───────────┬────────────┘        └──────────────┬───────────────┘
              ▼                                    ▼
  ┌────────────────────────┐        ┌──────────────────────────────┐
  │ 1 │ 7 │ pay invoice    │        │ (no rows)                    │
  └───────────┬────────────┘        └──────────────┬───────────────┘
              ▼                                    ▼
      200 OK + Alice's data              pgx.ErrNoRows ──▶ 404

The right-hand path needs no if statement in Go, and that is the point. Chapter 8 (CRUD done properly) already wrote errors.Is(err, pgx.ErrNoRows)notFoundResponse for the case “there is no task 1”. That same branch now also handles “task 1 is not yours”, because the database returns the identical error for both.

New word

pgx.ErrNoRows — the error the pgx driver returns when a query that expected one row got zero. It is a plain sentinel value, so errors.Is can compare against it.

The compiler as escort

What Steps 3 and 4 will feel like:

 sql/queries/tasks.sql       make sqlc            go build ./...
 ┌──────────────────┐     ┌──────────────┐     ┌────────────────────┐
 │ + user_id in     │ ──▶ │ regenerate   │ ──▶ │ compile errors —   │
 │   every query    │     │ internal/db  │     │ one per call site  │
 └──────────────────┘     └──────────────┘     │ whose ARGUMENTS    │
                                               │ changed shape      │
                                               └─────────┬──────────┘
                                                         │
              fix each: + user := app.contextGetUser(r)  │
                        + UserID: user.ID                │
                                                         ▼
                                               ┌────────────────────┐
                                               │ green build, then  │
                                               │ the two-user drill │
                                               └────────────────────┘

Walking it through: (1) you edit SQL, which is the only place the tenant rule is written down; (2) sqlc regenerates the Go functions, changing their parameter types; (3) go build refuses, naming files and line numbers; (4) each error is fixed with the same two lines; (5) the drill in Step 5 catches the call sites the compiler could not.


6. The steps

Step 1 — Add the user_id column

Create the migration pair. make db/migrations/new is the Makefile target from Chapter 5; it runs migrate create -seq -ext sql -dir ./migrations, which makes two empty numbered files.

make db/migrations/new name=add_user_id_to_tasks

You should see two file paths printed, both ending in migrations/000004_add_user_id_to_tasks.up.sql and ...down.sql.

Now the up file:

-- migrations/000004_add_user_id_to_tasks.up.sql
-- up: dev-grade — production would backfill instead of truncate.
TRUNCATE tasks;
ALTER TABLE tasks
    ADD COLUMN user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE;
CREATE INDEX idx_tasks_user_id ON tasks (user_id);

What this code says, line by line

  • TRUNCATE tasks; — empties the table. Every row, immediately, no WHERE, no undo. We need the table empty because the next statement adds a column that forbids blanks and supplies no default. This line is safe here and catastrophic anywhere near a customer; the comment above it is not decoration.
  • ALTER TABLE tasks ADD COLUMN ...ALTER TABLE is how you change a table’s shape after it exists. Migrations are how you do that in a reviewable, repeatable way.
  • user_id bigint — a big integer, matching the type of users.id. Foreign keys must match the type they point at.
  • NOT NULL — every task must have an owner. There is no such thing as an unowned task; the database now enforces that, not the Go code.
  • REFERENCES users — this is the foreign key. It says: the value in this column must be the id of a real row in users. Postgres will reject an insert naming a user who doesn’t exist. You can write REFERENCES users (id); when you omit the column, Postgres uses the referenced table’s primary key, which is what we want.
  • ON DELETE CASCADE — what to do when the referenced user row is deleted. CASCADE means “delete these rows too”. Without it, Postgres would refuse to delete a user who still has tasks.
  • CREATE INDEX idx_tasks_user_id ON tasks (user_id); — see the next callout.
Warning

That index is not optional. From this point on, every query against tasks filters by user_id. Without an index, Postgres answers each one with a sequential scan — reading every row in the table and throwing away the ones that don’t match. That cost grows in a straight line with every signup, so the service gets slower for everybody each time it succeeds. With the index, Postgres jumps straight to that user’s rows.

New word

index — an extra sorted structure the database maintains alongside the table, so it can find matching rows without reading all of them. Like the index at the back of a book: you could read all 400 pages looking for “goroutine”, or you could look it up. Indexes cost a little space and a little write time, and they are how databases stay fast.

Now the down file. Chapter 5 promised every migration would be paired with one that undoes it; the original edition prints this one only for migrations 000001 and 000002, so here it is.

-- migrations/000004_add_user_id_to_tasks.down.sql
-- Shown here for the first time: the mechanical inverse of the up file.
DROP INDEX IF EXISTS idx_tasks_user_id;
ALTER TABLE tasks DROP COLUMN IF EXISTS user_id;

IF EXISTS means “and don’t complain if it’s already gone”, which makes the file safe to run twice. Note that down migrations undo structure, never data: the rows TRUNCATE removed are not coming back.

Apply it:

make db/migrations/up

You should see a line beginning 4/u add_user_id_to_tasks followed by a duration in parentheses. Confirm the shape of the table:

make db/psql

At the taskd=# prompt, type \d tasks and press Enter. In the column list you should see a row for user_id of type bigint with not null. Below the columns, under Indexes:, you should see a line naming idx_tasks_user_id, and under Foreign-key constraints: a line naming tasks_user_id_fkey with ON DELETE CASCADE. Type \q to leave.

Note

sqlc.yaml sets schema: "migrations", which means sqlc reads this same folder to learn the table shapes — including the TRUNCATE tasks; line. That is harmless: sqlc is looking for definitions, and TRUNCATE defines nothing.

Step 2 — Put the filter in every query

This is the whole security change. Open sql/queries/tasks.sql. The edits, as diffs:

-- CreateTask: add the column
INSERT INTO tasks (user_id, title, notes, priority, due_at)
VALUES ($1, $2, $3, $4, $5) RETURNING *;

-- GetTask / UpdateTask / DeleteTask: scope the WHERE
WHERE id = $1 AND user_id = $2 ...

-- ListTasks: first predicate becomes
WHERE user_id = sqlc.arg('user_id') AND (sqlc.narg('status') ... )

-- and one new query for ch. 17's quotas, while we're in the file:
-- name: CountActiveTasks :one
SELECT count(*) FROM tasks WHERE user_id = $1 AND status <> 'archived';

Two of those need unpacking before you can apply them.

The $ numbers are positions, and inserting one renumbers the rest. In SQL, $1, $2 … are bind parameters: placeholders the driver fills in safely, so user input never becomes SQL. They are numbered by position, not by name. Chapter 8’s UpdateTask used $2 for title and $7 for version. Making user_id the second parameter pushes everything else up by one, so version becomes $8. The diff above cannot be pasted literally into UpdateTask — you have to renumber. That is exactly why the full file is printed below.

sqlc.arg versus sqlc.narg. ListTasks uses named parameters instead of numbers, because Chapter 9 (Listing at scale) needed the same value in several places. sqlc.arg('x') declares a required parameter; sqlc.narg('x') declares a nullable one, which sqlc turns into a Go pointer where nil means “the client didn’t filter on this”. user_id is required, so it is sqlc.arg.

Common mistake

You’ll see: nothing — no error at all. It means: if you write sqlc.narg('user_id') by mistake, the parameter becomes a *int64, nil is a legal value, and WHERE user_id = NULL matches no rows for anybody. Fix: sqlc.arg('user_id'). Required things get arg; optional things get narg.

Here is sql/queries/tasks.sql in full, after this chapter’s edits. The original edition gives only the diffs above; this is the finished file, and since the chapter’s own claim is that the schema change is the security review checklist, the checklist should be legible.

-- sql/queries/tasks.sql — replaces the whole file
-- The comment line IS the interface: "name:" becomes the Go
-- function's name; ":one" declares it returns exactly one row.
-- $1..$5 become the fields of a generated CreateTaskParams struct, in order.
-- name: CreateTask :one
INSERT INTO tasks (user_id, title, notes, priority, due_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;      -- hand the inserted row straight back: id, timestamps and all

-- name: GetTask :one
SELECT * FROM tasks
WHERE id = $1 AND user_id = $2;

-- :execrows returns HOW MANY rows were affected — which is how the
-- handler distinguishes "deleted" (1) from "never existed" (0).
-- name: DeleteTask :execrows
DELETE FROM tasks WHERE id = $1 AND user_id = $2;

-- The optimistic lock lives in two places here:
--   SET version = version + 1     every successful write bumps the counter
--   WHERE ... AND version = $8    and only succeeds against the version
--                                 the client actually read.
-- If someone else wrote first, versions no longer match, zero rows update,
-- and :one reports pgx.ErrNoRows — which the handler translates to 409.
-- name: UpdateTask :one
UPDATE tasks
SET title = $3, notes = $4, status = $5, priority = $6, due_at = $7,
    version = version + 1, updated_at = now()
WHERE id = $1 AND user_id = $2 AND version = $8
RETURNING *;

-- name: ListTasks :many
SELECT sqlc.embed(tasks), count(*) OVER() AS total_count
FROM tasks
WHERE user_id = sqlc.arg('user_id')
  AND (sqlc.narg('status')::text   IS NULL OR status   = sqlc.narg('status'))
  AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
  AND (sqlc.narg('search')::text   IS NULL
       OR title ILIKE '%' || sqlc.narg('search') || '%')
ORDER BY
  CASE WHEN sqlc.arg('sort')::text = 'created_at' THEN created_at END ASC,
  CASE WHEN sqlc.arg('sort')::text = '-created_at' THEN created_at END DESC,
  CASE WHEN sqlc.arg('sort')::text = 'due_at'      THEN due_at END ASC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = '-due_at'     THEN due_at END DESC NULLS LAST,
  CASE WHEN sqlc.arg('sort')::text = 'priority'    THEN priority END ASC,
  id ASC
LIMIT sqlc.arg('page_limit') OFFSET sqlc.arg('page_offset');

-- name: CountActiveTasks :one
SELECT count(*) FROM tasks WHERE user_id = $1 AND status <> 'archived';

Read the file as a checklist. Six queries; all six mention user_id. CreateTask writes it; the other five filter on it. Any future query you add to this file that does not mention user_id is, by construction, a query that can read across tenants — and the whole checklist is one short file you can re-read in twenty seconds.

Note

CountActiveTasks is not used by anything yet. It is added now because we are already editing this file, and Chapter 17 (Entitlements and quotas) needs it to enforce “your plan allows 50 active tasks”. <> is SQL for “not equal to”. Adding it now costs one line; adding it later costs another trip through make sqlc and another review.

Step 3 — Regenerate the Go code

make sqlc

That runs sqlc generate, which reads migrations/ for the table shapes and sql/queries/ for the queries, and rewrites internal/db/. It prints nothing on success. It is a code generator, so never edit its output — the header of every file it writes says DO NOT EDIT and means it.

Look at what changed, if you like:

git diff --stat internal/db/

You should see internal/db/models.go and internal/db/tasks.sql.go listed as modified. Inside them, the Task struct has gained a UserID int64 field, each of the five params structs has gained one too, and a new CountActiveTasks function has appeared.

Common mistake

You’ll see: cmd/api/tasks.go:47:44: undefined: db.GetTaskParams (your numbers will differ) It means: you edited the Go handlers before running make sqlc, so the type you are naming does not exist yet. Fix: run make sqlc first, always. SQL is the source; Go is downstream of it.

Step 4 — Let the compiler escort you

go build ./...

This fails on purpose, and it is the moment the whole sqlc bet pays off.

You should see a header line naming the package — # github.com/yourname/taskd/cmd/api — followed by errors of this shape:

cmd/api/tasks.go:62:38: cannot use id (variable of type int64) as db.GetTaskParams value in argument to app.q.GetTask

The line and column numbers in your copy will be different; the sentence is the same. Read it in English: you handed this function an int64; it now wants a db.GetTaskParams. There will be three of these, in showTaskHandler, updateTaskHandler and deleteTaskHandler — the three call sites that passed a bare id.

Fix each one identically. Two lines: fetch the user out of the request context (Chapter 11 put it there), then name it in the params struct.

user := app.contextGetUser(r)
// ...
task, err := app.q.GetTask(r.Context(), db.GetTaskParams{ID: id, UserID: user.ID})

Here are the two shortest handlers in full, after the change. cmd/api/tasks.go keeps the imports it already had.

// cmd/api/tasks.go — replaces showTaskHandler and deleteTaskHandler
func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) {
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}

	user := app.contextGetUser(r)

	task, err := app.q.GetTask(r.Context(), db.GetTaskParams{ID: id, UserID: user.ID})
	if err != nil {
		switch {
		case errors.Is(err, pgx.ErrNoRows):
			app.notFoundResponse(w, r) // "no such task" AND "not your task"
		default:
			app.serverErrorResponse(w, r, err)
		}
		return
	}
	app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil)
}

func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) {
	id, err := app.readIDParam(r)
	if err != nil {
		app.notFoundResponse(w, r)
		return
	}

	user := app.contextGetUser(r)

	rows, err := app.q.DeleteTask(r.Context(), db.DeleteTaskParams{ // :execrows
		ID: id, UserID: user.ID,
	})
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}
	if rows == 0 {
		app.notFoundResponse(w, r)
		return
	}

	app.writeJSON(w, http.StatusOK,
		envelope{"message": "task successfully deleted"}, nil)
}

What this code says, line by line

  • app.contextGetUser(r) — Chapter 11’s authenticate middleware looked up the token, found the user, and stashed it in the request’s context. This pulls it back out. It returns *db.GetUserForTokenRow; the field we want is .ID.
  • db.GetTaskParams{ID: id, UserID: user.ID} — a struct literal with named fields. sqlc generated this struct from the query’s two $ parameters, so the Go type and the SQL now agree by construction.
  • rows == 0 in delete — :execrows returns the number of rows the DELETE affected. Zero now means either “no such task” or “not yours”, and both deserve the same 404.

updateTaskHandler has two calls, and only the first of them was flagged. Change both:

// cmd/api/tasks.go — inside updateTaskHandler, the two calls that change
	user := app.contextGetUser(r)

	// (1) the fetch, before the overlay — THIS one the compiler flagged:
	task, err := app.q.GetTask(r.Context(), db.GetTaskParams{ID: id, UserID: user.ID})

	// (2) the write, after validation — the compiler said nothing about this
	//     one. Note that version moved to $8 in the SQL, but Go names its
	//     fields, so the only edit here is the new UserID.
	updated, err := app.q.UpdateTask(r.Context(), db.UpdateTaskParams{
		ID: task.ID, UserID: user.ID, Title: task.Title, Notes: task.Notes,
		Status: task.Status, Priority: task.Priority, DueAt: task.DueAt,
		Version: task.Version,
	})

That second call is the first of three call sites the compiler will not flag, because they already passed a params struct and adding a field to a struct breaks nothing. You have to find them yourself: UpdateTask above, plus these two.

// cmd/api/tasks.go — inside createTaskHandler, after the validation block
	user := app.contextGetUser(r)

	task, err := app.q.CreateTask(r.Context(), db.CreateTaskParams{
		UserID:   user.ID,
		Title:    input.Title,
		Notes:    input.Notes,
		Priority: input.Priority,
		DueAt:    input.DueAt,
	})
// cmd/api/tasks.go — inside listTasksHandler, after the validation block
	user := app.contextGetUser(r)

	rows, err := app.q.ListTasks(r.Context(), db.ListTasksParams{
		UserID:     user.ID,
		Status:     nilIfEmpty(status),
		Priority:   nilIfEmpty(priority),
		Search:     nilIfEmpty(search),
		Sort:       f.Sort,
		PageLimit:  f.Limit(),
		PageOffset: f.Offset(),
	})
Important

Why doesn’t the compiler catch those three? Because Go lets a struct literal name only some of its fields; the rest get their zero value. Adding a UserID int64 field to CreateTaskParams leaves db.CreateTaskParams{Title: ...} compiling happily with UserID: 0. Now look at what a forgotten one actually does: CreateTask with user 0 violates the foreign key and returns a 500; ListTasks with user 0 returns an empty list; UpdateTask with user 0 matches nothing and returns a 409. All three are wrong. All three fail closed. Nobody gets anybody else’s data. That is the design earning its keep even where the compiler is silent.

Build again:

go build ./... && echo TENANCY-BUILDS

You should see TENANCY-BUILDS.

Step 5 — Prove isolation with two users

A green build means the code is consistent. It does not mean the code is correct. Register two users, give each a task, and check four things. Start the server (make run/api) and run these in another terminal.

# two accounts
curl -s -d '{"name":"Alice","email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users > /dev/null
curl -s -d '{"name":"Bob","email":"bob@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users > /dev/null

# two tokens
A=$(curl -s -d '{"email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
B=$(curl -s -d '{"email":"bob@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

# one task, owned by Alice
TASK_A=$(curl -s -H "Authorization: Bearer $A" localhost:4000/v1/tasks \
  -d '{"title":"alice private"}' | jq -r .task.id)
echo "Alice's task id: $TASK_A"

$TASK_A should be a number. Now the four checks:

# 1. Alice sees her own task
curl -s -H "Authorization: Bearer $A" localhost:4000/v1/tasks | jq '.tasks | length'   # 1

# 2. Bob sees nothing
curl -s -H "Authorization: Bearer $B" localhost:4000/v1/tasks | jq '.tasks | length'   # 0

# 3. Bob cannot fetch Alice's task by id
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $B" localhost:4000/v1/tasks/$TASK_A                        # 404

# 4. Bob cannot delete it either — and it is still there afterwards
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
  -H "Authorization: Bearer $B" localhost:4000/v1/tasks/$TASK_A                        # 404
curl -s -H "Authorization: Bearer $A" localhost:4000/v1/tasks | jq '.tasks | length'   # 1

The comment on each line is the value you should see. Check 4 is the one people skip and the one that matters most. DELETE ... WHERE id = $1 AND user_id = $2 matches nothing for Bob, so rows == 0, so 404, and Alice’s row is untouched. A version that lost the second predicate would have returned 200 and destroyed Alice’s task. The status code catches that — but only the last line proves the data survived. Negative tests that also assert “and nothing happened” are the ones worth writing.

New word

-w '%{http_code}\n' — a curl flag that prints the numeric status code and nothing else. Combined with -o /dev/null (throw the body away) it turns curl into a status-code probe.


7. Checkpoint: prove it works

Four checks. Run them in order; each covers a different layer.

# 1. the schema has the column, the index and the cascade
make db/psql

At the taskd=# prompt type \d tasks. You should see user_id | bigint | not null among the columns, idx_tasks_user_id under Indexes:, and tasks_user_id_fkey with ON DELETE CASCADE under Foreign-key constraints:. Type \q.

# 2. every task query mentions the tenant
grep -c user_id sql/queries/tasks.sql

You should see 6 — the INSERT column list, plus one line in each of the five queries that filter.

# 3. it compiles, and every task call site names a user
go build ./... && echo TENANCY-BUILDS
grep -c "UserID:" cmd/api/tasks.go
grep -c "contextGetUser" cmd/api/tasks.go

You should see TENANCY-BUILDS, then 6 (one per params struct), then 5 (one per handler).

# 4. two users cannot reach each other

Re-run the four checks from Step 5. They should print 1, 0, 404, 404, then 1.

If you got something else

You got Cause Fix
ERROR: column "user_id" of relation "tasks" contains null values (SQLSTATE 23502) from make db/migrations/up The TRUNCATE tasks; line is missing or came after the ALTER TABLE Put TRUNCATE tasks; first. Then migrate ... force 3 to clear the dirty flag, and run up again
A 500 on POST /v1/tasks, with a log line naming tasks_user_id_fkey UserID was left out of db.CreateTaskParams, so the insert claims user 0, who does not exist Add UserID: user.ID to the struct literal in createTaskHandler
2 instead of 0 in check 2 — Bob sees Alice’s tasks ListTasksParams is missing UserID, or ListTasks still lacks WHERE user_id = sqlc.arg('user_id') Fix the SQL first, make sqlc, then the handler
200 instead of 404 in check 3 GetTask still ends WHERE id = $1 Add AND user_id = $2, regenerate, rebuild
500 with missing user value in request context A task route was moved outside Chapter 11’s r.Group with requireAuthenticatedUser Check routes.go: r.Use(app.authenticate) on the top-level router, the /tasks route inside the group

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: cmd/api/tasks.go:47:44: undefined: db.GetTaskParams (your numbers will differ) It means: you wrote the Go change before regenerating. The type genuinely does not exist yet. Fix: make sqlc, then go build ./.... SQL first, Go second, every time.

Common mistake

You’ll see: cannot use id (variable of type int64) as db.GetTaskParams value in argument to app.q.GetTask It means: exactly what it says — this call site has not been tenant-scoped yet. Fix: db.GetTaskParams{ID: id, UserID: user.ID}, with user := app.contextGetUser(r) above it. This error is a feature; it is the security checklist printing itself.

Common mistake

You’ll see: make sqlc failing with a complaint about parameter $2 in UpdateTask. The exact wording depends on your sqlc version; it will name the query and the parameter. It means: $2 is now claimed twice — by title in the SET list and by user_id in the WHERE — because the new predicate went in without renumbering the rest. Fix: copy the full UpdateTask from Step 2. Order is $1 id, $2 user_id, $3..$7 the five fields, $8 version.

Common mistake

You’ll see: error: Dirty database version 4. Fix and force version. It means: migration 4 failed partway, so migrate has locked itself and wants a human. This is deliberate. Fix: repair the SQL file, then migrate -path ./migrations -database $TASKD_DB_DSN force 3 to say “we are back at version 3”, then make db/migrations/up. In development, docker compose down -v and starting fresh is also honest.

Two more that produce no error at all, which is what makes them the dangerous ones:

Symptom What it means Fix
Green build, but the drill shows Bob seeing Alice’s tasks One of the three struct-literal call sites is missing UserID: — most often ListTasksParams, because it is the longest literal and the easiest to skim past grep -c "UserID:" cmd/api/tasks.go should print 6. Anything less means one params struct is not naming a user
Everything works; you left CREATE INDEX idx_tasks_user_id out of the migration Nothing fails today. Every list, show, update and delete becomes a sequential scan, and the service gets measurably slower with every signup Confirm with \d tasks in psql. If the index is missing, add it in a new migration — never edit a migration that has already run

9. Pitfalls

403 vs 404 for foreign resources. A 403 Forbidden confirms the resource exists — it says “I know who you are; you still can’t”. A 404 reveals nothing. Look at what the difference gives away:

An attacker holds a valid token for their own free account
and walks task ids 1, 2, 3, … asking for each.

  If the server answers 403                If it answers 404 for everything
  ┌──────────────────────────┐             ┌──────────────────────────┐
  │ GET /v1/tasks/41 → 404   │             │ GET /v1/tasks/41 → 404   │
  │ GET /v1/tasks/42 → 403   │             │ GET /v1/tasks/42 → 404   │
  │ GET /v1/tasks/43 → 404   │             │ GET /v1/tasks/43 → 404   │
  └────────────┬─────────────┘             └────────────┬─────────────┘
               ▼                                        ▼
  "42 exists and belongs to someone         "I have learned nothing about
   else." Repeat 100,000 times and           which ids exist."
   you know the customer count, the
   growth rate, and where to aim the
   next bug you find.
New word

oracle (security) — any behaviour that answers an attacker’s question for free. A witness who nods when you guess right. An enumeration oracle answers “does this exist?”, which is how attackers map a system before attacking it.

The scoped-query approach gives you 404 for nothing, because pgx.ErrNoRows is identical for “missing” and “not yours”. A security property that falls out of the data access pattern is the best kind: you get it by writing less code, not more.

The composite-index temptation. (user_id) alone is right for now. When Chapter 18 (Prometheus) gives you metrics that someday show list queries hurting, (user_id, created_at) or (user_id, status) are the candidates — chosen from EXPLAIN output, not guessed today. An index you added on a hunch costs write speed and disk forever, in exchange for a benefit nobody measured.

New word

composite index — an index over two columns together. It can serve queries that filter on the first column, or on both — but not on the second alone. Order matters, which is why guessing is a bad way to choose one.

ON DELETE CASCADE scope. Deleting a user vaporises their tasks and their tokens. That is correct for GDPR-style erasure — when someone says “delete my account”, partial deletion is a legal problem, not a tidiness problem. It is catastrophic if an admin tool deletes the wrong user. Cascades are loaded weapons; know each one you install. The subscriptions table gets one too, deliberately.

The deliberate alternative is a soft delete: instead of removing the row, set a deleted_at timestamp and treat any row with one as gone. That makes a mistaken deletion recoverable — and costs you a second filter (AND deleted_at IS NULL) in every query, forever, with exactly the failure mode this chapter is about. We take the cascade, and we take the risk knowingly.

Note

The original edition places that subscriptions table in Chapter 16. It is created in Chapter 15 (Stripe I), in migration 000005_billing; Chapter 16 only writes to it. Corrected here so the pointer leads somewhere.

New word

GDPR — the European data-protection law that gives people a right to have their data deleted. The practical consequence for you: “delete my account” must really delete, everywhere.


10. Check yourself — quiz

  1. Bob asks for Alice’s task and gets a 404. Which line of Go code decided that?
  2. Explain “fails closed” using this chapter’s two designs.
  3. Next month you add a new endpoint. In version (a) you write its SQL without user_id; in version (b) the SQL is right but you leave UserID out of the Go params struct. What does each one do, and which does the compiler catch?
  4. Why is CREATE INDEX idx_tasks_user_id in the same migration as the column, rather than “later, if it gets slow”?
  5. go build ./... is green. Is tenancy done? Justify your answer.
  6. What exactly goes wrong if you run ALTER TABLE tasks ADD COLUMN user_id bigint NOT NULL REFERENCES users on a table that already has 500 rows, and what are the three steps that avoid it?
  7. CountActiveTasks is added in this chapter but called by nothing. Why now?
  8. A colleague proposes returning 403 Forbidden for another user’s task, arguing it is “more honest”. Give the counter-argument in two sentences.
Answers
  1. None — and that is the point. GetTask with AND user_id = $2 returns pgx.ErrNoRows for “no such task” and “not your task” identically, and Chapter 8’s existing errors.Is(err, pgx.ErrNoRows) branch turns both into notFoundResponse. To return anything other than 404 you would have to add code.

  2. Failing closed means that when something goes wrong the system denies rather than allows. With the filter in SQL, a forgotten predicate returns nothing — a visible bug. With the check in Go, a forgotten if returns everything — an invisible breach. Same mistake, opposite blast radius.

  3. (a) leaks everything; (b) fails closed; the compiler catches neither. A brand-new query without user_id returns every user’s rows, and nothing complains — it changed no existing type, so there is nothing for the compiler to reject (Exercise 3 makes you watch this happen). A missing UserID field sends 0; no user has id 0, so reads come back empty and writes violate the foreign key. The compiler only helps when an existing signature changes; the file-level review habit is what covers new code.

  4. Because every query from now on filters by user_id. Without the index each one is a sequential scan, and the cost grows in a straight line with every signup — the service gets slower each time it succeeds. This is not premature optimisation; it is the known access pattern being indexed on the day it is created.

  5. No. Three of the six call sites are struct literals with named fields, and Go permits omitting a field — it becomes 0. Those three compile whether or not you added UserID. The build proves consistency; the two-user drill in Step 5 proves correctness.

  6. Postgres refuses: ERROR: column "user_id" of relation "tasks" contains null values (SQLSTATE 23502), because 500 rows have nothing to put in a column that forbids blanks. The three steps are: add the column nullable (instant), backfill the existing rows with their true owners in batches, then ALTER COLUMN user_id SET NOT NULL.

  7. Because we already have the file open and the generator loaded. Chapter 17 (Entitlements and quotas) needs it to enforce “your plan allows N active tasks”. Batching a known-needed query into an existing schema change costs one line now and saves a separate migration, regeneration and review later.

  8. 403 confirms the resource exists, which lets anyone with a free account map your ID space, your customer count and your growth rate by walking ids. 404 costs nothing to implement — it is what the scoped query already produces — and gives an attacker no information at all.


11. Practice

Exercise 1 — Turn the drill into a script (easy)

Step 5 proved isolation by hand. Hands forget. Write scripts/isolation.sh that runs the same checks and exits 0 on success, non-zero on any failure, so you can re-run it after every future chapter — chapters 13 to 19 all touch this code path.

Answer
#!/usr/bin/env bash
# scripts/isolation.sh — tenancy regression check. Run after every chapter.
set -euo pipefail
BASE=${BASE:-http://localhost:4000}

reg() {  # $1 = a unique local-part; prints a token
  curl -sf -o /dev/null "$BASE/v1/users" \
    -d "{\"name\":\"$1\",\"email\":\"$1@example.com\",\"password\":\"pa55word123\"}" || true
  curl -sf "$BASE/v1/tokens/authentication" \
    -d "{\"email\":\"$1@example.com\",\"password\":\"pa55word123\"}" \
    | jq -r .authentication_token.token
}

A=$(reg "alice-$RANDOM"); B=$(reg "bob-$RANDOM")

TASK_A=$(curl -sf -H "Authorization: Bearer $A" "$BASE/v1/tasks" \
          -d '{"title":"alice private"}' | jq -r .task.id)

n=$(curl -sf -H "Authorization: Bearer $B" "$BASE/v1/tasks" | jq '.tasks | length')
[ "$n" = "0" ] || { echo "FAIL: B sees $n tasks, want 0"; exit 1; }

code=$(curl -s -o /dev/null -w '%{http_code}' \
  -H "Authorization: Bearer $B" "$BASE/v1/tasks/$TASK_A")
[ "$code" = "404" ] || { echo "FAIL: B fetching A's task got $code, want 404"; exit 1; }

code=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE \
  -H "Authorization: Bearer $B" "$BASE/v1/tasks/$TASK_A")
[ "$code" = "404" ] || { echo "FAIL: B deleting A's task got $code, want 404"; exit 1; }

n=$(curl -sf -H "Authorization: Bearer $A" "$BASE/v1/tasks" | jq '.tasks | length')
[ "$n" = "1" ] || { echo "FAIL: A's task disappeared"; exit 1; }

echo "ISOLATION-OK"

set -euo pipefail makes the shell stop on the first error instead of ploughing on. $RANDOM gives each run fresh emails, so re-running does not trip the unique-email constraint from Chapter 10. The || true after registration tolerates a duplicate.

Verify:

bash scripts/isolation.sh
echo $?

You should see ISOLATION-OK then 0.

Now break it on purpose: delete UserID: user.ID from ListTasksParams, rebuild, re-run. The script should print a FAIL: line and exit non-zero. Put the line back.

Exercise 2 — Measure what the index is for (medium)

Prove the index earns its place. Seed 10,000 tasks for one user, then compare the query plan with and without the index.

Answer

Open make db/psql and run:

-- give the lowest-numbered user 10,000 tasks
INSERT INTO tasks (user_id, title)
SELECT (SELECT min(id) FROM users), 'seed ' || g
FROM generate_series(1, 10000) AS g;

-- let Postgres refresh its statistics; the planner decides from these
ANALYZE tasks;

-- ask about a DIFFERENT user, who owns only a handful of rows
EXPLAIN ANALYZE SELECT * FROM tasks WHERE user_id = (SELECT max(id) FROM users);

The output is a tree of plan nodes, one per line, plus an InitPlan line for the max(id) sub-select. Find the node that reads tasks. With the index present it should name idx_tasks_user_id — either Index Scan using idx_tasks_user_id or Bitmap Index Scan on idx_tasks_user_id. Now remove the index and ask again:

DROP INDEX idx_tasks_user_id;
EXPLAIN ANALYZE SELECT * FROM tasks WHERE user_id = (SELECT max(id) FROM users);

You should now see Seq Scan on tasks, and the rows removed by filter figure in the output will be roughly 10,000 — that is the work the index was skipping. Compare the actual time values on each plan’s top line rather than guessing; the ratio is the point, not the absolute numbers, which depend on your machine.

Put the index back and clean up:

CREATE INDEX idx_tasks_user_id ON tasks (user_id);
DELETE FROM tasks WHERE title LIKE 'seed %';

The honest twist. Now run EXPLAIN ANALYZE for the user who owns all 10,000 rows. Postgres will very likely choose a sequential scan even with the index available, and it is right to: when a filter matches most of the table, reading the table straight through is cheaper than bouncing between index and rows. Indexes pay off when the filter is selective. In a real multi-tenant system with thousands of users, user_id = $1 is extremely selective, which is exactly why this index is not optional.

Exercise 3 — Feel the danger the discipline removes (harder)

Write a new query that deliberately forgets the tenant filter, and observe that nothing stops you.

Answer

Add to sql/queries/tasks.sql:

-- name: GetTaskByTitle :one
SELECT * FROM tasks WHERE title = $1;

Run make sqlc, then go build ./.... Both succeed. There is no warning, no lint failure, no red text. If you now wired that query to a handler, any logged-in user could read any other user’s task by guessing its title — and every test you have would still pass, because no test knows the query exists.

Write in learnings/ch12.md, in your own words:

  • The compiler protects you only where a type changed. A brand-new unscoped query changes no existing type, so nothing complains.
  • The real guard is the review habit: sql/queries/tasks.sql is one short file, and every query in it must mention user_id. That is a rule a human can check in twenty seconds and a grep can check in zero.
  • The general principle worth keeping: prefer designs where forgetting is a compile error. The same instinct produced WHERE user_id = $2 instead of a Go if, the citext column type in Chapter 10 instead of a lowercasing helper, and the CHECK constraint in Chapter 5 instead of a validation rule you have to remember to call.

Then delete the query and re-run make sqlc, so it does not follow you into Chapter 13.


12. FAQ

Why not one database per customer? Wouldn’t that be safer? It would, and a few products with a handful of very large enterprise customers do exactly that. The cost is that every migration must run N times, every backup is N backups, connection pools multiply, and a customer with three rows costs as much to operate as one with three million. Shared tables with a tenant column is the standard because it scales down as well as up. The safety you give up is bought back by this chapter’s discipline.

Why not check ownership in Go? I find that easier to read. You can, and that readability is a trap. The Go version needs a human to remember it at every one of a growing number of call sites, and its failure mode is “returns everyone’s data”. The SQL version needs a human to remember it in one file, and its failure mode is “returns nothing”. Pick the design whose mistakes are boring.

Why did all my code break, and is that good? Yes, genuinely good. A change to who-may-see-what should touch every place that reads the data. The compiler turning that into a list of file names and line numbers is the cheapest security review you will ever get. The alternative — a change that compiles silently — means the security question was never asked at the places that needed to answer it.

Should I use Postgres row-level security instead? Row-level security (RLS) is a Postgres feature that attaches a policy to the table itself, so the database adds AND user_id = current_setting('app.user_id') to every query whether you wrote it or not. It is genuinely stronger, and it is the right tool if you have several applications or an analytics team querying the same database directly. It costs you: a session variable set correctly on every connection (tricky with a connection pool), policies that are invisible when you read the query, and a debugging experience where a query returns nothing and the reason is somewhere else entirely. For one Go application that owns its database, the explicit filter is easier to read, easier to test, and visible in the file you are editing. Know RLS exists; reach for it when a second consumer appears.

Why does the migration delete my data? Because NOT NULL and existing rows are incompatible, and in development the rows are test data you created five minutes ago. TRUNCATE is the honest shortcut, labelled as one in a comment. On a live table you would do the three-step dance instead: add the column nullable, backfill each row’s true owner in batches, then set NOT NULL. You would also write the index as CREATE INDEX CONCURRENTLY, because the plain form holds a lock that blocks writes for as long as it takes to build — on a large table, that is an outage.

I eventually want shared or team tasks. Have I made that impossible? No — you have made it bounded. Chapter 27 (Production checklist, and where to go next) names teams as one of the projects this codebase is now shaped for: an orgs table, a membership table with roles, and this chapter’s discipline re-applied with org_id where user_id is today. The compiler escorts you through the call sites a second time. Building ownership first and sharing second is the correct order; retrofitting ownership onto a shared-by-default system is the nightmare.


13. Where we are

A secure, multi-tenant API: registration, login, per-user data, defensive errors. It is, functionally, a complete free product. Part V is where it becomes a business — first faster (caching), then fair (rate limits), then paid (Stripe).

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go           # UPDATED: every handler is tenant-scoped
│   ├── users.go
│   └── tokens.go
├── internal/
│   ├── data/              # filters.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output — models.go + tasks.sql.go REGENERATED
│   └── validator/
├── migrations/            # NEW: 000004_add_user_id_to_tasks up + down
├── sql/queries/
│   ├── tasks.sql          # UPDATED: user_id everywhere, + CountActiveTasks
│   ├── users.sql
│   └── tokens.sql
├── scripts/               # NEW, if you did Exercise 1: isolation.sh
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: register, log in, and create, list, read, update and delete tasks that belong to you and only you. Two accounts on the same server cannot see, edit or delete each other’s data, and neither can learn that the other’s data exists.

What is still fake or missing:

  • Every list request hits Postgres. Chapter 13 (Caching with DragonflyDB) puts a cache in front of it — and the cache key must be scoped per user, or it undoes this chapter in one line.
  • Nothing limits how fast anyone can call. Chapter 14 (Rate limiting) fixes that.
  • CountActiveTasks is dead code. Chapter 17 (Entitlements and quotas) gives it a job.
  • Nobody pays. Chapters 15 and 16 add Stripe.
  • activated is still always true. Chapter 21 makes it mean something.

For your notes

Copy these into learnings/ch12.md, in your own words:

  1. The tenant filter lives inside the query, not in a Go if. WHERE id = $1 AND user_id = $2 makes the safe path the only path; a forgotten filter fails closed, not open.
  2. Prefer designs where forgetting is a compile error. That is the same instinct behind the CHECK constraint, the citext column, and the expiry > now() in Chapter 11’s auth query.
  3. 404, not 403, for other people’s resources. 403 confirms existence and hands an attacker a map. Here you get 404 for free, because “missing” and “not yours” produce the same pgx.ErrNoRows.
  4. Adding a NOT NULL column to a populated table is three steps: nullable, backfill, tighten. Anything shorter only works when the data is disposable, and you say so in a comment.
  5. A green build proves consistency, not correctness. Struct literals with named fields let Go zero-fill a missing field. The two-user drill is the proof; run it after every chapter that touches this code.

Chapter 13 — Caching with DragonflyDB

Every time somebody asks GET /v1/tasks, taskd runs a SQL query, walks the rows, and turns them into JSON. It does that from scratch every single time, even when nothing has changed since the last request four hundred milliseconds ago. This chapter adds a second place to keep the answer — an in-memory store called DragonflyDB — so the second identical question is answered from memory instead of from Postgres. The easy half is remembering. The hard half, the half that this chapter is really about, is forgetting at the right moment.

What you’ll be able to do by the end

  • Explain what a cache is, what cache-aside means, and why the cache never talks to the database.
  • Run DragonflyDB next to Postgres in Docker Compose, and talk to it from Go.
  • Invalidate every cached page, sort and filter belonging to one user with a single command, and explain why that is possible.
  • Watch a response flip between X-Cache: MISS and X-Cache: HIT, and make it flip on demand.
  • Stop the cache container while the server is running and watch taskd keep answering — slower, correct, unbothered.
  • Say out loud, in one sentence, why you should probably not have added a cache yet.

Time: ~50 minutes reading, ~45 minutes typing.

You need before starting: a working Chapter 12 (Ownership: making it multi-tenant). Prove it in two commands — log in, then list your tasks:

TOKEN=$(curl -s -d '{"email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

You should get a JSON body containing a "metadata" key. Keep that terminal open — $TOKEN is used all through this chapter. If you get {"error":"invalid authentication credentials"}, register the user again with the POST /v1/users command from Chapter 10 (Users and passwords).


1. The problem, in plain words

You have a friend whose phone number you look up in a contacts app about six times a day. After the third time you write it on a sticky note by the phone. Looking at the sticky note takes half a second; opening the app takes eight. That sticky note is a cache: a fast copy of an answer you already worked out, kept nearby so you don’t have to work it out again.

It also has the exact problem every cache has. Your friend changes their number. The contacts app knows. The sticky note does not. From that moment your sticky note is a confident liar, and it will keep lying until somebody notices and throws it away.

That is the whole subject. Caching takes an afternoon to add and a career to keep honest, and the hard half has one name: invalidation — making the copy stop being used once the original changes.

The uncomfortable question first

Should taskd have a cache at all? Honestly: not yet. Postgres answers “give me user 42’s twenty most recent tasks” from an indexed table in a millisecond or two. Adding a cache buys you maybe a millisecond and costs you a second program that has to be running, a second thing that can be down, a second thing to configure and deploy, and the permanent obligation to invalidate correctly forever. That is a poor trade for a millisecond.

We build it anyway, for two reasons stated plainly rather than dressed up. First, chapters coming up genuinely need a fast shared store for other purposes: Chapter 14 (Rate limiting) needs a place to count requests per user across all server instances, and Chapter 17 (Entitlements and quotas) needs somewhere to keep the answer to “what plan is this person on?”. The infrastructure is going in regardless, so it may as well earn its keep here too. Second, cache-aside with correct invalidation is a skill you will need on a day when it does matter, and today’s endpoint is a safe place to learn it — the worst outcome of a bug here is a task list that is sixty seconds out of date.

Remember this

A cache is a performance patch you apply to a measured wound, not a default architecture layer. Every endpoint you cache is an invalidation liability you carry for the life of the codebase.

The other decision hiding in here

There is a second question this chapter answers, and it comes back four more times before the book ends: when this component fails, does the request fail with it?

Think about doors in a building. Fire doors on a stairwell fail unlocked — if the power dies you want everybody out, so the safe failure is “open”. The door on a bank vault fails locked — if the power dies you want the money to stay put, so the safe failure is “shut”. Neither is universally right. Which one you pick depends entirely on what is behind the door.

For the cache, we choose fail-open: the cache is an optimization, never a dependency. Every cache error is logged and treated as “not found”. Dragonfly being down makes taskd slower; it never makes taskd wrong or unavailable. The same question comes back with different answers: Chapter 14 (Rate limiting) also lands on open for its fairness limiter, but says out loud which limiters should fail closed instead — the ones guarding money or login attempts. Chapter 17 (Entitlements and quotas) invents a third answer for a case where neither is right: if it cannot find out what plan you are on, it serves you the free tier rather than locking you out. Each time the decision is argued rather than defaulted into.

One piece of notation you’ll need

Two paragraphs from now this chapter starts saying “O(1)” and “O(keys)”. That is big-O notation, and it is shorthand for how the cost of an operation grows as the data grows.

  • O(1) — constant cost. Doing the thing takes the same effort whether there are ten items or ten million. Flipping a light switch is O(1); it does not get harder in a bigger room.
  • O(n) — linear cost. The work grows in step with the number of items, n. Reading every page of a book to find a word is O(n); a book twice as long takes twice as long.

“O(keys)” is O(n) with the n given a name: the cost grows with the number of keys stored in the cache. You do not need any more of it than that. When this chapter says “O(1) invalidation”, it means the work of invalidating does not grow when the user has more cached pages — and that is the entire point of the trick you are about to learn.


2. New words in this chapter

  • cache — a fast temporary copy of an answer, kept so the slow original doesn’t have to be asked again.
  • in-memory store — a database that keeps everything in RAM rather than on disk. Very fast, and everything is lost if it restarts. That is fine for a cache and fatal for your only copy.
  • cache hit — the answer was in the cache. cache miss — it wasn’t.
  • cache-aside (also lazy loading) — check the cache; on a miss, load from the database, save the answer, return it. The application does the thinking; the cache never talks to the database.
  • TTL (time to live) — how long a cached value is allowed to survive before it expires by itself. The use-by date.
  • eviction — the cache throwing something out, either because its TTL expired or because it needed the memory.
  • invalidation — making cached answers stop being used once the underlying data changes.
  • stale — cached data that no longer matches the truth.
  • read-your-own-writes — the guarantee that after you change something, you immediately see your own change. Users experience a violation of this as data loss.
  • key versioning (also generation number) — building a counter into every cache key so that incrementing the counter makes all the old keys unreachable in one step.
  • fingerprint — a short string built from everything that could change an answer, used to give each distinct question its own cache key.
  • stampede (also thundering herd) — a popular cached value expires and hundreds of simultaneous requests all hit the database at once.
  • singleflight — a helper that lets one goroutine do a duplicated piece of work while the rest wait and share its result.
  • degraded mode — running with a component missing: slower or with fewer features, but still correct and still available.
  • best-effort — we try, and if it fails we carry on rather than failing the request.
  • fail-open / fail-closed — when a component breaks, whether requests are let through anyway (open) or refused (closed).
  • Redis — the original in-memory key-value store; the thing everyone means when they say “put a cache in front of it”.
  • RESP — the wire format Redis speaks: the exact bytes a client sends to say GET mykey. DragonflyDB speaks it too, which is why every Redis client and tool works against it unchanged.
  • DragonflyDB — a modern re-implementation of Redis that speaks RESP but uses all your CPU cores instead of one.
  • single-threaded / multi-threaded — whether a program does one thing at a time on one CPU core or many things at once across many cores.
  • KEYS / SCAN — Redis commands that list existing keys. KEYS stops the server dead while it walks everything; SCAN walks in small batches instead.
  • INCR / INCRBY — Redis commands that add to a number stored at a key, creating it at zero if it isn’t there yet.
  • atomic — happens completely or not at all, with no observable half-way state, even when other clients are doing things at the same time.
  • hex encoding — writing raw bytes as text using the sixteen characters 09 and af, two characters per byte. 0xff becomes "ff".
  • flame graph — a profiling chart showing where a program spends its time; the thing you look at before deciding what deserves a cache.
  • memlock — an operating-system limit on how much memory a program may pin into RAM so it can never be swapped to disk.

3. The goal

DragonflyDB in the Compose stack, an internal/cache package over go-redis, cache-aside reads for the task list with O(1) invalidation via key versioning, and stampede protection with singleflight. And — just as important — a clear-eyed statement of when not to do any of this.

Chapter 2 (The skeleton: a server that answers) drew an empty internal/cache/ folder in the project tree with the note “ch. 13”. This is the chapter that fills it.


4. The thinking

Do we need a cache at all?

For a per-user task list on an indexed table, Postgres answers in a millisecond or two; a cache adds a second stateful system, a second failure mode, and the entire discipline of invalidation. The professional answer is: measure first — Chapter 18 (Prometheus: metrics that answer questions) builds the histograms that exist for exactly this purpose — and cache the endpoints that are hot and read-heavy.

We build caching now anyway for two honest reasons: the SaaS features coming next (rate limiting in Chapter 14, entitlement lookups in Chapter 17) genuinely need a fast shared store, so the infrastructure earns its keep regardless; and cache-aside with correct invalidation is a load-bearing skill worth learning on a low-stakes endpoint.

Why this exists

A histogram in Chapter 18’s sense is a record of how long requests took, bucketed, so you can ask “what did the slowest 1% of GET /v1/tasks calls cost?”. Without that number, “add a cache” is a guess. With it, “add a cache” is a decision. The order matters: measure, then patch.

Why DragonflyDB specifically?

It speaks the Redis protocol (RESP), so every Redis client, tutorial and mental model applies unchanged — but it’s a modern multi-threaded implementation (shared-nothing across cores) where Redis proper is single-threaded per instance. On a beefy VPS, one Dragonfly saturates the machine where Redis would need a cluster.

Unpacking the two pieces of vocabulary in that sentence:

  • Single-threaded per instance means one Redis process does one command at a time, on one CPU core. To use a 16-core server you run 16 Redis processes and split your keys across them — that arrangement is a cluster, and it brings its own operational weather.
  • Shared-nothing across cores is how Dragonfly avoids that. Each core owns its own slice of the keys and never touches another core’s slice, so there is nothing for the cores to fight over and no lock to queue behind. One process, all your cores.

The strategic beauty: because it’s protocol-compatible, choosing it is a zero-lock-in decision — swap the container image for redis:7 or valkey and nothing in our Go code changes. Those are the best kinds of infrastructure bets.

Option What it costs you Why we did or didn’t pick it
No cache at all Nothing today Genuinely defensible for this endpoint — but Chapters 14 and 17 need a shared fast store anyway
Redis One core per process; a cluster to use a big machine The reference implementation; still a fine choice, and our code would work against it untouched
DragonflyDB A less famous name in your stack Chosen. Same protocol, all cores, one process. Zero lock-in, because it is protocol-compatible
Memcached A different protocol, and none of the Redis tooling or clients carry over Pure key-to-bytes. Its incr refuses to create a missing counter, so the IncrBy(key, 0) idiom this chapter is built on has no equivalent
New word

protocol-compatible — two different programs that accept the exact same commands over the network. Like two devices sharing one plug standard: you can swap the appliance without rewiring the house.

Client library

go-redis (redis/go-redis v9) — the de-facto standard, connection pooling built in. (rueidis benchmarks faster via auto-pipelining; at our call volume the difference is noise, and go-redis’ ubiquity wins the “future maintainer at 3 a.m.” test.)

New word

auto-pipelining — quietly bundling several commands into one network round trip. It is a real speed-up under heavy load. “The difference is noise” means: at the number of calls taskd makes, the saving is smaller than the variation between two runs of the same benchmark.

The “future maintainer at 3 a.m.” test is a recurring idea in this book: when something breaks at three in the morning, the person fixing it will search the error message. A library with ten thousand Stack Overflow answers is worth more at that moment than a library that is 15% faster.

The invalidation strategy — the part interviews are made of

A user’s list cache must die when any of their tasks changes. Three candidate paths:

Path How invalidation works Why we rejected or chose it
1. TTL only Wait; entries expire after 60 s Users see their own edit missing from the list they refresh one second later. Rejected as the sole mechanism.
2. Track and delete every list key Keep a registry of that user’s keys, or SCAN for them List keys vary by page, sort and filter, so this is O(keys), racy, and KEYS in production is a firing offence
3. Key versioning Every key embeds a per-user counter; bump the counter Chosen. One O(1) write makes all that user’s old keys unreachable

Path 1 fails on a property with a name: read-your-own-writes. If Alice adds “buy milk” and then refreshes, and “buy milk” is not there, no amount of explaining that it will appear within sixty seconds will help. She will conclude the app lost her data, and she will be right to. Stale data about other people is often survivable; stale data about your own last action is not.

Path 2 fails on mechanics. Alice’s list is cached under a different key for page 1, page 2, sorted by due date, filtered to open, and so on. To delete them all you need to know all of them, which means either maintaining a registry of keys (which itself needs invalidating) or asking the server to list every key that looks like Alice’s. KEYS u:42:* does that — and blocks the whole server while it walks every key in memory. On a cache with millions of keys that is a stall every other client feels. SCAN walks in small batches instead, but you are still doing work proportional to how much is stored, and new keys can appear while you scan.

Path 3 is the trick. Every cache key embeds a per-user version counter (u:42:v7:tasks:<params-hash>). Invalidation = INCR the counter — one O(1) write makes all of that user’s old keys unreachable garbage, which TTLs then sweep out. No enumeration, no registry, no races worth losing sleep over.

Think of it like

You did not go round collecting every copy of the old key. You changed the lock. Or, in library terms: you did not burn the old catalogue cards, you moved the whole shelf. The cards still exist; they just point at nothing anybody will look at again.

Path 3, with a short TTL as belt-and-braces. This pattern generalizes to almost every “invalidate a family of keys” problem you’ll ever meet.

The reading pattern, named

And the reading pattern itself, named because you’ll hear it in interviews: cache-aside (or lazy loading). The application, not the cache, does the thinking: try the cache; on a hit, serve it; on a miss, load from the database, store the result, serve it. The cache is a bystander that remembers answers — it never talks to Postgres itself, and it can vanish entirely without breaking anything except latency. Every cached read in this book is this five-step loop.

Stampede protection

When a hot key expires, N concurrent requests all miss and all hit Postgres at once (thundering herd). golang.org/x/sync/singleflight collapses concurrent identical loads within one process into a single DB call — ten lines, removes the worst of it. (Cross-instance stampedes need distributed locks or probabilistic early expiry; noted, not needed at our scale.)

New word

goroutine — a piece of work running at the same time as the rest of the program. Go serves every incoming HTTP request in its own goroutine, which is exactly why ten requests can arrive at the same line of code at the same instant.

The “within one process” qualifier is doing real work in that paragraph. If you run three copies of taskd behind a load balancer, singleflight collapses ten misses to one query per copy — three queries instead of thirty. Better, not perfect. Fixing the remaining three would need the copies to coordinate through a distributed lock, and that is a lot of machinery to save two queries.

Failure policy, decided before the first outage

The cache is an optimization, never a dependency. Every cache error is logged and treated as a miss; Dragonfly being down makes taskd slower, not broken.

Remember this

Decide your failure policy while everything is working. In an outage, “what should this do when the cache is down?” is a design question, and design questions are the worst possible thing to be answering at 3 a.m.


5. A picture of it

Two pictures, because there are two ideas: how a read works, and how forgetting works.

The cache-aside loop

Here is the path a single GET /v1/tasks takes once this chapter is done.

                        ┌──────────────────┐
    GET /v1/tasks ─────▶│     handler      │
                        └────────┬─────────┘
                                 │ 1. build the key: this user + this query
                                 ▼
                        ┌──────────────────┐  found  ┌───────────────────┐
                        │ 2. ask the cache │────────▶│ 3a. write those   │
                        │                  │   HIT   │     bytes. Done.  │
                        └────────┬─────────┘         └───────────────────┘
                                 │ MISS — absent, or the cache errored
                                 ▼
                        ┌──────────────────┐
                        │ 3b. ask Postgres │
                        └────────┬─────────┘
                                 │ 4. turn rows + metadata into JSON bytes
                                 ▼
                        ┌──────────────────┐
                        │ 5. store the     │──▶ write those bytes. Done.
                        │    bytes, TTL 60s│
                        └──────────────────┘
  1. The handler builds a key that identifies this user asking this exact question.
  2. It asks the cache for that key.
  3. On a hit the stored bytes are written straight to the client and the handler returns. No SQL runs, no JSON is built.
  4. On a miss — which includes “the cache is unreachable” — it queries Postgres and marshals the result to JSON exactly as Chapter 9 (Listing at scale) did.
  5. It stores those bytes under the key with a 60-second TTL, then writes them to the client.

The single most important property: there is no arrow from the cache to Postgres. The cache never fetches anything. Delete the whole cache box from that diagram and every path still reaches an answer — it just goes the long way round every time. That is what makes fail-open possible.

Key versioning, before and after one INCR

This is the harder idea, so here is the same user’s key space photographed twice: once before they create a task, once immediately after.

BEFORE the write                    AFTER  InvalidateUser(42)

  u:42:ver  =  7                      u:42:ver  =  8    ◀── one INCR
  ──────────────────────────          ──────────────────────────
  u:42:v7:tasks:9f3a…  page 1         u:42:v7:tasks:9f3a…  page 1
  u:42:v7:tasks:c018…  page 2         u:42:v7:tasks:c018…  page 2
  u:42:v7:tasks:41bb…  sorted         u:42:v7:tasks:41bb…  sorted
        ▲                                   ▲
        │ every read lands here             │ still in memory, but nothing
        │                                   │ will ever build a v7 key
        │                                   │ again. TTLs sweep them out
                                            │ within the minute.

                                      u:42:v8:tasks:9f3a…  ◀── reads land here
                                            now, and it is empty: a guaranteed
                                            miss, so the next read comes
                                            fresh from Postgres

Walking it: user 42’s counter is at 7, so their list caches under keys like u:42:v7:tasks:a1b2.... They create a task → InvalidateUser bumps the counter to 8 → the next read builds its key as u:42:v8:..., finds nothing there (guaranteed miss), loads fresh from Postgres and caches under v8. The stale v7 keys were never touched — they are unreachable, and their TTL sweeps them out within a minute. One O(1) increment invalidated every page, sort and filter combination at once.

Remember this

Nothing was deleted. The address space moved. That one sentence is the chapter.


6. The steps

Six steps. Steps 1 and 2 stand alone. Step 4 will not compile until step 3 has added the two struct fields it uses, so do them in that order — and if you do get a build error in between, read it rather than panicking. Section 8 translates the ones you are most likely to hit.

Step 1 — Add DragonflyDB to the Compose stack

Chapter 5 (PostgreSQL and migrations) created docker-compose.yml with one service, db. We add a second, called cache, next to it. Do not replace the file — add these lines underneath the db service, at the same indentation level as db:, above the volumes: block at the bottom.

# docker-compose.yml — add this service under `services:`, alongside `db`
  cache:
    image: docker.dragonflydb.io/dragonflydb/dragonfly
    ulimits:
      memlock: -1
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

What this file says, line by line

  • image: docker.dragonflydb.io/dragonflydb/dragonfly — where to download the program from. Unlike postgres:17-alpine, this one names a full registry host, because Dragonfly is published on its own registry rather than Docker Hub. There is no version tag, so you get latest, which means a rebuild months from now can quietly hand you a different Dragonfly. The book’s own docker-compose.yml leaves this one untagged all the way to the end; pinning it to a specific release is a one-word change you are free to make, and Chapter 25 (Docker: a 15 MB production image) argues the general case for pinning.
  • ulimits: memlock: -1 — an operating-system limit, set to “unlimited”. memlock controls how much memory a program may pin into RAM so the OS can never move it to swap on disk. Dragonfly wants headroom here; without it the container may refuse to start or complain in its log. You do not need to understand any more about it than “Dragonfly asks for this, so we grant it”.
  • ports: - "6379:6379" — publish container port 6379 as port 6379 on your machine, so go run ./cmd/api running on your laptop can reach it at localhost:6379. 6379 is the conventional Redis port, and Dragonfly keeps it.
  • healthcheck — the same idea as the db service’s pg_isready. redis-cli ping asks the server “are you alive?”, which a healthy server answers with PONG. Docker runs it every 5 seconds, allows each attempt 3 seconds, and marks the container unhealthy after 10 failures. Chapter 25 makes the api service wait for this to go green before it starts.

Now start it, and pull in the two Go libraries.

docker compose up -d cache
go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight

What these commands do. docker compose up -d cache starts only the cache service (-d means “detached” — run in the background and give me my prompt back). Your existing db container is untouched. go get downloads the two modules and records them in go.mod and go.sum: go-redis is the client library, singleflight is the stampede collapser.

What you should see. docker compose prints a short progress log ending in a line saying the cache container was created and started; the first run also downloads the image, which takes a moment. go get prints one go: downloading … line per module plus its dependencies, then go: added … lines. Confirm the container is up:

docker compose ps

You should see two rows, db and cache, both in state running, and once the healthcheck has had a few seconds, (healthy) next to each.

Checkpoint

Ask Dragonfly directly whether it is alive: docker compose exec cache redis-cli ping It prints PONG. That is a complete round trip: your shell → the container → Dragonfly → back.

Common mistake

You’ll see: no configuration file provided: not found It means: you are not in the folder that holds docker-compose.yml. Fix: cd to the project root — the folder with go.mod in it — and run the command again.

Step 2 — Build the internal/cache package

Everything Go needs to know about caching lives in one small file with six methods. Nothing outside this package ever imports go-redis — if you ever swap the library, this is the only file that changes.

// internal/cache/cache.go — new file
package cache

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type Cache struct {
	rdb *redis.Client
}

func New(addr string) (*Cache, error) {
	rdb := redis.NewClient(&redis.Options{
		Addr:         addr,
		DialTimeout:  2 * time.Second,
		ReadTimeout:  200 * time.Millisecond,
		WriteTimeout: 200 * time.Millisecond,
	})
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := rdb.Ping(ctx).Err(); err != nil {
		return nil, err
	}
	return &Cache{rdb: rdb}, nil
}

func (c *Cache) Close() error { return c.rdb.Close() }

// version returns the user's current cache generation, creating it lazily.
func (c *Cache) version(ctx context.Context, userID int64) (int64, error) {
	return c.rdb.IncrBy(ctx, fmt.Sprintf("u:%d:ver", userID), 0).Result()
}

// ListKey builds a versioned key for a user's task-list query.
func (c *Cache) ListKey(ctx context.Context, userID int64, queryFingerprint string) (string, error) {
	v, err := c.version(ctx, userID)
	if err != nil {
		return "", err
	}
	sum := sha256.Sum256([]byte(queryFingerprint))
	return fmt.Sprintf("u:%d:v%d:tasks:%s", userID, v, hex.EncodeToString(sum[:8])), nil
}

func (c *Cache) Get(ctx context.Context, key string) ([]byte, bool) {
	b, err := c.rdb.Get(ctx, key).Bytes()
	if err != nil {
		return nil, false // redis.Nil and real errors both → miss
	}
	return b, true
}

func (c *Cache) Set(ctx context.Context, key string, val []byte, ttl time.Duration) {
	_ = c.rdb.Set(ctx, key, val, ttl).Err() // best-effort by policy
}

// InvalidateUser is the O(1) trick: bump the generation.
func (c *Cache) InvalidateUser(ctx context.Context, userID int64) {
	_ = c.rdb.Incr(ctx, fmt.Sprintf("u:%d:ver", userID)).Err()
}

What this code says, line by line

  • type Cache struct { rdb *redis.Client } — our own type wrapping the library’s client. The field is lower-case, so it is unexported: nothing outside this package can reach rdb and start issuing raw Redis commands. Every cache operation in taskd has to go through one of the methods below, which is how a policy (“errors are misses”) becomes enforceable rather than aspirational.
  • redis.NewClient(&redis.Options{...}) does not connect. It builds a client with a connection pool behind it — the same idea as Chapter 6’s pgxpool, a handful of already-open connections kept and reused because opening a new one costs milliseconds.
  • DialTimeout: 2 * time.Second — how long we’ll wait to open a connection. Two seconds is generous because this happens rarely.
  • ReadTimeout / WriteTimeout: 200 * time.Millisecond — how long a single command may take once connected. These are aggressive on purpose. A slow cache must degrade to a miss, not add 2 s to every request. Postgres answers this query in a couple of milliseconds; if the cache needs more than 200 ms, going to Postgres is straightforwardly the faster path.
  • ctx, cancel := context.WithTimeout(...) / defer cancel() — a context is Go’s standard “here is the deadline and the cancel button for this piece of work”, introduced in Chapter 6 (Connecting with pgx/v5). This one says “the ping gets two seconds”. defer cancel() releases the context and its timer as soon as the function returns instead of leaving them alive until the deadline passes; forget it and go vet reports the cancel function is not used on all paths (possible context leak).
  • rdb.Ping(ctx).Err() — go-redis returns a result object from every command; .Err() pulls the error out of it. This is the line that actually opens a connection, and it is here so that New fails at start-up rather than on the first user request.
  • func (c *Cache) Close() error — hands back the pooled connections. main will defer it.
  • version is lower-case: internal to the package. IncrBy(ctx, key, 0) is a small idiom — “add zero to this number and tell me the result”. Redis creates a missing key at 0 before applying the increment, so this reads the counter, creates it as 0 if absent, atomically, in one command. Compare the alternative: GET returns a special “no such key” error you must detect and translate, then you need a second command to create it, and two clients doing that at once can race. One command, no branches, no race.
  • ListKey builds the key. sha256.Sum256 hashes the fingerprint string down to 32 fixed bytes; sum[:8] slices off the first 8 of them; hex.EncodeToString renders those 8 bytes as 16 characters of 09af. The point is not security — it is length. A fingerprint like open|high|milk|-created_at|3|20 could be long and contains characters you would rather not put in a key; 16 hex characters is short, fixed-size and safe, and the chance of two different fingerprints colliding in 64 bits is not something you will meet.
  • Get returns ([]byte, bool) — the bytes and “did we find them”. Inside, every error path returns nil, false. redis.Nil is go-redis’ way of saying “no such key”, and a network failure is a different error entirely, but this function deliberately treats them the same. That single line is the fail-open policy, written in code. See Pitfalls for what it costs.
  • Set returns nothing at all. _ = ...Err() explicitly throws the error away. The blank identifier _ is Go’s “yes, I meant to ignore this”. Go would compile the line without it — a function call whose result you drop is legal — but linters such as errcheck flag a silently discarded error, and more to the point the next human to read this file deserves to know the omission was a decision. A cache write that fails is not a request that failed; the user still gets their data. This is what best-effort means.
  • InvalidateUser is three lines and the most important method in the file. Incr adds one to u:<id>:ver, atomically. Every key built after that moment has a different version segment, so every key built before it is unreachable.
New word

atomic — happens completely or not at all, with nothing observable in between, even when other clients are hitting the same key at the same instant. Two simultaneous INCRs on a counter at 7 always produce 9, never 8. If INCR were “read, add one, write back” from Go, they could both read 7 and both write 8.

This is the moment to go back and re-read the before/after diagram in section 5, now that you have seen the four lines that produce it: version reads the counter, ListKey bakes it into every key, and InvalidateUser moves it. Three methods, one idea.

Note

Two methods this package will grow later are missing on purpose, because nothing calls them yet: Delete(ctx, key), which Chapter 17 (Entitlements and quotas) needs, and SetNX(ctx, key, ttl), which Chapter 23 (Hardening the edge: headers, CORS, idempotency keys) needs for idempotency locks. Exercise 2 has you write Delete now, so that Chapter 17 compiles when you reach it, and prints SetNX next to it for reference. The original edition mentions both only in passing and prints neither.

Step 3 — Wire it into application and main

The application struct from Chapter 2 — the one box holding the logger, config and DB pool that every handler reaches through its receiver — grows two fields.

// cmd/api/main.go — add these two imports to the existing import block
	"golang.org/x/sync/singleflight"

	"github.com/yourname/taskd/internal/cache"
// cmd/api/main.go — replaces the existing application struct
type application struct {
	config  config
	logger  *slog.Logger
	db      *pgxpool.Pool
	q       *db.Queries
	cache   *cache.Cache       // nil is a legal state: "no cache today"
	sfGroup singleflight.Group // collapses concurrent identical DB loads
}

What this code says

  • cache *cache.Cache is a pointer, and the comment is the design. In Go, a pointer’s zero value is nil — “points at nothing”. Most of the time nil is a bug waiting to be dereferenced. Here it is a deliberate state meaning “we are running without a cache today”, and every call site checks for it. Giving nil a documented meaning is the whole of degraded mode.
  • sfGroup singleflight.Group is a value, not a pointer, and it has no constructor. Its zero value is ready to use — the same Go convention as sync.WaitGroup in Go in one sitting. It holds a mutex internally, which means an application must never be copied. It never is: the whole codebase passes *application around, and every handler is a method on the pointer.

Now main. Because a beginner cannot safely apply a patch to a file they cannot see, here is the whole function as it stands after this change.

// cmd/api/main.go — replaces the whole main() function
func main() {
	configPath := flag.String("config", "config.toml", "path to config file")
	flag.Parse()

	cfg, err := loadConfig(*configPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	logger := newLogger(cfg)

	// ch. 6 — the database is REQUIRED. A dead DB fails the boot.
	pool, err := openDB(cfg)
	if err != nil {
		logger.Error("cannot connect to database", "error", err)
		os.Exit(1)
	}
	defer pool.Close()
	logger.Info("database connection pool established")

	// ch. 13 — the cache is OPTIONAL. Failure degrades, never blocks boot.
	appCache, err := cache.New(cfg.cache.addr)
	if err != nil {
		// Cache down != app down. Log it and run degraded — by policy.
		logger.Warn("cache unavailable, continuing without it", "error", err)
		appCache = nil
	} else {
		defer appCache.Close()
		logger.Info("cache connection established", "addr", cfg.cache.addr)
	}

	app := &application{
		config: cfg, logger: logger, db: pool,
		q: db.New(pool), cache: appCache,
	}

	err = app.serve()
	if err != nil {
		logger.Error("server error", "error", err)
		os.Exit(1)
	}
}

What this code says, line by line

  • cfg.cache.addr already exists. Chapter 3 (Configuration and logging, the Nadh way) put a [cache] block in config.toml with addr = "localhost:6379" and the matching cfg.cache.addr = k.String("cache.addr") line in loadConfig. Nothing to add; this is the first code to read it.
  • Compare the two failure branches directly. The database one calls logger.Error and os.Exit(1) — the process stops. The cache one calls logger.Warn and sets appCache = nil — the process continues. That difference is the fail-open decision from “The thinking”, turned into four lines of Go. Every “required vs optional dependency” argument in this book cashes out as a choice between those two shapes.
  • appCache = nil is redundant and deliberate. cache.New already returns nil on error, so the variable is already nil. Writing it anyway states the intent to the next reader: we are choosing to run without a cache, not accidentally carrying a broken one.
  • defer appCache.Close() sits inside the else. If construction failed there is nothing to close, and calling a method on a nil *Cache would panic when main returns — the worst possible time.
  • The struct literal now folds in q. Chapter 7 (sqlc: SQL in, type-safe Go out) set it on a separate line (app.q = db.New(pool)); it moves into the literal here alongside cache: appCache. Same result, one less line, and it matches Appendix E’s final main.go.
  • sfGroup is not mentioned. It gets its zero value automatically, which is exactly what we want.

Here is the branch that matters, drawn:

   cache reachable                    cache down (app.cache == nil)

   handler                            handler
     │ app.cache != nil  ✓              │ app.cache != nil  ✗
     ├─▶ ListKey ────▶ [cache]          │  (the whole block is skipped)
     ├─▶ Get     ────▶ [cache]          │
     ├─▶ singleflight ─▶ [Postgres]     ├─▶ singleflight ─▶ [Postgres]
     ├─▶ Set     ────▶ [cache]          │  (skipped: key is still "")
     └─▶ 200 OK, X-Cache: HIT or MISS   └─▶ 200 OK, X-Cache: MISS

   Same status code. Same body. Only the latency differs.

Every cache call site in the book checks app.cache != nil first — that check is the degraded mode.

Warning

Add the two imports and the two struct fields in the same sitting. Go treats an unused import as a compile error, not a warning, so importing singleflight before anything mentions it gives you "golang.org/x/sync/singleflight" imported and not used. The fields are what use them.

Step 4 — Cache-aside in the list handler

The shape: fingerprint the query → try cache → on miss, singleflight the DB load → store → serve. Only the list handler changes.

First, two new imports in tasks.go. Chapter 8 gave the file errors, net/http, time, pgx, data, db and validator; caching adds JSON marshalling and string formatting.

// cmd/api/tasks.go — add these two lines to the existing import block
	"encoding/json"
	"fmt"

Now the handler. This is a replacement for the whole of listTasksHandler as Chapter 9 wrote it and Chapter 12 amended it. Read the top third and you will recognise every line — validation is untouched. Everything from fingerprint := down is new.

Note

The original edition prints this handler with two elisions — /* params as before */ and “assemble tasks + metadata exactly as before” — so typing it in as printed does not compile (undefined: md, among others). The complete function is printed here, checked against the finished source.

// cmd/api/tasks.go — replaces listTasksHandler in full
func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) {
	v := validator.New()
	qs := r.URL.Query()

	status := app.readString(qs, "status", "")
	priority := app.readString(qs, "priority", "")
	search := app.readString(qs, "search", "")

	f := data.Filters{
		Page:     app.readInt(qs, "page", 1, v),
		PageSize: app.readInt(qs, "page_size", 20, v),
		Sort:     app.readString(qs, "sort", "-created_at"),
		SortSafelist: []string{"created_at", "-created_at",
			"due_at", "-due_at", "priority"},
	}
	if status != "" {
		v.Check(validator.PermittedValue(status, data.Statuses...), "status", "invalid")
	}
	if priority != "" {
		v.Check(validator.PermittedValue(priority, data.Priorities...), "priority", "invalid")
	}
	data.ValidateFilters(v, f)
	if !v.Valid() {
		app.failedValidationResponse(w, r, v.Errors)
		return
	}

	user := app.contextGetUser(r)

	// The fingerprint captures everything that changes the RESULT: two
	// requests with identical filters/sort/page share a cache entry;
	// any difference produces a different key. Forget a parameter here
	// and users see each other's filter results — see Pitfalls.
	fingerprint := fmt.Sprintf("%s|%s|%s|%s|%d|%d",
		status, priority, search, f.Sort, f.Page, f.PageSize)

	var key string
	if app.cache != nil {
		if k, err := app.cache.ListKey(r.Context(), user.ID, fingerprint); err == nil {
			key = k
			if b, ok := app.cache.Get(r.Context(), key); ok {
				w.Header().Set("Content-Type", "application/json")
				w.Header().Set("X-Cache", "HIT")
				w.Write(b)
				return
			}
		}
	}

	// singleflight.Do: if ten goroutines arrive here with the same key
	// at once, ONE runs this function; the other nine wait and share
	// its return value. Ten cache misses become one database query.
	body, err, _ := app.sfGroup.Do(key+fingerprint, func() (any, error) {
		rows, err := app.q.ListTasks(r.Context(), db.ListTasksParams{
			UserID:     user.ID,
			Status:     nilIfEmpty(status),
			Priority:   nilIfEmpty(priority),
			Search:     nilIfEmpty(search),
			Sort:       f.Sort,
			PageLimit:  f.Limit(),
			PageOffset: f.Offset(),
		})
		if err != nil {
			return nil, err
		}

		// Each generated row is {Task, TotalCount} thanks to sqlc.embed —
		// peel the tasks out, and grab the count (identical on every row,
		// courtesy of the window function).
		tasks := make([]db.Task, 0, len(rows)) // 0-length, NOT nil: encodes as []
		var total int64
		for _, row := range rows {
			tasks = append(tasks, row.Task)
			total = row.TotalCount
		}

		md := data.CalculateMetadata(total, f.Page, f.PageSize)

		js, err := json.Marshal(envelope{"tasks": tasks, "metadata": md})
		return js, err
	})
	if err != nil {
		app.serverErrorResponse(w, r, err)
		return
	}

	js := body.([]byte)
	if app.cache != nil && key != "" {
		app.cache.Set(r.Context(), key, js, 60*time.Second)
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-Cache", "MISS")
	w.Write(js)
}

What this code says, line by line

  • fingerprint joins the six things that can change the answer, separated by | so that ("a", "bc") and ("ab", "c") cannot produce the same string. It does not contain the user ID — ListKey adds that, and the u:%d: prefix is what keeps Chapter 12’s tenancy promise inside the cache.
  • var key string is declared outside the if, on purpose. Its zero value is "", and further down key != "" is the test for “did we ever manage to build a key?”. Declaring it inside the if would put it out of scope by the time we need it.
  • if k, err := app.cache.ListKey(...); err == nil — Go’s if-with-initializer: run the call, keep k and err scoped to this if, and only proceed on success. If Dragonfly times out here, err is non-nil, key stays "", and the handler carries on into the database path. Nothing fails.
  • The hit path writes three things and returns. Content-Type because we are bypassing writeJSON, which normally sets it; X-Cache: HIT for debugging; then the raw stored bytes. w.Write sends 200 OK automatically as part of the first write. The return is load-bearing — without it the handler would continue and write a second response body.
  • app.sfGroup.Do(key+fingerprint, fn) takes a string and a function. Ten goroutines calling Do with the same string at the same moment result in the function running once; the other nine block until it returns and then receive the identical result. Ten misses, one query. The string here is the cache key with the fingerprint glued on the end — look hard at what that string contains when key is empty, then do Exercise 3.
  • func() (any, error) — the work is written as an anonymous function, defined inline and passed as a value. any is Go’s “a value of any type at all” (it is a newer spelling of interface{}), because singleflight cannot know what your function returns.
  • Do returns three values and we ignore the third with _. The third is a bool named shared meaning “more than one caller received this result”. Useful for a metric one day; not used here.
  • Everything inside the closure is Chapter 9’s code, unmoved. The ListTasks call with Chapter 12’s UserID, the loop peeling row.Task out of sqlc’s embedded struct, the running total, and CalculateMetadata. The only change is the ending: instead of handing the envelope to writeJSON, we json.Marshal it ourselves to get bytes we can both store and send. (envelope is Chapter 8’s one-line type, map[string]any, so the response comes out as {"tasks": [...], "metadata": {...}} rather than a bare array.)
  • js := body.([]byte) is a type assertion — the same operation Chapter 11 used on context values. body is typed any, and this says “I know this is really a []byte; give it to me as one”. If it were not, this line would panic. It is safe here because exactly one function produces the value and that function returns json.Marshal’s output. This is a place where Go makes you promise something the compiler cannot check, and the reason it is acceptable is that the promise is five lines away.
  • if app.cache != nil && key != "" — two conditions, because there are two ways to arrive here without a usable key: no cache at all, or a cache that failed while building the key.
  • The miss path sets the same headers with MISS and writes the same bytes. A hit and a miss produce byte-identical bodies. Only the header and the elapsed time differ.

We cache the serialized bytes, not the structs — the expensive parts are the query and the marshal; a HIT does neither. The X-Cache header is a debugging gift to your future self and costs nothing.

Here is what singleflight is actually preventing:

WITHOUT singleflight            WITH singleflight

 req 1 ─┐                        req 1 ─┬──▶ runs the query ──▶ ┌────┐
 req 2 ─┤                        req 2 ─┤          │            │ PG │
 req 3 ─┼─▶ all 10 miss ─▶┌────┐ req 3 ─┼── wait   │            └────┘
  ...   │   all 10 query  │ PG │  ...   │   ...    │
 req 10─┘                 └────┘ req 10─┘◀─────────┘ same bytes to all 10

 10 requests = 10 queries        10 requests = 1 query
Common mistake

You’ll see: ./cmd/api/tasks.go:NN:17: undefined: fmt It means: you added the code but not the import. Go will not guess. Fix: add "encoding/json" and "fmt" to the import block at the top of tasks.go.

Common mistake

You’ll see: no error — but filter results look wrong sometimes. You ask for ?status=done and get the whole list back, or a ?search=milk result shows up under a different search term. It means: a parameter is missing from the fingerprint string. Anything left out stops distinguishing two questions, so two genuinely different queries share one cache entry and whichever ran first wins for the next sixty seconds. Fix: the format string takes six values and every one of them is read from the query string above it. Count them: status, priority, search, f.Sort, f.Page, f.PageSize. If you ever add a seventh filter to this handler, it goes in here too, in the same commit.

Step 5 — Invalidate in all three write handlers

Three handlers change a user’s tasks: create, update, delete. Each gets the same three-line block, immediately after the database call succeeds and before the response is written.

// cmd/api/tasks.go — the invalidation call, added in three places
	if app.cache != nil {
		app.cache.InvalidateUser(r.Context(), user.ID)
	}

Exactly where, in each handler:

Handler Put it after And before
createTaskHandler the app.q.CreateTask(...) call and its if err != nil block the headers := make(http.Header) line
updateTaskHandler the app.q.UpdateTask(...) call and its error switch the final app.writeJSON(...)
deleteTaskHandler the if rows == 0 { ... } not-found check the final app.writeJSON(...)

Why after the write, not before. If you invalidate first and the write then fails, you have thrown away a perfectly good cache for nothing. If you invalidate after and the write succeeded, the next read is guaranteed fresh. The ordering costs nothing and is right in both directions.

Why in deleteTaskHandler it goes after the rows == 0 check. DeleteTask returns the number of rows it removed. Zero means the task did not exist or was not this user’s — nothing changed, so nothing needs invalidating, and the handler returns 404 anyway.

Common mistake

You’ll see: no error at all. The build is green, the tests pass, and your new task is missing from GET /v1/tasks for up to sixty seconds. It means: you forgot one of the three call sites — almost always deleteTaskHandler, because its early return is easy to slip past. Fix: grep -n InvalidateUser cmd/api/tasks.go should print three lines. Exercise 1 turns this into an automatic check.

Build it:

go build ./... && echo CACHE-BUILDS

You should see CACHE-BUILDS.

Step 6 — Watch it work

Start the server with make run/api and, in another terminal, run these with $TOKEN still set.

curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache
# X-Cache: MISS
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache
# X-Cache: HIT
curl -sH "Authorization: Bearer $TOKEN" -d '{"title":"bust it"}' localhost:4000/v1/tasks
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache
# X-Cache: MISS   ← the new task is there; read-your-own-writes holds

What these commands do. -s silences curl’s progress meter. -H adds a header. -D- dumps the response headers to standard output (the - means “to stdout”), which is what grep then filters. The third command has -d, which makes curl send a POST with that JSON body — that is the write that bumps the version counter.

What you should see. Exactly the four lines commented above: miss, hit, then (after the write) miss again. If the third curl printed a JSON error instead of a created task, fix that first — the fourth line depends on the write having happened.

And peek behind the curtain:

docker compose exec cache redis-cli

That drops you into an interactive prompt against Dragonfly, showing the address it connected to. Type KEYS u:* and press enter. You get a numbered list, one line per key: one entry for the version counter and one per cached query shape. The shape of that list, with your own user ID and your own hex digits in place of these:

    (prompt)> KEYS u:*
    1) "u:<your id>:ver"
    2) "u:<your id>:v<n>:tasks:<16 hex characters>"

Then GET u:1:ver (with your own id) prints the current generation as a quoted number, and TTL on one of the tasks: keys prints the seconds remaining out of 60. Type exit to leave.

Warning

KEYS is fine in development and a firing offence in production. It walks every key in the server before it answers — on a cache holding millions of keys, a multi-second job. On Redis, which does one thing at a time, that means every other client waits; Dragonfly spreads the walk across its cores, which makes it less catastrophic and no less wasteful. If you ever need this on a live system, use SCAN, which walks in small batches and lets other commands through in between.


7. Checkpoint: prove it works

Three drills. The first proves caching, the second proves invalidation, the third proves the thing this chapter cares about most: that the cache can die without taking the API with it.

Drill 1 — Hit and miss

Step 6 left an entry in the cache, and it lives for sixty seconds. Start from a known state by writing something first — that bumps your version counter, so the next read cannot possibly hit.

curl -s -H "Authorization: Bearer $TOKEN" -d '{"title":"drill 1"}' \
  localhost:4000/v1/tasks > /dev/null
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache

You should see X-Cache: MISS then X-Cache: HIT. If you skip the write and get HIT twice, the entry from Step 6 has not expired yet — that is the cache working, not a fault.

Drill 2 — Different questions get different keys

curl -sH "Authorization: Bearer $TOKEN" -D- 'localhost:4000/v1/tasks?page_size=5' | grep X-Cache

You should see X-Cache: MISS, even though you just hit the plain list twice. A different page_size is a different fingerprint, therefore a different key, therefore a different answer. Run it a second time and it becomes HIT.

Drill 3 — Kill the cache while the server is running

This is the one worth doing slowly.

docker compose stop cache
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep -E 'HTTP|X-Cache'

You should see HTTP/1.1 200 OK and X-Cache: MISS. The request succeeded with the cache container stopped. Every subsequent request is also a MISS, because there is nowhere to store anything, and every one of them still returns your tasks.

Now restart the server while the cache is still down (Ctrl-C, then make run/api) and read the log line:

time=2026-... level=WARN msg="cache unavailable, continuing without it"
  error="dial tcp 127.0.0.1:6379: connect: connection refused"

The address in the error is whatever localhost resolved to on your machine — 127.0.0.1 or [::1]. connection refused means “something answered at that address and said no”, which is what a stopped container looks like.

Checkpoint

That line is a WARN, not an ERROR, and the server keeps starting. If you expected a crash, re-read Step 3: that is the whole design. A beginner’s instinct here is that a warning means something is broken. It means something is missing and we planned for it.

Bring it back:

docker compose start cache

The running server does not pick it up — app.cache is nil for the life of the process, by design. Restart the server and the log line becomes level=INFO msg="cache connection established" addr=localhost:6379.

If you got something else

You got Cause Fix
X-Cache: MISS every single time, cache container running The Set at the bottom of the handler is not running — usually key is "" because ListKey errored Check docker compose ps shows cache healthy; check cache.addr in config.toml is localhost:6379
No X-Cache header at all You are hitting an old binary Stop the server, go build ./..., start it again
HTTP/1.1 401 Unauthorized $TOKEN expired (tokens last 24 hours) Re-run the login command from the top of this chapter
The X-Cache line prints but you cannot see your tasks Working as intended: grep X-Cache discards every other line, headers and body alike Drop the | grep X-Cache to see the whole response, or add -o /dev/null to see only headers

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
no required module provides package github.com/redis/go-redis/v9; to add it: go get github.com/redis/go-redis/v9 You wrote the import before downloading the library Run the go get from Step 1
"golang.org/x/sync/singleflight" imported and not used You added the import but not the sfGroup field Add both together; Go treats an unused import as an error
./cmd/api/tasks.go:NN:17: undefined: fmt Missing import in tasks.go Add "encoding/json" and "fmt"
./cmd/api/tasks.go:NN:52: undefined: md You copied a version of the handler with the metadata line elided Use the complete listing in Step 4
./cmd/api/main.go:NN:14: undefined: cache Missing "github.com/yourname/taskd/internal/cache" import in main.go Add it to the import block
level=WARN msg="cache unavailable, continuing without it" The cache container is not running. This is not a failure. docker compose up -d cache, restart the server
dial tcp: lookup cache: no such host cache.addr is set to cache:6379, the container network name, but you are running go run on your laptop Use localhost:6379 for host runs; cache:6379 is for Chapter 25’s containerised stack
A new task is missing from the list for ~60 s A missing InvalidateUser call in one of the three write handlers grep -n InvalidateUser cmd/api/tasks.go must print 3
Browser downloads the response instead of showing it, but only sometimes The hit path is missing w.Header().Set("Content-Type", ...), so hits and misses behave differently Set it on both paths — it is in the Step 4 listing
panic: interface conversion: interface {} is nil, not []uint8 The type assertion body.([]byte) ran on a nil result — you removed or reordered the if err != nil check above it Restore the error check; it is what guarantees body is real
redis-cli: command not found You ran redis-cli on your own machine, where it isn’t installed Prefix it: docker compose exec cache redis-cli

9. Pitfalls

  • Caching before measuring. Restated because it’s the big one: every cached endpoint is an invalidation liability you carry forever. Earn each one with a histogram.

    The liability is not the code you wrote today — it is the code somebody writes in eight months. A new endpoint that modifies tasks and does not call InvalidateUser produces stale lists, no error, and a bug report that says “sometimes it doesn’t save”. Every cache adds a rule that every future writer must remember.

  • Caching authorization. Never cache “is this token valid” carelessly — a revoked token surviving in cache is a security hole with a TTL. If auth lookups ever need caching, TTL <= 30 s and explicit deletion on logout. We don’t cache auth in this book.

    Note

    Chapter 11 (Stateful tokens) flagged this decision in advance. Its argument against JWTs included the observation that the per-request database lookup is cheap and could be cached if it ever showed up in a flame graph. This is the chapter that decides not to, and this is the binding answer: we do not cache auth in this book. A cached token check means a user can log out, or an administrator can revoke a stolen token, and the token keeps working for as long as the TTL. There is no error message for that. There is only an incident.

  • redis.Nil conflation. Our Get folds “key absent” and “Dragonfly on fire” into one miss by policy; do still log real errors (add an slog call, sampled) or an outage hides as a hit-rate cliff. Which you’ll only notice if Chapter 18’s metrics include cache hits and misses — foreshadowing.

    Read that carefully, because it names the exact price of fail-open. When Dragonfly dies, taskd keeps serving and says nothing. The only visible signal is that your hit rate falls off a cliff — and you can only see a cliff if you are drawing the graph. Chapter 18 adds a cacheOps counter labelled hit and miss for precisely this. “Sampled” means logging one in every N errors rather than all of them: during an outage every request fails, and un-sampled logging would produce thousands of identical lines a second.

  • Unbounded values. A 100-item page serializes to a few KB — fine. If you ever cache unpaginated collections, a whale user turns each SET into a megabyte and Dragonfly’s memory into a graph that only goes up. Cache paginated shapes only.

    Chapter 9 capped page_size at 100 in ValidateFilters. That validation rule, written for a different reason, is now also the thing bounding your cache entry size. Remove it and you have quietly created a memory problem two chapters away from the code you edited.

  • An incomplete fingerprint. This is the one the handler’s own comment points here for. The fingerprint has to contain every input that can change the answer. Leave one out — add a ?due_before= filter next year and forget this line — and two different questions collapse onto one cache key, so whichever ran first is served to both for the next sixty seconds. There is no error and no log line; the symptom is “the filter doesn’t work sometimes”, reported weeks later. The rule that keeps you safe is mechanical: a new query parameter and the fmt.Sprintf that builds the fingerprint change in the same commit, or neither changes.

  • Version-key expiry. We give u:<id>:ver no TTL (losing it just resets generations — harmless, since old keys still expire by TTL). If you do TTL it, that’s why nothing breaks. Understanding why the failure is benign is the difference between a pattern and a superstition.

    Work it through. Say user 42 is at v9 and the counter vanishes — evicted, or the whole cache restarted. The next read calls version, IncrBy(…, 0) recreates it at 0, and keys are built at v0. Nothing is stored at v0, so you get a miss and a fresh read from Postgres. The orphaned v9 keys are unreachable and expire within a minute. The worst case is a burst of extra database queries. Compare that with a design where losing the counter served stale data forever, and you can see why this one is safe to not worry about.


10. Check yourself — quiz

  1. Define cache-aside in one sentence, and say which component does the deciding.
  2. Chapter 12 spent a whole chapter making sure users cannot see each other’s data. The fingerprint string contains no user ID. Why is the cache still tenant-safe?
  3. Your colleague proposes dropping key versioning and relying on a 60-second TTL alone. Give the concrete user-visible symptom that argues against it, and name the property being violated.
  4. u:42:ver is at 7 and user 42 has nine cached list keys. InvalidateUser(42) runs. How many keys were deleted, and how many are now unreachable?
  5. What does IncrBy(ctx, "u:42:ver", 0) do, and why is it better here than a GET?
  6. ReadTimeout is 200 ms while DialTimeout is 2 s. Explain the difference in reasoning.
  7. Ten requests for the same uncached page arrive in the same millisecond, on one server. How many SELECTs reach Postgres, and what would the answer be if you ran three copies of taskd behind a load balancer?
  8. The handler stores json.Marshal’s output rather than the []db.Task slice. Name the two costs a cache hit avoids as a result.
Answers
  1. Try the cache; on a hit serve it; on a miss load from the database, store the result, serve it. The application decides — the cache is passive and never contacts the database. That is why removing the cache entirely changes only latency, not correctness.

  2. Because the user ID is added by ListKey, not by the fingerprint. The final key is u:%d:v%d:tasks:%s with userID as the first %d, so Alice’s page 1 and Bob’s page 1 hash to the same trailing 16 characters but live under different prefixes. The fingerprint’s job is to distinguish questions; the prefix’s job is to distinguish askers.

  3. Alice creates a task, refreshes the list, and her new task is not there. The property is read-your-own-writes. Stale data about other people is often tolerable; stale data about your own last action reads as data loss, and users do not file that as “eventual consistency”, they file it as “your app deleted my work”.

  4. Zero deleted. All nine unreachable. INCR only touches the counter. The nine v7 keys still occupy memory until their 60-second TTLs expire, but no code will ever construct a v7 key again, so nothing can read them. That is why the operation is O(1): the cost does not depend on how many keys the user had.

  5. It adds zero to the counter and returns the result. Redis creates a missing key at 0 before applying an increment, so this reads and lazily creates the counter, atomically, in one command. A GET would return a special “no such key” error you must detect and translate, then need a second command to create the key — two round trips, more branches, and a race between two requests both discovering it is absent.

  6. DialTimeout covers opening a TCP connection, which happens rarely and can legitimately take a moment; two seconds is cheap insurance. ReadTimeout/WriteTimeout cover a single command on an already-open connection, and they sit on the hot path of every request. Postgres answers this query in a couple of milliseconds, so a cache that needs longer than 200 ms is slower than the thing it is meant to be faster than. Degrading to a miss is the correct outcome.

  7. One. singleflight.Do runs the loader once for a given key; the other nine goroutines block and receive the same bytes. With three instances behind a load balancer you get three — one per process, because singleflight coordinates goroutines inside a single program and knows nothing about the other copies. Fixing that last part needs a distributed lock or probabilistic early expiry, which the chapter names and declines.

  8. The database query and the JSON marshalling. A hit writes bytes that already exist straight to the socket. Storing structs would mean re-marshalling on every hit, which is the more expensive of the two halves once a page has a hundred tasks in it.


11. Practice

Exercise 1 — Turn Step 6 into a script that checks itself (easy)

Typing four curl commands and reading the output by eye is fine once. Write scripts/cache-proof.sh that runs the miss → hit → write → miss sequence and fails loudly if any step is wrong, so you can re-run it after every future change to the list handler.

Requirements: log in to get a token, assert MISS, assert HIT, create a task, assert MISS again, exit non-zero on any mismatch.

Answer
# scripts/cache-proof.sh — new file. Run with: bash scripts/cache-proof.sh
set -euo pipefail

HOST=localhost:4000
EMAIL=alice@example.com
PASS=pa55word123

TOKEN=$(curl -s -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" \
  "$HOST/v1/tokens/authentication" | jq -r .authentication_token.token)

if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
  echo "FAIL: could not log in as $EMAIL"; exit 1
fi

# Read the X-Cache header of a plain list request.
cache_header() {
  curl -s -D- -o /dev/null -H "Authorization: Bearer $TOKEN" "$HOST/v1/tasks" \
    | tr -d '\r' | awk -F': ' '/^X-Cache:/ {print $2}'
}

expect() {           # expect <wanted> <label>
  got=$(cache_header)
  if [ "$got" != "$1" ]; then
    echo "FAIL ($2): wanted X-Cache: $1, got '$got'"; exit 1
  fi
  echo "ok ($2): X-Cache: $got"
}

# A fresh generation, so the first read is guaranteed to be a miss.
curl -s -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"cache-proof warm-up"}' "$HOST/v1/tasks" > /dev/null

expect MISS "first read after a write"
expect HIT  "second read, same query"

curl -s -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"cache-proof bust"}' "$HOST/v1/tasks" > /dev/null

expect MISS "read after the invalidating write"

echo "CACHE-PROOF PASSED"

Verify it. With the server and the cache both running, bash scripts/cache-proof.sh prints three ok lines and CACHE-PROOF PASSED. Then prove the script has teeth: comment out the InvalidateUser block in createTaskHandler, rebuild, restart, and run it again. It now fails on a MISS assertion with a line of the shape

FAIL (read after the invalidating write): wanted X-Cache: MISS, got 'HIT'

which is exactly the silent bug from Step 5, caught automatically. Put the line back.

Which of the two MISS assertions fails depends on what was already in the cache when you started: with invalidation removed, an entry left over from the previous run makes the first assertion fail instead. Both are the same bug. If you want the run to be deterministic, empty the cache first with docker compose exec cache redis-cli FLUSHALL, which deletes every key and prints OK. Do that in development only; on a live system it throws away the entire cache for every user at once.

Notes on the shell. set -euo pipefail makes the script stop on the first error rather than carrying on with garbage. tr -d '\r' strips the carriage returns HTTP headers end with, which otherwise end up inside the value and make the string comparison fail for invisible reasons. -o /dev/null throws the body away so only headers reach awk.

Exercise 2 — Add Delete to the cache package (medium)

Chapter 17 (Entitlements and quotas) caches each user’s plan under a key like ent:42 and needs to remove exactly that one key when a subscription changes — a single key, so key versioning is overkill. It calls app.cache.Delete(ctx, key), which does not exist yet. Write it, matching the best-effort policy of Set, and prove it works.

Answer
// internal/cache/cache.go — add this method, after Set
// Delete removes a single key — best-effort, same policy as Set. (ch. 17)
func (c *Cache) Delete(ctx context.Context, key string) {
	_ = c.rdb.Del(ctx, key).Err()
}

Three deliberate choices, all copied from Set:

  • No return value. A failed delete is not a failed request. If you returned the error, every call site would have to decide what to do with it, and the honest answer at every one of them is “nothing”.
  • _ = rather than omitting .Err(). Go would compile without it, but the blank identifier states that ignoring the error is a decision.
  • Del, not Unlink. Redis and Dragonfly both offer UNLINK for freeing large values in the background. For a single small key it makes no difference, and Del is the one every reader recognises.

Verify it. Add this temporary block to main(), right after the cache construction block, and delete it once it has printed. It needs "context" and "time" in main.go’s imports.

	// cmd/api/main.go — TEMPORARY, delete after it prints.
	if appCache != nil {
		ctx := context.Background()
		appCache.Set(ctx, "probe", []byte("hello"), time.Minute)
		_, before := appCache.Get(ctx, "probe")
		appCache.Delete(ctx, "probe")
		_, after := appCache.Get(ctx, "probe")
		logger.Info("delete probe", "before", before, "after", after)
	}

Run make run/api. Among the start-up lines you should see:

time=2026-... level=INFO msg="delete probe" before=true after=false

before=true means the key was there; after=false means Delete removed it. Take the block out again, and remove the two imports if nothing else in main.go uses them.

Chapter 23 (Hardening the edge: headers, CORS, idempotency keys) adds the other missing method, SetNX, for a different job: claiming a short-lived lock. It is printed here for the first time so that this listing of internal/cache is complete — nothing calls it until Chapter 23, so adding it now is optional, and the file compiles either way.

// internal/cache/cache.go — Chapter 23's method, shown here for completeness
// SetNX takes a short lock: true means we claimed the key, false means
// somebody else already holds it. Used by the idempotency middleware. (ch. 23)
func (c *Cache) SetNX(ctx context.Context, key string, ttl time.Duration) (bool, error) {
	return c.rdb.SetNX(ctx, key, 1, ttl).Result()
}

SETNX is “set if not exists”: Redis and Dragonfly store the value only when the key is absent, and report whether they did. That yes/no answer, decided atomically inside the server, is what makes it a lock — two clients racing to claim the same key, and exactly one gets true. Unlike Set and Delete it does return its error, because here a failure is not something the caller can shrug off: “I could not tell whether somebody else holds the lock” is a different answer from “nobody does”.

Exercise 3 — Find the tenancy hole in the singleflight key (harder)

Read this line from Step 4 again, and read Step 3’s design note that app.cache == nil is a legal state:

// cmd/api/tasks.go — one line from listTasksHandler, for reading only
	body, err, _ := app.sfGroup.Do(key+fingerprint, func() (any, error) {

Task. Answer three questions in writing before looking at the answer. (a) What is the value of key when the cache container is stopped? (b) Given that, what string is singleflight using to decide which callers share a result? © Alice and Bob both send GET /v1/tasks with no query parameters, in the same millisecond, while the cache is down. What does Bob get?

Answer

(a) key is "". It is only assigned inside if app.cache != nil { if k, err := …; err == nil {, and in degraded mode neither condition is reached.

(b) key+fingerprint is therefore just fingerprint — and the fingerprint is built from status, priority, search, f.Sort, f.Page, f.PageSize. No user ID. Two different users asking the same question produce the identical string.

© Bob gets Alice’s task list. Whichever request arrives first runs the loader — with its user.ID baked into the ListTasks call — and singleflight hands the resulting bytes to everyone waiting on that key, Bob included. It is the exact bug this chapter’s own pitfall predicts (“forget a parameter here and users see each other’s filter results”), and it inverts Chapter 12, whose entire thesis is that the tenant filter lives inside every query.

Note the nasty shape of it: in normal operation the code is correct, because key starts with u:42:. The hole opens only in the degraded mode the chapter deliberately designed for — the configuration you are least likely to test.

The fix is one string:

// cmd/api/tasks.go — the fix, replacing that one line in listTasksHandler
	body, err, _ := app.sfGroup.Do(fmt.Sprintf("%d|%s", user.ID, fingerprint), func() (any, error) {

Now the singleflight key contains the user ID whether or not a cache key was ever built, and the two mechanisms stop depending on each other.

Note

The book keeps printing key+fingerprint, because that is what the original edition and the finished taskd source both contain, and later chapters reprint this handler. Applying the fix in your own copy is safe and correct; note only that one line will then differ from the listings in Chapter 17 onwards.

How to think about verifying it. Reliably reproducing the race by hand is difficult — you need two users’ requests inside the same singleflight window with the cache down. The honest deliverable here is the reasoning, not a screenshot. If you want to try: docker compose stop cache, then fire both users’ requests as background jobs from one shell line so they start together, and compare the task titles in the two responses. Most attempts will miss the window, which is exactly what makes this class of bug expensive.


12. FAQ

Do I actually need a cache for a todo app? No. The book says so out loud in “The thinking”, and it means it. Postgres serves this endpoint in a millisecond or two from an indexed table, and a cache buys you a fraction of that while costing you a whole extra system to run. Two things make it worth building here anyway: Chapters 14 and 17 need a fast shared store regardless, and cache-aside is a skill you want to have practised before the day you need it under pressure. If you were shipping this app tomorrow with ten users, skipping this chapter would be a defensible decision.

What’s the difference between DragonflyDB and Redis? From your Go code: nothing. They speak the same protocol, so the same client library, the same commands, and every Redis tutorial on the internet apply unchanged. The difference is inside: Redis handles commands one at a time on one CPU core, so using a large server means running many Redis processes and splitting the keys across them. Dragonfly uses all the cores in one process. If you prefer Redis, change one line in docker-compose.yml to image: redis:7 and everything in this chapter still works — that portability is precisely why we picked a protocol-compatible option.

What happens if the cache has stale data? With the invalidation in place it mostly cannot, and that is the point of the design. Every write through createTaskHandler, updateTaskHandler or deleteTaskHandler bumps that user’s version counter, so the next read of any of their pages is a guaranteed miss — including a read from a second device logged into the same account, because the counter belongs to the user, not to the session.

The sixty-second window only bites when tasks change without going through those three handlers: a row edited by hand in psql, a future background job, or a fourth write endpoint that somebody adds later and forgets to invalidate from. That last one is the realistic case, and it is exactly why the TTL is there — it is the ceiling on how long a missed invalidation can lie to a user. Whether sixty seconds is a tolerable ceiling is a product decision, and the TTL is the knob. What is not tolerable, and what the versioning exists to prevent, is a user not seeing their own edit.

Why not cache the login lookup? It runs on every single request. Because the failure mode is a security incident rather than a slow page. A cached “this token is valid” answer keeps being valid after the user logs out, after an administrator revokes the token, and after the account is deleted — for as long as the TTL. Nothing errors; nothing logs. You would be trading a database lookup that costs a fraction of a millisecond for a window in which a stolen token still works. Chapter 11 raised the possibility; this chapter is where we say no.

What TTL should I use? Sixty seconds here, because with correct invalidation the TTL is not doing the important work — it is a safety net for the case where an invalidation was missed or a key was orphaned by a version bump. Short enough that any bug self-heals within a minute; long enough that a busy user’s repeated refreshes mostly hit. If you find yourself reaching for a long TTL to raise your hit rate, that is usually a sign the invalidation is not trusted, and the invalidation is what you should fix.

Why cache bytes instead of Go objects? Because a hit should do as little work as possible, and []byte is the shape the network wants. If you cached []db.Task, every hit would still have to run json.Marshal over it — which on a hundred-task page is more expensive than the database query you avoided. There is a second reason: Dragonfly stores bytes anyway, so caching structs would mean serializing them into some format on the way in and parsing on the way out. Storing the exact bytes you are going to send removes both conversions.


13. Where we are

taskd now answers repeated list requests out of memory, forgets exactly the right things at exactly the right moment, and keeps working when the memory disappears. More usefully for the chapters ahead, there is now a fast shared store in the stack: Chapter 14 (Rate limiting) counts requests in it, and Chapter 17 (Entitlements and quotas) will keep each user’s plan there.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go            # UPDATED: cache + sfGroup fields, cache boot block
│   ├── server.go
│   ├── routes.go
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go           # UPDATED: cache-aside list, 3 invalidations
│   ├── users.go
│   └── tokens.go
├── internal/
│   ├── cache/
│   │   └── cache.go       # NEW: the go-redis wrapper, versioning, invalidation
│   ├── data/              # filters.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output — unchanged this chapter
│   └── validator/
├── migrations/            # unchanged this chapter
├── sql/queries/           # unchanged this chapter
├── scripts/               # + cache-proof.sh, if you did Exercise 1
├── Makefile  sqlc.yaml  config.toml  docker-compose.yml   # + cache service
└── go.mod   go.sum        # + go-redis/v9, + golang.org/x/sync

What works end to end: register, log in, and create, list, read, update and delete tasks that belong to you and only you — with repeat list requests served from Dragonfly, invalidated the instant you change anything, and served correctly from Postgres if Dragonfly is not there at all.

What is still fake or missing:

  • Nobody can see the hit rate. X-Cache tells you about one request; there is no aggregate. Chapter 18 (Prometheus: metrics that answer questions) adds a cacheOps counter labelled hit and miss, and until then a cache outage is invisible.
  • Cache errors are silent. By policy — see Pitfalls. The Get method folds “absent” and “broken” into one answer and logs neither.
  • Nothing limits how fast anyone can call. Chapter 14 (Rate limiting) uses this same Dragonfly instance to fix that.
  • Delete and SetNX are missing from internal/cache unless you did Exercise 2. Chapters 17 and 23 need them.
  • Nobody pays. Chapters 15 and 16 add Stripe.

For your notes

Copy these into learnings/ch13.md, in your own words:

  1. A cache is a performance patch applied to a measured wound, not a default layer. Every cached endpoint is an invalidation liability you and every future colleague carry forever.
  2. Cache-aside means the application does the thinking. Try the cache, load on a miss, store, serve. The cache never talks to the database, which is exactly why deleting it costs latency and nothing else.
  3. Key versioning invalidates a whole family of keys in one O(1) write. Nothing is deleted; the address space moves, and TTLs sweep up behind you. Reach for this any time you need to forget “everything belonging to X”.
  4. TTL alone breaks read-your-own-writes, and users experience that as data loss. Stale data about other people is a trade-off; stale data about your own last action is a bug report.
  5. Decide the failure policy before the outage, and write it into the code. logger.Error + os.Exit(1) for a required dependency, logger.Warn + nil for an optional one — and a != nil check at every call site is what turns that sentence into behaviour.

Chapter 14 — Rate limiting: per-instance, then distributed

Right now, anybody with a terminal and a for loop can point ten thousand requests a minute at taskd, and taskd will loyally try to serve every one of them. Your database gets the load, your other users get the queue, and nobody has done anything the API considers wrong. This chapter teaches taskd to say “you have had your share for now” — twice, in two different ways, because the person you are defending against before login and the person you are being fair to after login are not the same problem and do not have the same solution.

What you’ll be able to do by the end

  • Explain why one rate limiter is not enough, and exactly which threat each of the two answers.
  • Cause a real data race in a fifteen-line Go program, watch the Go runtime kill the process for it, and fix it with a mutex — so that the mutex in taskd is a tool you have used, not a word.
  • Stop a password-guessing loop at the door with an in-memory token bucket keyed on IP address.
  • Count requests per user in DragonflyDB, so the limit still means what it says when three copies of taskd are running behind a load balancer.
  • Return a 429 Too Many Requests with a Retry-After header, and say why the header matters more than the status code.
  • Point at the line in the code where billing will eventually plug in.

Time: ~50 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 13 (Caching with DragonflyDB). Three commands prove it — the containers are up, you can log in, and the cache is answering:

docker compose ps
TOKEN=$(curl -s -d '{"email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache

docker compose ps should list db and cache as running, and the last command should print an X-Cache: header. Keep that terminal open — $TOKEN is used throughout this chapter. If the login returns {"error":"invalid authentication credentials"}, re-register the user with the POST /v1/users command from Chapter 10 (Users and passwords).


1. The problem, in plain words

One road, many drivers

A server is a shared road. Every request takes a slice of a fixed amount of capacity: a connection, a goroutine, a database round trip, some memory. There is no rule of nature that shares that capacity out fairly. Whoever asks fastest gets the most.

That is fine until somebody asks very fast indeed. It does not take malice. A colleague writes an integration with a retry loop and no delay; a mobile app ships a bug where a screen refreshes on every keystroke; a script that was meant to run nightly runs in a while true. Any one of them can consume the capacity of a machine that was serving a thousand people happily a minute ago.

Rate limiting is the traffic light. It caps how many requests one caller may make in a period, and it answers everybody over the cap with a specific, polite refusal rather than a slow, mysterious one.

Why this exists

Without a limit, your service’s worst behaved client decides your best behaved client’s experience. With a limit, the abusive caller gets a fast, cheap 429 and everybody else keeps the service they had. The limiter does not make your server faster. It makes it predictable when somebody misbehaves — which is the property you actually want at 3 a.m.

Two different threats, wearing the same word

Say the word “abuse” out loud and you will find you mean two unrelated things.

Before login, somebody is attacking the front door: POSTing to /v1/tokens/authentication with alice@example.com and a different password every time until one works (brute force), or working through a million email/password pairs leaked from another site and betting on reuse (credential stuffing), or hammering POST /v1/users to create ten thousand junk accounts and send ten thousand emails from your mail server.

The defining feature of this attacker: you do not know who they are. No account, no token, no user ID. The only thing you can key a limit on is where the request came from — the IP address — and whatever you do about it has to be cheap, because it runs on every request from everybody.

After login, nobody is attacking anything. A paying customer’s integration is pulling their task list in a tight loop because someone set a poll interval to zero. Not a security problem: a fairness problem, and increasingly a commercial one, because you are going to sell a plan that says “Pro: 300 requests per minute” and that sentence has to be true.

The defining feature here: you know exactly who they are — there is a user ID on the request, and the number you enforce depends on what they pay you.

The arithmetic that decides where the counter lives

Here is the part that catches everybody. Suppose you enforce the post-login limit with a counter held in your program’s memory: a number per user that goes up on each request and resets every minute. Sixty per minute. Done.

Now you get busy and run three copies of taskd behind a load balancer, which is what “scaling out” means in practice and what Chapter 25 (Docker) and Chapter 27 (Going live) build towards. Each copy has its own memory, so each copy has its own counter. The load balancer spreads one user’s requests across all three.

   user's 180 requests in one minute
              │
      ┌───────┼───────┐
      ▼       ▼       ▼
   copy 1  copy 2  copy 3
   count   count   count
    60      60      60      ← each one thinks it is at its limit
   ────────────────────────
   actual requests served: 180, against a documented limit of 60

Three replicas times sixty is a hundred and eighty. Your “limit” is now a function of how many servers you happen to be running — which changes when you deploy, when a container restarts, when you scale up for Black Friday. A promise that changes when you deploy is not a promise.

New word

replica (also instance) — one running copy of your program. Running several is how services handle more traffic than one machine can, and every one of them has its own separate memory.

So: the post-login counter cannot live in the program’s memory. It has to live in one place all the copies can see. You already have such a place — the DragonflyDB instance from Chapter 13 (Caching with DragonflyDB), which that chapter told you would earn its keep here.

The pre-login limiter has no such requirement. Its job is to stop one machine burning its own capacity on a guessing attack, and if three replicas each refuse to let one IP guess faster than a couple of times a second, the attacker’s total is three times a very small number — still nothing against the millions of guesses a brute-force attack needs. Local defence in memory, cheap; global fairness in the shared store, exact.

Two things happening at once, inside one program

There is one more idea you need before the code makes sense, and it is the idea most beginners have never had to hold.

Your Go server does not handle requests one after another. The standard library runs every HTTP request in its own goroutine — a piece of work running at the same time as the rest of the program. Twenty simultaneous requests means twenty goroutines, running genuinely at the same instant on different CPU cores.

Now picture two clerks sharing one paper ledger, with no rule about who writes when. Clerk A reads “page count: 41”. Clerk B reads “page count: 41”. Clerk A writes 42. Clerk B writes 42. Two entries were made; the ledger says one. Nothing errored. Nobody can tell.

That is a data race: two things touching the same piece of data at the same time, where at least one of them is writing, and the result depends on which got there first. It is the worst category of bug, because it does not happen every time, it does not happen on your laptop, and it does not leave an error message.

Go’s maps take this further. A Go map is not merely inaccurate under concurrent writes — the Go runtime actively detects the situation and kills the entire process with fatal error: concurrent map writes. Not a 500 for that request. The whole server, gone, all in-flight requests dropped.

The limiter you are about to write keeps a map, and every request touches it. So before we write it, Step 1 has you cause the crash on purpose, in a throwaway program, and then fix it. Ten minutes, and afterwards sync.Mutex will be a thing you have used rather than a word in a comment.

The failure question, for the third time

Chapter 13 introduced a question that this book keeps asking: when this component breaks, does the request break with it? The fire door on a stairwell fails unlocked — power dies, everybody gets out. The bank vault door fails locked — power dies, the money stays put. Neither is universally right; it depends on what is behind the door.

DragonflyDB will be down at some point. When it is, the distributed limiter cannot count. Do you refuse every request (fail closed) or allow every request (fail open)?

This chapter chooses open, and states why in a code comment: the thing behind this door is fairness, not safety. A cache outage that turns into a total outage has converted a minor problem into a major one. If instead the limiter were guarding money or authentication — counting failed logins, or counting SMS messages you pay for — the answer would flip, because there the cost of allowing too much is worse than the cost of allowing nothing.

Remember this

Fail open or fail closed is a decision you make per component, on purpose, and write down. The cache in Chapter 13 fails open. The fairness limiter in this chapter fails open. Chapter 17 (Entitlements and quotas) fails closed on a paid feature, and Chapter 22 (Password reset and account lifecycle) fails closed on account deletion. Whenever you meet this fork, the wrong move is not picking the other branch — it is not noticing there was a fork.


2. New words in this chapter

  • rate limit — a cap on how many requests one caller may make in a period. To throttle is to apply one.
  • token bucket — a limiter that refills an allowance at a steady rate and lets you spend a small amount all at once. A jar refilled with one token per second; each request spends one.
  • rate — the steady refill speed, in requests per second (rps).
  • burst — the short allowance to exceed the steady rate; the jar’s capacity.
  • fixed window — count requests per calendar minute and reset at the boundary. Simple, with a known flaw. Like a parking meter that resets on the hour.
  • boundary burst — that flaw: a caller can use a full window’s allowance at the end of one window and another full allowance at the start of the next, doing 2× the limit in a moment.
  • sliding-window log — a more accurate limiter that remembers each request’s timestamp; no boundary burst, but memory grows with requests.
  • goroutine — a piece of work running at the same time as the rest of the program, extremely cheap to start. Go runs every HTTP request in one.
  • data race — two goroutines touching the same data at the same time with at least one writing, so the result depends on timing. Two people grabbing the last seat.
  • mutex (mutual exclusion) — a lock ensuring only one goroutine touches shared data at a time. The single key to the stationery cupboard.
  • critical section — the lines between Lock() and Unlock(): the part only one goroutine may be inside.
  • deadlock — everyone waiting for a lock nobody will release. The program stops, forever, with no error.
  • race detector — a build mode (go run -race, go test -race) that instruments your program to report data races as they happen.
  • memory leak — memory your program keeps hold of and will never use again, growing until the process is killed.
  • janitor — this book’s name for a background loop that removes stale entries on a schedule.
  • IP address — the numeric address of a machine on a network, e.g. 203.0.113.7. A port is the numbered door on that machine, e.g. :54321.
  • RemoteAddr — Go’s record of the address the TCP connection came from, in host:port form.
  • reverse proxy — a server in front of your app that receives all public traffic and forwards it inward. The reception desk everyone must pass.
  • X-Forwarded-For — the header a proxy adds naming the real client’s IP. Only trustworthy when a proxy you control is the only route in.
  • brute force — guessing passwords repeatedly. credential stuffing — trying username and password pairs stolen from another site, betting on reuse.
  • 429 Too Many Requests — the HTTP status that means “you are going too fast; slow down”.
  • Retry-After — the response header telling a limited client how many seconds to wait. The “back in ten minutes” sign on the door.
  • fail open / fail closed — when a dependency breaks, allow by default or deny by default.
  • atomic — an operation that either happens completely or not at all, with no observable half-done state, even when several clients act at once.
  • INCR / EXPIRE — two Redis-protocol commands: add one to a number and return the result; and set a key to delete itself after N seconds.

3. The goal

Two limiters, layered:

  1. A per-IP, in-memory token bucket protecting the unauthenticated endpoints (login, register) from brute force.
  2. A per-user fixed-window limiter in DragonflyDB whose ceiling depends on plan tier — the first place in this codebase where billing touches request handling.

Both answer with a proper 429 carrying a Retry-After header.

That second one is the interesting sentence. Up to now, every user of taskd has been identical in the eyes of the code. After this chapter there is a number in the request path — a ceiling — that is about the user rather than the request. Today it is hardcoded to 60 for everybody. Chapter 17 (Entitlements and quotas) replaces the hardcoded line with a lookup, and that is the moment taskd stops being a task manager and starts being a product with plans.


4. The thinking

Why two limiters?

Because they answer different threats, and a single mechanism cannot answer both.

Pre-auth limiter Post-auth limiter
Defends against brute force, credential stuffing, signup spam one integration consuming everyone’s capacity
What you know about the caller an IP address, and that’s all their user ID and their plan
What you key on IP user ID
Must be cheap — it runs on every request correct — it is a promise you sold
Where the counter lives this process’s memory DragonflyDB, shared by all processes
Consequence of being per-instance attacker gets N× a tiny number: still tiny customer gets N× their plan: the plan is a lie
Algorithm token bucket (golang.org/x/time/rate) fixed window (INCR + EXPIRE)
On failure cannot fail — it is local fails open

Read the “must be” row twice. The pre-auth limiter runs on requests from people who have not proved anything, so it has to cost almost nothing: a map lookup and some arithmetic, no network call. The post-auth limiter is allowed one network round trip to Dragonfly, because by then the caller has authenticated and is about to cause a database query anyway.

Choosing an algorithm

There are three classic answers. The book uses two of them, deliberately, in different places.

Algorithm How it works Cost Accuracy Used here for
Token bucket A bucket holds up to burst tokens and refills at rate per second. Each request removes one; no token, no service. A few bytes and some arithmetic per caller Smooth — allows a short burst, then paces you the in-memory IP limiter
Fixed window Count requests per calendar minute; the counter resets at the boundary. Two Redis commands, one small key Good, except at window edges the distributed user limiter
Sliding-window log Remember the timestamp of every request; count how many fall inside the last 60 seconds. Memory proportional to requests (a sorted set per user) Exact — no boundary artifact nothing, by choice

Token bucket for the in-memory layer, because golang.org/x/time/rate — a package maintained by the Go team — gives it to us tested and free, and because its burst behaviour matches how real clients behave. A browser opening a page makes six requests in a moment and then goes quiet; a smooth limiter that allows a small burst and then paces you is friendlier than one that refuses the second request of a normal page load.

Fixed window for the distributed layer, because it is two commands and simple enough to hold entirely in your head. Its known flaw is the boundary burst: a client can spend a full window’s allowance in the last second of one window and another full allowance in the first second of the next, briefly doing 2× the limit. Sliding-window-log fixes that, at the cost of storing every request’s timestamp — memory that grows with traffic, on the hot path, forever.

For plan enforcement, where the limit is a fairness ceiling and not a hard security boundary, the simplicity wins and the boundary artifact is acceptable. Chapter 24 (OpenAPI) documents the limit, and we say so there rather than pretending the artifact isn’t real.

Note

“We say so in the docs rather than pretending it isn’t there” is not a throwaway line. A known, documented, bounded imperfection is engineering. The same imperfection undocumented is a support ticket you cannot reproduce, filed by a customer who counted.

The memory-leak-shaped detail

Every first in-memory rate limiter ever written has the same bug, and it is not in the limiting.

You need one bucket per client, so you keep a map from IP address to bucket. A new IP arrives, you add an entry. That map only ever grows. One entry per IP address that has ever contacted your server, held forever, for the life of the process. A public API sees millions of distinct IPs. That is a memory leak: memory you will never use again and never release, growing until the process is killed for it.

The fix is bookkeeping, and it is three parts:

  1. Record a lastSeen timestamp alongside each client’s bucket.
  2. Run a background loop — a janitor — that wakes up periodically and deletes entries nobody has used in a while.
  3. Guard the map with a mutex, because the janitor and every request goroutine are now all touching the same map at the same time.

An LRU cache (a fixed-size map that evicts the least recently used entry) is the other standard answer and would also work. We take the explicit janitor because the janitor teaches you something an LRU library hides: the limiter is the easy part; the lifecycle of the limiter’s state is the part that bites.

Think of it like

The limiter is a coat check. Handing out tickets is nothing. The problem is that if you never throw away the tickets for coats that were collected three years ago, eventually your entire building is tickets.


5. A picture of it

The token bucket

What you are looking at: one caller’s bucket, refilled continuously, drained one token per request.

             2 tokens added every second (the rate)
                          │
                          ▼
                  ┌───────────────┐
                  │  ● ● ● ● ● ●  │  capacity 6 (the burst)
                  │               │  a full bucket discards extras
                  └───────┬───────┘
                          │  each request removes one token
                          ▼
              is a token available?
                    │        │
                   yes       no
                    │        │
                    ▼        ▼
             handler runs   429 Too Many Requests
                            Retry-After: 60

Walk it through:

  1. The bucket starts full: six tokens, so the first six requests all go through immediately.
  2. Request seven arrives a millisecond later. The bucket is empty. It is refused.
  3. Half a second later there is one token again (two per second), so one request goes through.
  4. A caller making two requests a second, forever, never sees a refusal. That is the steady rate.
  5. A caller who is quiet for ten seconds and then fires six at once also never sees a refusal — the bucket refilled while they were quiet, capped at six. That is the burst.

The fixed window and its known flaw

What you are looking at: two adjacent one-minute windows and the worst thing a client can do to them.

   window 27,343,204            │   window 27,343,205
   (12:04:00 – 12:04:59)        │   (12:05:00 – 12:05:59)
   ──────────────────────────── │ ────────────────────────────
   counter: 0 ............ 60   │   counter: 0 ............ 60
                        ▲▲▲▲▲   │   ▲▲▲▲▲
                  60 requests   │   60 requests
                  at 12:04:59   │   at 12:05:00
                                │
        120 requests in about two seconds, and neither
        window's counter ever went above its limit of 60

Both windows obeyed the rule. The client still got double the rate for a moment. That is the boundary burst, in one picture. It is bounded (2×, never worse), it is brief, and for a fairness ceiling it is survivable. For “how many times may you try this password”, it would not be.

Where each limiter sits in the request path

What you are looking at: the route tree after this chapter, with the two limiters in place and the healthcheck deliberately outside both.

   incoming request
         │
         ▼
   recoverPanic ──▶ logRequest ──▶ authenticate    (global: ch. 4, ch. 11)
         │
         ▼
   /v1 ─┬─ GET /healthcheck ................... no limiter at all
        │
        ├─ r.Group ◀── app.rateLimitIP        (in memory, keyed on IP)
        │     POST /users
        │     POST /tokens/authentication
        │
        └─ r.Group ◀── app.requireAuthenticatedUser
                   ◀── app.rateLimitUser      (Dragonfly, keyed on user)
              /tasks  POST · GET · GET /{id} · PATCH · DELETE
  1. The three global middlewares run for everything, including the healthcheck. They catch panics, log, and work out who you are without gating anybody.
  2. /v1/healthcheck is registered before any group and therefore has no limiter. Chapter 27’s uptime monitor polls it every few seconds; limiting it would make your own monitoring the attack.
  3. The first group is the public ring. Its only middleware is the IP limiter — these are exactly the endpoints that create the credentials the next ring demands, so they cannot require a token.
  4. The second group is the authenticated ring. Order matters: requireAuthenticatedUser runs first and rejects anonymous callers, so by the time rateLimitUser runs there is guaranteed to be a real user ID to key on.

6. The steps

Nine steps. Steps 1 and 2 are groundwork; 3 to 6 build and prove the in-memory limiter; 7 to 9 build and prove the distributed one.

Step 1 — Watch a data race kill a program

This step does not touch taskd. You are going to write fifteen lines in a scratch folder, run them, and watch the Go runtime destroy the process — because in three steps you will write a map that every request goroutine touches, and the mutex guarding it should be something you understand rather than something you copied.

Make a folder anywhere outside your taskd project:

mkdir -p ~/scratch/race && cd ~/scratch/race
go mod init race

go mod init race creates a go.mod file declaring a module called race — the same thing Chapter 2 (The skeleton) did for taskd. Go needs it before it will build anything.

// ~/scratch/race/main.go — new file, not part of taskd
package main

import "fmt"

func main() {
	counters := make(map[string]int)
	done := make(chan bool)

	// Start four goroutines. Each adds 10,000 to the same map entry.
	// The correct total is not in doubt: 40,000.
	for i := 0; i < 4; i++ {
		go func() {
			for j := 0; j < 10000; j++ {
				counters["hits"]++
			}
			done <- true
		}()
	}

	// Wait for all four to report in before printing.
	for i := 0; i < 4; i++ {
		<-done
	}
	fmt.Println("hits:", counters["hits"])
}

What this code says, line by line

  • go func() { ... }() starts a goroutine: run this function alongside everything else and don’t wait for it. The trailing () calls it immediately.
  • counters["hits"]++ is the whole crime. It is not one operation. It is read the current value, add one, write it back — three steps, with gaps between them where another goroutine is doing exactly the same thing.
  • done <- true sends a value into the channel; <-done receives one, blocking until a value arrives. Four sends, four receives, so main cannot reach the Println until all four goroutines have finished. Chapter 4 (Lifecycle) used the same doorbell trick to wait for shutdown.

Run it:

go run .

What you should see. Not hits: 40000. On any modern multi-core machine you should see the process die, starting with these two lines:

fatal error: concurrent map writes

goroutine 10 [running]:

…followed by dozens of lines of stack trace naming every goroutine, and a non-zero exit status. Read the first line again: fatal error, not “error” or “panic”. This one is not recoverable. The recoverPanic middleware from Chapter 4 cannot save you from it, because the Go runtime is not raising a panic — it is deciding the program’s memory can no longer be trusted and stopping.

Common mistake

You’ll see: fatal error: concurrent map writes It means: two goroutines wrote to the same Go map at the same instant, and the runtime killed the process to stop it corrupting itself. Fix: guard every read and write of that map with a mutex — the next half of this step.

Now run it under the race detector, a build mode that instruments every memory access and reports races as it sees them:

go run -race .

What you should see. One or more blocks in this shape, before the same fatal error:

==================
WARNING: DATA RACE
Read at 0x00c00007c0f0 by goroutine 7:
  ...
Previous write at 0x00c00007c0f0 by goroutine 8:
  ...
==================

The hexadecimal number is a memory address; the two goroutine numbers are the culprits; the omitted lines are file-and-line references straight into your main.go. That is the detector doing exactly what you want: naming the two places in your own code that collided.

New word

race detector-race compiles a slower version of your program that watches every memory access and reports when two goroutines touch the same address without coordination. It costs roughly 5–10× the CPU and memory, so it is a testing tool, not a production flag. Chapter 20 (Testing) puts -race into the test command permanently, and Chapter 26 (CI/CD) makes the build fail if it ever fires.

Now fix it. Two new lines and one new import:

// ~/scratch/race/main.go — replaces the whole file
package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex // the key to the cupboard: exactly one exists
	counters := make(map[string]int)
	done := make(chan bool)

	for i := 0; i < 4; i++ {
		go func() {
			for j := 0; j < 10000; j++ {
				mu.Lock()             // wait until you hold the key
				counters["hits"]++    // the critical section: one goroutine only
				mu.Unlock()           // hand the key back
			}
			done <- true
		}()
	}

	for i := 0; i < 4; i++ {
		<-done
	}
	fmt.Println("hits:", counters["hits"])
}

What this code says

  • var mu sync.Mutex declares a mutex. Note there is no sync.NewMutex() — like sync.WaitGroup, its zero value is ready to use. That is a Go convention worth recognising.
  • mu.Lock() means: if nobody holds the lock, take it and continue; if somebody does, stop here and wait. Goroutines queue up in the runtime, using no CPU while they wait.
  • mu.Unlock() releases it, and one waiting goroutine is admitted.
  • The two lines between them are the critical section. Only one goroutine is ever inside it. Every other goroutine is either before Lock or after Unlock.

Run both again:

go run .
go run -race .

What you should see. hits: 40000 from both, and no WARNING: DATA RACE block from the second.

Warning

The one way to make this worse is to Lock() and never Unlock() — for example by returning early from a function between the two. Every other goroutine then waits for a key that will never come back, forever, using no CPU and printing nothing. That is a deadlock, and it looks exactly like “the server hung”. When you write Lock(), decide immediately where the matching Unlock() goes.

You can delete ~/scratch/race now, or keep it — it is the cheapest concurrency laboratory you will ever own.

Step 2 — Add the token-bucket package

golang.org/x/time/rate is maintained by the Go team in the golang.org/x repositories: not part of the standard library, but from the same people and held to the same standard. It gives us a tested token bucket so we do not write timing code by hand.

go get golang.org/x/time/rate

What this command does. It downloads the module, records it in go.mod as a dependency, and records a cryptographic checksum of its exact contents in go.sum so a future download that differs by a byte fails loudly.

What you should see. A line naming the module and the version it resolved, in the shape go: added golang.org/x/time v0.9.0. Your patch version may differ; that is fine.

Note

This is the third golang.org/x module in taskd: golang.org/x/crypto for bcrypt (Chapter 10), golang.org/x/sync for singleflight (Chapter 13), and now golang.org/x/time. Appendix D (the dependency ledger) accounts for all of them.

Step 3 — Write the per-IP limiter

This is the chapter’s biggest single block. It goes at the bottom of cmd/api/middleware.go, alongside recoverPanic, logRequest, authenticate and requireAuthenticatedUser from earlier chapters.

First, the imports. middleware.go already imports time and net/http; these three are new:

// cmd/api/middleware.go — add to the existing import block
	"net"
	"sync"

	"golang.org/x/time/rate"

Now the middleware itself. Take it slowly — the shape is unusual, and the shape is the lesson.

// cmd/api/middleware.go — add this function
func (app *application) rateLimitIP(next http.Handler) http.Handler {
	type client struct {
		limiter  *rate.Limiter
		lastSeen time.Time
	}
	// Recall: every request runs in its own goroutine, and Go maps are
	// NOT safe for concurrent writes — two requests touching this map
	// simultaneously would crash the server. The mutex is the lock on
	// the door: Lock() admits one goroutine; everyone else queues at
	// Unlock(). Run any doubt through `go test -race`.
	var (
		mu      sync.Mutex
		clients = make(map[string]*client)
	)

	// The janitor: without it this map grows by one entry per IP ever
	// seen, forever — the memory leak every first limiter ships. Once a
	// minute, drop clients idle for 3+ minutes.
	go func() {
		for {
			time.Sleep(time.Minute)
			mu.Lock()
			for ip, c := range clients {
				if time.Since(c.lastSeen) > 3*time.Minute {
					delete(clients, ip)
				}
			}
			mu.Unlock()
		}
	}()

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !app.config.limiter.enabled {
			next.ServeHTTP(w, r)
			return
		}
		ip, _, err := net.SplitHostPort(r.RemoteAddr)
		if err != nil {
			app.serverErrorResponse(w, r, err)
			return
		}
		mu.Lock()
		if _, ok := clients[ip]; !ok {
			clients[ip] = &client{limiter: rate.NewLimiter(2, 6)} // 2 rps, burst 6
		}
		clients[ip].lastSeen = time.Now()
		allowed := clients[ip].limiter.Allow()
		mu.Unlock()

		if !allowed {
			app.rateLimitExceededResponse(w, r)
			return
		}
		next.ServeHTTP(w, r)
	})
}

What this code says, line by line

  • The signature. func (app *application) rateLimitIP(next http.Handler) http.Handler is the middleware shape from Chapter 4 (Lifecycle): take the next handler in the chain, return a new handler that wraps it. app is the receiver — the one box holding the config, logger and pools.

  • type client struct { ... } declares a type inside a function, which is legal Go and used here because nothing outside this function needs it. It pairs a bucket with the time we last heard from that IP. limiter is a pointer because a rate.Limiter contains a mutex of its own and must never be copied.

  • The three sections before return run once, when routes() builds the router at startup — not per request. The return http.HandlerFunc(...) at the bottom is what runs per request. This is the part beginners misread: the map, the mutex and the janitor goroutine are created a single time and then closed over by the returned function.

    New word

    closure — a function that keeps a reference to variables from the scope where it was created. The returned handler still reaches mu and clients long after rateLimitIP has returned, because it captured them. That is why the map survives between requests without being a package-level global.

  • go func() { for { ... } }() starts the janitor: an infinite loop in its own goroutine that sleeps a minute, takes the lock, deletes anything idle for more than three minutes, releases the lock, and never returns. It lives as long as the process, by design.

  • time.Since(c.lastSeen) > 3*time.Minutetime.Since(t) is “how long ago was t”. Why three minutes when the sweep runs every one? Because deleting a bucket resets that IP’s allowance, so sweeping eagerly would hand attackers a fresh bucket. Three minutes of silence from an IP whose bucket refills in three seconds means the bucket is full anyway, and deleting it changes nothing. Deleting from a map while ranging over it, incidentally, is one of the few things the Go spec explicitly promises is safe.

  • if !app.config.limiter.enabled — the kill switch from Chapter 3 (Configuration and logging), which put [limiter] enabled = true in config.toml. Setting TASKD_LIMITER__ENABLED=false in the environment turns both limiters off without a rebuild, which is what integration tests want.

  • net.SplitHostPort(r.RemoteAddr)r.RemoteAddr is the address the TCP connection came from, and it always carries a port: "203.0.113.7:54321", or "[::1]:54321" for IPv6. That port is different on every connection from the same machine, so keying on the whole string would give every connection its own bucket and limit nothing. SplitHostPort returns host, port and an error; _ discards the port. If it errors — which an address Go itself produced should never do — the request gets a 500 rather than a guess. Note that this limiter does not fail open, because it is guarding the login door.

  • if _, ok := clients[ip]; !ok — the comma-ok form of a map lookup: ok is true when the key exists, and _ discards the value because we only care whether it was there.

  • rate.NewLimiter(2, 6) — two tokens per second, capacity six. Both numbers are per IP. Six is roughly what a browser or a mobile app does when it opens; two per second is far more than any human logging in and far less than any password-guessing script.

  • clients[ip].limiter.Allow() — the entire limiting decision. Allow takes a token if one is available and returns true; otherwise it returns false immediately and takes nothing. It does not block or sleep. (The same package offers Wait, which blocks until a token frees up. That is wrong for an HTTP server: it turns “too fast” into “very slow”, holds the connection open, and makes overload worse.)

  • allowed := ... then mu.Unlock() then if !allowed — read this pairing carefully. The answer is captured into a local variable inside the critical section, and the lock is released before anything slow happens. Writing the response or calling next.ServeHTTP while holding the lock would put the whole server behind one mutex — every request in the process waiting on every other request’s database query. The critical section is three fast lines by design.

  • return after app.rateLimitExceededResponse(w, r) — this is the middleware refusing to call next, which is how any middleware stops a request. Forget the return and you send the 429 and run the handler.

Here is the whole map-and-janitor arrangement in one picture:

        clients map — one entry per IP ever seen
        ┌───────────────────┬──────────┬────────────┐
        │ "203.0.113.7"     │ limiter  │ 12:04:31   │
        │ "198.51.100.22"   │ limiter  │ 12:04:29   │
        │ "192.0.2.44"      │ limiter  │ 11:58:02   │ ◀ idle 6 min
        │ ...               │  ...     │   ...      │
        └───────────────────┴──────────┴────────────┘
              ▲                             │
              │ adds or touches an entry    │ every 60 s, deletes
              │ on every request            ▼ entries idle 3+ min
      ┌───────┴────────┐            ┌───────────────┐
      │ request        │            │ janitor       │
      │ goroutines (N) │            │ goroutine (1) │
      └────────────────┘            └───────────────┘
              └──────── both hold mu while touching ────────┘

Without the janitor, that table only ever grows. Without the mutex, the janitor deleting a row while a request goroutine adds one is the crash you produced in Step 1.

Step 4 — Give the API a way to say “too fast”

The limiter calls app.rateLimitExceededResponse, which does not exist yet. It joins the other error helpers in cmd/api/errors.go, all of which funnel through the single errorResponse from Chapter 8 (CRUD done properly) so every error taskd emits has the same {"error": ...} shape.

// cmd/api/errors.go — add this function
func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Retry-After", "60")
	app.errorResponse(w, r, http.StatusTooManyRequests, "rate limit exceeded")
}

What this code says

  • http.StatusTooManyRequests is Go’s named constant for 429. Using the name instead of the number means a typo is a compile error rather than a wrong status in production.
  • w.Header().Set("Retry-After", "60") comes first. Order is law here: errorResponse calls writeJSON, which calls w.WriteHeader(status), and after WriteHeader any header you set is silently ignored. Set headers before the status, always.
  • The value is a number of seconds as a string. Retry-After also accepts an HTTP date, but seconds is simpler and is what every client library handles.
Note

Sixty seconds is exactly right for the per-user limiter, whose window is a minute. For the IP limiter it is generous: that bucket refills two tokens a second, so a client could safely retry in half a second. Advertising 60 is a conservative choice — it errs towards telling clients to back off harder than strictly necessary, which is a fine way to be wrong. The book keeps one shared helper; an exercise at the end of the chapter lets you make it per-limiter.

Remember this

A 429 without Retry-After is a refusal without instructions. Well-written clients that get one will retry immediately, which is how a rate limiter turns a busy period into an outage.

Step 5 — Wire it into the public routes

The original edition described this wiring in a sentence and never printed it. Here it is as code for the first time — and because Chapter 10 (Users and passwords) promised that the registration endpoint would be rate limited “in Chapter 14”, this block is where that promise is kept.

routes.go currently has the three-ring shape from Chapter 11 (Stateful tokens). The change is to draw one more ring: a group whose only middleware is the IP limiter, containing exactly the two public POST endpoints. Because a diff into the middle of a nested function is hard to apply blind, here is the whole function as it stands after this chapter.

// cmd/api/routes.go — replaces the whole routes() function
func (app *application) routes() http.Handler {
	r := chi.NewRouter()

	// Registration order = wrapping order, outermost first (ch. 4).
	r.Use(app.recoverPanic)
	r.Use(app.logRequest)

	// authenticate IDENTIFIES; it tolerates anonymity and never gates.
	// The gating is done per-group by requireAuthenticatedUser (ch. 11).
	r.Use(app.authenticate)

	r.Route("/v1", func(r chi.Router) {
		// Outside every limiter (ch. 14): rate-limiting the healthcheck
		// makes your monitoring the DoS and your pager the victim.
		r.Get("/healthcheck", app.healthcheckHandler)

		// ch. 14 — the unauthenticated surface is brute-force and
		// mail-cannon territory: everything here sits behind the per-IP
		// token bucket.
		r.Group(func(r chi.Router) {
			r.Use(app.rateLimitIP)

			r.Post("/users", app.registerUserHandler)                    // ch. 10
			r.Post("/tokens/authentication", app.createAuthTokenHandler) // ch. 11
		})

		// --- authenticated ring ---
		r.Group(func(r chi.Router) {
			r.Use(app.requireAuthenticatedUser)
			r.Use(app.rateLimitUser) // ch. 14 — plan-aware, per user

			r.Route("/tasks", func(r chi.Router) {
				r.Post("/", app.createTaskHandler)
				r.Get("/", app.listTasksHandler)
				r.Get("/{id}", app.showTaskHandler)
				r.Patch("/{id}", app.updateTaskHandler)
				r.Delete("/{id}", app.deleteTaskHandler)
			})
		})
	})

	return r
}

What this code says, line by line

  • r.Group(func(r chi.Router) { ... }) creates a scope that inherits everything registered above it and can add its own middleware, without adding anything to the URL. Compare r.Route("/tasks", ...), which creates a scope and a path prefix. A group is a ring in the onion; a route is a folder in the URL.
  • The inner r shadows the outer r. Inside the function literal, r is the group’s router. Registering on it affects only that group. This is idiomatic chi and slightly startling the first time.
  • r.Get("/healthcheck", ...) sits above both groups, so it inherits the three global middlewares and nothing else. This placement is the whole of “leave the healthcheck out of the limiter” — there is no exclusion list, only a route registered before the rings.
  • r.Use(app.rateLimitUser) comes after r.Use(app.requireAuthenticatedUser). Middleware in a group runs in registration order, so authentication is checked first. That guarantees rateLimitUser always has a real logged-in user to key on. Swap the two lines and every anonymous request would be counted against the same shared bucket before being rejected.
  • rateLimitUser does not exist yet, so the code will not compile until Step 8. If you want a compiling checkpoint after Step 6, comment that one line out and restore it at Step 8.
Note

This chapter’s version of routes() is not its final version. Chapters 15 through 24 add billing, webhooks, metrics, docs, account routes and two more middlewares, and each one shows its own change. Appendix F prints the complete final routes.go in one piece, annotated with the chapter that added each line.

Step 6 — Prove the IP limiter

Comment out the r.Use(app.rateLimitUser) line for the moment, rebuild, and start the server:

go build ./... && make run/api

In another terminal, fire ten login attempts as fast as your shell can manage. Deliberately wrong credentials — we are testing the limiter, not the login:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
    localhost:4000/v1/tokens/authentication
done

What this command does. seq 1 10 prints the numbers one to ten, so the loop runs ten times. -s silences curl’s progress meter, -o /dev/null throws the response body away, and -w "%{http_code}\n" prints only the status code and a newline. Ten lines out, one per request.

What you should see. A run of 401s — the login correctly refusing an unknown email — followed by 429s once the bucket empties:

401
401
401
401
401
401
429
429
429
429

The exact split depends on your machine. Six is the bucket’s capacity, so six is the expected number of 401s; if your shell is slow enough that the loop takes over half a second, a token will have refilled and you may see a seventh. Both are correct behaviour, and understanding why the number can vary is more useful than the number.

Now look at a refused response in full:

curl -i -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
  localhost:4000/v1/tokens/authentication

What you should see while the bucket is still empty: a 429 Too Many Requests status line, a Retry-After: 60 header, a Content-Type: application/json header, and this body:

{"error":"rate limit exceeded"}

Wait five seconds and run it again. You should get 401 — the bucket refilled at two tokens per second and capped at six. (Drill 3 of the checkpoint proves the other half of this: that the healthcheck route, registered outside the group, is not limited at all.)

Step 7 — The distributed counter

Now the other half. This one lives in the internal/cache package built in Chapter 13, next to cache.go, because it is a Dragonfly operation rather than an HTTP concern.

// internal/cache/ratelimit.go — new file
package cache

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

// Allow implements a fixed-window counter: at most limit requests per window.
// Fails OPEN on cache errors — availability over strictness, by policy.
func (c *Cache) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
	bucket := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix()/int64(window.Seconds()))
	n, err := c.rdb.Incr(ctx, bucket).Result()
	if err != nil {
		return true, err // fail open
	}
	if n == 1 {
		c.rdb.Expire(ctx, bucket, window+time.Second)
	}
	return n <= int64(limit), nil
}
Note

The original edition printed this function without its import block. The three imports above are the ones it needs; nothing else about the function has changed.

What this code says, line by line

  • func (c *Cache) Allow(...) hangs a new method on the Cache type from Chapter 13. Methods for one type can live in any file of the package, which is why this gets its own file.

  • ctx context.Context is the per-request envelope from Chapter 6 (pgx): it carries the deadline and the cancellation signal, so if the client hangs up mid-request, the Dragonfly command is abandoned too.

  • time.Now().Unix() is the number of seconds since 1 January 1970 — a single ever-increasing integer, the standard way computers name an instant.

  • / int64(window.Seconds()) is integer division, and it is the whole algorithm. Dividing seconds-since-1970 by 60 and throwing away the remainder gives you which minute it is, counted from 1970. Every clock on every server computes the same number for the same instant, with no coordination and no shared state.

  • fmt.Sprintf("rl:%s:%d", ...) builds the key: the prefix rl: (rate limit), then the caller’s key, then that window number. So user 42’s key changes every minute, by itself, because the arithmetic changed.

  • c.rdb.Incr(ctx, bucket).Result() sends Redis’s INCR command: add one to this key and return the new value. If the key does not exist, it is created at 0 first, so the answer is 1. INCR is atomic — if a hundred requests hit it at the same instant from ten different servers, they receive the numbers 1 to 100, each exactly once, with no locking on our side. That atomicity is the reason this fits in a single line instead of being an entire chapter.

  • return true, err on a failure. true means allowed. Read that with the comment: on a cache error the caller is let through and the error is handed back for logging. That is fail-open, in one line, deliberately.

  • if n == 1 { c.rdb.Expire(...) } — only the request that created the key sets its lifetime, which saves one command on every subsequent request in the window. window+time.Second gives a second of slack so a key cannot expire slightly before its window is genuinely over.

  • return n <= int64(limit), nil — the decision. Note it is the post-increment count, so with a limit of 60 the sixty-first request sees n == 61 and is refused. The request that gets refused is still counted; that is fine and mildly useful, since a caller hammering the limit is visible in the number.

Worked example

Limit 60 per minute, for user 42.

A request arrives at 12:04:37. time.Now().Unix() is some large number; divided by 60 it comes out at 27,343,204. The key is therefore rl:user:42:27343204. INCR returns, say, 13. Thirteen is not greater than sixty, so the request is allowed. The exact window number depends on the date — what matters is that it goes up by exactly one every minute, forever.

At 12:05:01, the division yields 27,343,205. That key does not exist, so INCR creates it and returns 1, and because n == 1, EXPIRE gives it 61 seconds to live. The window rolled over by arithmetic alone. Nobody reset anything; nobody scheduled anything.

   12:04:37                          12:05:01
       │                                 │
       ▼                                 ▼
  key  rl:user:42:27343204          rl:user:42:27343205
  INCR → 13   (13 <= 60, allow)     INCR → 1    (1 <= 60, allow)
                                    n == 1, so EXPIRE 61s
       └── still there for ~61s ────┘
           unreachable: no code will build that key again

Embedding the window number in the key means expired buckets are not cleaned up so much as never addressed again. The EXPIRE is garbage collection, not correctness — if it never ran, the limiter would still be right, and Dragonfly would accumulate dead keys.

Note

INCR and EXPIRE are two separate commands, so the pair is not atomic. If the process died between them, one counter key would survive forever with no expiry — a few dozen bytes of no consequence. Making it genuinely atomic takes a five-line Lua script sent to the server as one unit; it is a good exercise and an unnecessary dependency today. The chapter’s Practice section has it.

Step 8 — The plan-aware middleware

Back in middleware.go, directly beneath rateLimitIP:

// cmd/api/middleware.go — add this function
func (app *application) rateLimitUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !app.config.limiter.enabled || app.cache == nil {
			next.ServeHTTP(w, r)
			return
		}
		user := app.contextGetUser(r)

		limit := 60 // req/min — replaced by entitlements in ch. 17
		ok, err := app.cache.Allow(r.Context(),
			"user:"+itoa(user.ID), limit, time.Minute)
		if err != nil {
			app.logger.Warn("rate limiter unavailable", "error", err)
		}
		if !ok {
			app.rateLimitExceededResponse(w, r)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Now uncomment the r.Use(app.rateLimitUser) line you commented out in Step 6.

What this code says, line by line

  • No map, no mutex, no janitor. Compare it with rateLimitIP and notice how much shorter it is. All the state lives in Dragonfly, which is another way of stating the trade: the distributed limiter costs a network round trip and saves you every concurrency problem in the previous function.

  • app.cache == nil — Chapter 13 made nil a legal, documented state of the cache field meaning “we booted without a cache today”. Checking it here is the second half of fail-open: no cache, no counting, requests flow. Without this check the next line would panic on every request.

  • app.contextGetUser(r) retrieves the user that authenticate put in the request context back in Chapter 11. It panics if there is no user — deliberately, because a route reaching this middleware without an authenticated user is a wiring mistake by you, not a runtime condition. It is safe here because requireAuthenticatedUser is registered before it in the same group.

  • limit := 60 is the seam. One integer, hardcoded, with a comment naming the chapter that replaces it. Chapter 17 (Entitlements and quotas) swaps this line for a lookup of the user’s plan, and nothing else in this function changes. That is what a well-placed seam looks like: the future change has exactly one address.

  • "user:"+itoa(user.ID) builds the key. itoa is the two-line strconv.FormatInt wrapper from helpers.go, added in Chapter 8 (CRUD done properly) because strconv.Itoa only accepts int and our IDs are int64:

    // cmd/api/helpers.go — already exists, shown here as a reminder
    func itoa(i int64) string {
    	return strconv.FormatInt(i, 10)
    }
    

    The 10 is the number base — decimal. So user 42 becomes the string "user:42", and Allow turns that into rl:user:42:<window>. The user: prefix matters: it keeps this family of keys distinct from any other kind of thing you might one day limit, such as "ip:" or "org:".

  • if err != nil { app.logger.Warn(...) } and then no return. This is the most important four lines in the function, and they look like a bug until you read them properly. An error is logged at Warn level and the code carries on to check ok — which Allow set to true on its way out. The request proceeds. Fail-open is not a comment; it is this control flow.

  • Warn, not Error. Chapter 3 (Configuration and logging) established the levels: Error means something is broken that needs a human; Warn means something is degraded and expected to be survivable. A limiter that cannot count is degraded, not broken.

  • time.Minute is the window, passed as a parameter rather than baked into Allow, so the same function can enforce a per-second or per-hour limit elsewhere.

Step 9 — Prove the user limiter

Sixty requests is tedious to watch, so temporarily lower the limit. Change one line:

// cmd/api/middleware.go — TEMPORARY, for this test only
		limit := 3 // req/min — replaced by entitlements in ch. 17

Rebuild and restart (go build ./... && make run/api), then, with $TOKEN still set from the top of the chapter:

for i in $(seq 1 5); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks
done

What you should see. Three successes and two refusals:

200
200
200
429
429

The fourth request is the one that fails, because the counter is checked after being incremented: request four sets the counter to 4, and 4 is greater than 3. Unless your loop happens to straddle a minute boundary — in which case you have just witnessed the boundary burst from the diagram in section 5, live, and you should run it again.

Look at the key Dragonfly is holding:

docker compose exec cache redis-cli KEYS 'rl:*'

What this command does. docker compose exec cache runs a command inside the cache container; redis-cli is Dragonfly’s command-line client; KEYS 'rl:*' lists every key starting with rl:.

What you should see. One line per active window, in this shape — your user ID and your window number will differ, and the window number is an eight-digit number that increases by one every minute:

1) "rl:user:1:29255181"

Two more commands worth running before you move on:

docker compose exec cache redis-cli GET 'rl:user:1:29255181'
docker compose exec cache redis-cli TTL 'rl:user:1:29255181'

Substitute the key that KEYS actually printed. GET prints the current count as a quoted number; TTL prints the seconds remaining out of 61. Wait a minute, run KEYS 'rl:*' again, and the old key is gone and a new one with the next window number has taken its place.

Warning

KEYS walks every key in the server and blocks all other clients while it does. It is fine on your laptop and a firing offence on a production cache holding millions of keys. SCAN is the production-safe version, walking in small batches. Chapter 13 made the same point about the cache keys; it applies to every Redis-protocol server you will ever touch.

Now put the limit back to 60 and rebuild:

// cmd/api/middleware.go — restore this line
		limit := 60 // req/min — replaced by entitlements in ch. 17

7. Checkpoint: prove it works

Four drills. The first two you have already run as part of the steps; run them again in one go, from a clean start, so you know the finished state works.

Start the server with make run/api and, in another terminal:

Drill 1 — The IP limiter refuses once the bucket is empty

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
    localhost:4000/v1/tokens/authentication
done

You should see roughly six 401s followed by 429s.

Drill 2 — The 429 carries instructions

curl -sD- -o /dev/null -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
  localhost:4000/v1/tokens/authentication | grep -Ei 'HTTP/|Retry-After'

Run it immediately after Drill 1, while the bucket is still empty. -D- dumps the response headers to stdout, -o /dev/null discards the body, and grep -Ei filters case-insensitively. You should see a line containing 429 and a line reading Retry-After: 60.

Drill 3 — The healthcheck is outside everything

for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" localhost:4000/v1/healthcheck
done | sort | uniq -c

You should see a single tally line for 200, counting 30.

Drill 4 — The limiter survives the cache dying

This is the one that proves the failure policy, and it is worth doing slowly.

docker compose stop cache
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  localhost:4000/v1/tasks

You should see 200. The distributed limiter cannot count, and the request is served anyway — because Allow returned true alongside its error, and rateLimitUser logged a warning and carried on. In the server’s log you should see a line at WARN level whose message is rate limiter unavailable, with an error attribute describing a refused connection to port 6379.

Bring it back:

docker compose start cache
Checkpoint

A WARN line and a 200 is the correct outcome, not a bug. If your instinct was that the request should have failed, re-read the fail-open discussion in section 1 — and then notice that this is exactly the design you would not want on a login-attempt counter.

If you got something else

You got Cause Fix
429 on the very first request of Drill 1 Leftover tokens from an earlier test; the bucket refills at 2/s Wait five seconds and start again
No 429 at all in Drill 1, only 401s Limiter disabled, or the route group not applied Check [limiter] enabled = true in config.toml, and that TASKD_LIMITER__ENABLED is not set to false in your shell; check r.Use(app.rateLimitIP) is inside the group containing the two POSTs
429 on Drill 3 (the healthcheck) /v1/healthcheck was registered inside the limited group Move the r.Get("/healthcheck", ...) line above r.Group(...)
Drill 4 returns 500 instead of 200 The app.cache == nil check is missing, or Allow was changed to return false on error Restore both — the nil check and return true, err
{"error":"invalid authentication credentials"} on Drill 4 $TOKEN expired; tokens last 24 hours Re-run the login command from the top of the chapter

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
no required module provides package golang.org/x/time/rate; to add it: followed by go get golang.org/x/time/rate You wrote the import before downloading the package Run the go get from Step 2
./cmd/api/middleware.go:NN:14: undefined: net You used net.SplitHostPort without importing net. net/http does not bring net with it — they are separate packages Add "net" to the import block
"sync" imported and not used You added the import but not the code that uses it. Go treats an unused import as a compile error, on purpose Add both together, or delete the import until you need it
./cmd/api/routes.go:NN:8: undefined: app.rateLimitUser You wired the route group before writing the middleware Finish Step 8, or comment the r.Use line out until then
./cmd/api/middleware.go:NN:32: undefined: itoa helpers.go is missing the two-line itoa wrapper from Chapter 8 Add it; the code is in Step 8
fatal error: concurrent map writes and the whole server dies You removed, forgot or misplaced the mutex around the clients map Every read and every write of clients goes between mu.Lock() and mu.Unlock()
The server hangs; no logs, no responses, no CPU use A Lock() with no matching Unlock() — a deadlock. Usually an early return between the two Re-read Step 3: capture the answer into allowed, unlock, then branch
Every request gets 429 after the first few, even from different machines Everyone shares one IP as far as Go can see: you are behind Docker’s network, a VPN, or a proxy Expected in containers — see the Pitfalls. Locally, run the server with go run, not inside Compose
A single caller sails past the per-user limit Two or more copies of taskd running against different caches — or app.cache was nil at boot docker compose ps and check the cache service; restart the server after starting the cache
Anonymous callers get counted together under one bucket r.Use(app.rateLimitUser) was registered before r.Use(app.requireAuthenticatedUser), so unauthenticated requests reach it as AnonymousUser, whose ID is 0 Put requireAuthenticatedUser first, as in Step 5
panic: missing user value in request context, logged as a 500 rateLimitUser is mounted somewhere the global authenticate middleware never ran It belongs inside /v1, below r.Use(app.authenticate)
429 responses have no Retry-After header The header was set after WriteHeader ran, or the helper sets it after calling errorResponse Set the header first — Step 4

9. Pitfalls

  • RemoteAddr behind a proxy. Once Caddy (Chapter 27, Going live) fronts the API, every request’s RemoteAddr is Caddy’s own address — one “IP” absorbing the entire limit for every user on the internet. The fix is reading the X-Forwarded-For header, but only from a trusted proxy: chi’s middleware.RealIP does this, and blindly trusting the header lets any client invent an identity per request and dodge limits entirely. This is the single most common rate-limiting bug in the wild.

    New word

    X-Forwarded-For — a header a proxy adds, containing the address of the client that contacted it. It is ordinary text: anybody can send it. It is only meaningful when a proxy you control is the only route to your server, because then the proxy overwrites whatever the client claimed. Chapter 27 mounts middleware.RealIP and explains the guard that makes it safe.

    You will meet a mild version of this the first time you run taskd inside Docker Compose: every request appears to come from the Docker network’s gateway address, so the whole world shares one bucket. Nothing is broken. It is the same bug, in miniature, and Chapter 25 (Docker) is where it starts to matter.

  • Fail open versus fail closed. We chose open — cache down means requests flow — because this limiter protects fairness. A limiter protecting money or authentication (login attempts, SMS sends, anything you pay per unit for) should fail closed: better to refuse everyone for two minutes than to let an attacker through unlimited because your counter is offline. The choice is per limiter, and making it consciously is the entire skill.

  • 429 without Retry-After turns well-behaved clients into instant-retry loops that make the overload worse. The status code tells a client it failed; the header tells it how to be a good citizen. Most HTTP client libraries with retry support read Retry-After automatically, so the header is often the difference between a client that backs off correctly with no code changes and one that hammers you until a human intervenes.

  • Limiting the healthcheck. Leave /v1/healthcheck outside all limiters, or your monitoring becomes the denial of service and your pager the victim. Picture it: the uptime monitor polls every ten seconds from one IP, trips the limit, receives 429, decides the service is down, and wakes you at 4 a.m. about a service that is serving every real user perfectly. The same reasoning will apply to Stripe’s webhook endpoint in Chapter 16 (Stripe II), which sits outside the IP limiter for exactly this reason: Stripe retries in bursts, and throttling those retries turns a delivery into a failure.

  • The limiter’s own state is a lifecycle you own. The janitor is not optional decoration. A map that grows by one entry per distinct IP address, on a public API, is a leak with an unbounded ceiling. If you ever fork this middleware for a new key — per API key, per organisation — the janitor has to come with it.


10. Check yourself — quiz

  1. rate.NewLimiter(2, 6). What do the two numbers mean, and how many requests can a caller that has been silent for a minute make in one instant?
  2. A colleague suggests deleting the janitor goroutine, arguing that the map is “only a few bytes per user”. Give the concrete failure, and say what makes it worse on a public API than on an internal one.
  3. The clients map is touched inside mu.Lock() / mu.Unlock(). What exactly happens if you remove those two lines — not “a race”, but what the reader of your logs would see?
  4. Why is allowed := clients[ip].limiter.Allow() captured into a variable, with the if !allowed check placed after mu.Unlock() rather than before it?
  5. Fixed-window counters have a known flaw. Describe it with numbers for a limit of 60 per minute, and say why this chapter accepts it here but would not accept it for counting failed logins.
  6. Allow returns (true, err) when Dragonfly is unreachable. Name the policy, name one kind of limiter for which that return value would be the wrong choice, and explain why.
  7. You run three copies of taskd behind a load balancer. For each of the two limiters, say whether its effective limit changes, and by how much.
  8. /v1/healthcheck has no rate limiting. Which line of routes.go makes that true, and what would the 4 a.m. consequence of getting it wrong actually look like?
Answers
  1. 2 tokens per second (the sustained rate) and a bucket capacity of 6 (the burst). A caller silent for a minute can make six requests instantly — the bucket refilled while they were quiet but is capped at six; the extra tokens the minute would have produced were discarded. The seventh request in that instant is refused, and afterwards they are paced at two per second.

  2. Without the janitor, clients gains one entry per distinct IP address and never loses one: a memory leak that ends with the process being killed for using too much memory. It is worse on a public API because the number of distinct IP addresses reaching you is unbounded — the whole internet — whereas an internal service sees a fixed, small set of callers whose entries would plateau.

  3. Two goroutines writing the same map at the same instant trigger the Go runtime’s own check and the entire process dies with fatal error: concurrent map writes. What a reader of the logs sees is not an error line for one request: it is the log stopping, mid-traffic, with every in-flight request dropped. recoverPanic cannot catch it, because it is a runtime fatal error, not a panic. It also will not happen on your laptop with one curl at a time, which is what makes it dangerous.

  4. So that the lock is held for the shortest possible time, covering only the three fast map operations. If the if block ran while locked, then writing the 429 response — or worse, calling next.ServeHTTP, which runs the entire handler including its database query — would happen with the lock held. Every other request in the process would queue behind it, and one slow query would serialise the whole server.

  5. The boundary burst: 60 requests at 12:04:59 and 60 more at 12:05:00 is 120 requests in about a second, with neither window’s counter ever exceeding 60. It is accepted here because the limit is a fairness ceiling, the excess is bounded at 2× and brief, and the alternative costs memory proportional to traffic. It would not be acceptable for failed logins, where the whole point is an exact ceiling on guesses and doubling it doubles the attacker’s throughput.

  6. The policy is fail open. It is the wrong choice for any limiter guarding money or authentication — counting failed login attempts, capping SMS or email sends, metering a paid API. For those, the counter being unavailable should mean deny, because an attacker who can knock your cache over would otherwise gain unlimited attempts at exactly the moment you are least able to watch.

  7. The per-user limiter does not change: its counter lives in Dragonfly, which all three copies share, so 60 per minute stays 60 per minute. The per-IP limiter becomes 3× more permissive: each copy has its own clients map, so one IP can hold three separate buckets, giving 6 requests per second sustained and a burst of 18. That is accepted deliberately — three times a very small number is still a very small number against a brute-force attack.

  8. r.Get("/healthcheck", app.healthcheckHandler) is registered directly on the /v1 router, above both r.Group(...) blocks, so it inherits only the three global middlewares. Get it wrong and your uptime monitor — one IP, polling every few seconds — trips the limit, receives 429, concludes the service is down, and pages you about an outage that does not exist while every real user is served normally.


11. Practice

Exercise 1 — Prove the limit with a script instead of your eyes (easy)

Counting 401s by eye works once. Write scripts/limit-proof.sh that runs ten login attempts, counts how many came back 429, and exits non-zero if the answer is zero — so you can re-run it after any future change to the middleware chain and find out immediately if you have broken the limiter.

Solution
# scripts/limit-proof.sh — new file
#!/usr/bin/env bash
set -euo pipefail

HOST=${HOST:-localhost:4000}

# Ten deliberately-wrong logins, collecting only the status codes.
codes=$(for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
    "$HOST/v1/tokens/authentication"
done)

limited=$(echo "$codes" | grep -c '^429$' || true)
allowed=$(echo "$codes" | grep -c '^401$' || true)

echo "allowed: $allowed   limited: $limited"

if [ "$limited" -eq 0 ]; then
  echo "FAIL: no request was rate limited — is the IP limiter mounted?" >&2
  exit 1
fi

# The 429 must carry instructions, not merely a refusal.
if ! curl -sD- -o /dev/null \
     -d '{"email":"nobody@example.com","password":"wrongpassword"}' \
     "$HOST/v1/tokens/authentication" | grep -qi '^Retry-After:'; then
  echo "FAIL: 429 response had no Retry-After header" >&2
  exit 1
fi

echo "OK"

What the unfamiliar parts do

  • set -euo pipefail makes the script stop on the first failing command, on an undefined variable, and on a failure anywhere in a pipeline. Without it a shell script cheerfully continues after errors.
  • ${HOST:-localhost:4000} means “use $HOST if it is set, otherwise this default”.
  • grep -c '^429$' counts lines that are exactly 429. || true stops the script exiting when grep finds nothing and returns non-zero — which is a legitimate outcome we want to report ourselves.
  • grep -qi is quiet (no output) and case-insensitive, used purely for its exit status.

How to verify. chmod +x scripts/limit-proof.sh, then run it — it should print a line with both counts and then OK. Now set TASKD_LIMITER__ENABLED=false in the environment, restart the server, and run it again: it should print FAIL: no request was rate limited and exit 1. A test that never fails is not a test.

Exercise 2 — Break the mutex on purpose, in taskd (medium)

Step 1 proved the crash in a toy program. Prove it in the real one. Temporarily remove the mu.Lock() and mu.Unlock() calls from rateLimitIP, then drive enough concurrent traffic at a public endpoint to make the server die. Capture the first three lines of output, then put the mutex back.

Solution

Edit the request-handling part of rateLimitIP to remove both lock calls:

// cmd/api/middleware.go — TEMPORARY, deliberately broken
		// mu.Lock()
		if _, ok := clients[ip]; !ok {
			clients[ip] = &client{limiter: rate.NewLimiter(2, 6)}
		}
		clients[ip].lastSeen = time.Now()
		allowed := clients[ip].limiter.Allow()
		// mu.Unlock()

Rebuild and start the server, then send many requests at once. Plain curl in a loop is sequential and will not do it — you need genuine concurrency. Backgrounding with & is enough:

for i in $(seq 1 200); do
  curl -s -o /dev/null -d '{"email":"a@b.co","password":"pa55word123"}' \
    localhost:4000/v1/tokens/authentication &
done
wait

& runs each curl in the background; wait blocks until they have all finished.

What you should see in the server’s terminal: the process stops, with output beginning

fatal error: concurrent map writes

followed by a long stack trace naming main.(*application).rateLimitIP.func2 among the running goroutines, and the shell prompt returning — the server is gone.

If it survives, you were unlucky with timing: run the loop again, or raise 200 to 1000. The bug is probabilistic, which is precisely the argument for the race detector rather than for testing by hand. If you have Go’s hey or Apache’s ab installed, hey -n 2000 -c 50 ... reproduces it more reliably.

Now put the two lines back, rebuild, and re-run the same loop. The server should survive it, and the responses should be a mixture of 401 and 429.

Two things worth writing in your notes. First, the crash needed concurrency, not volume — 200 sequential requests never trigger it, 200 simultaneous ones usually do. Second, this is why you cannot find this class of bug by clicking around: your own testing is single-threaded, and production is not.

Exercise 3 — Make INCR and EXPIRE atomic with a Lua script (harder)

The chapter notes that the two commands are not atomic, and that the fully atomic version is a five-line Lua script. Write it, as a second method on Cache called AllowAtomic, leaving the existing Allow untouched.

Redis-protocol servers, Dragonfly included, can run a small Lua program on the server as a single indivisible operation. go-redis exposes this through redis.NewScript.

Solution
// internal/cache/ratelimit.go — add below Allow
// allowScript runs INCR and EXPIRE as ONE indivisible server-side
// operation, so a crash can never leave a counter without a lifetime.
// KEYS[1] is the bucket key; ARGV[1] is the window length in seconds.
var allowScript = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return n
`)

// AllowAtomic is Allow with the two-command race closed. Same policy:
// fails OPEN on cache errors.
func (c *Cache) AllowAtomic(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
	bucket := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix()/int64(window.Seconds()))
	ttl := int64(window.Seconds()) + 1

	n, err := allowScript.Run(ctx, c.rdb, []string{bucket}, ttl).Int64()
	if err != nil {
		return true, err // fail open, exactly as Allow does
	}
	return n <= int64(limit), nil
}

Add "github.com/redis/go-redis/v9" to the file’s imports — cache.go already imports it, but Go requires each file to import what it uses.

What the unfamiliar parts do

  • redis.NewScript compiles the Lua source once and remembers its SHA-1 hash. On each Run the client sends the hash rather than the whole script (EVALSHA), and falls back to sending the full text if the server has forgotten it. You get the efficiency without managing any of it.
  • KEYS and ARGV are how a script receives its inputs. Keys go in KEYS — the separation is not decoration, it is how a clustered server knows which node the script touches — and everything else goes in ARGV. Lua indexes from 1, not 0.
  • .Run(ctx, c.rdb, []string{bucket}, ttl) passes one key and one argument, and .Int64() converts the reply.
  • The whole script runs to completion with nothing else interleaved, so there is no instant at which the counter exists without an expiry.

How to verify. Point rateLimitUser at AllowAtomic instead of Allow, lower limit to 3, rebuild, and re-run Drill 4 of the checkpoint plus the five-request loop from Step 9. You should see identical behaviour: 200 200 200 429 429, and a TTL on the key of 61 or less. Behaviour identical, failure mode removed — which is the honest summary of what this exercise buys you, and why the chapter treats it as optional.


12. FAQ

Why two rate limiters? Isn’t one good one enough? They key on different things, and you cannot key on a user ID for a request that has no user. The login endpoint is the clearest case: the entire point of attacking it is that you do not have an account yet. Meanwhile the per-IP limiter cannot enforce “Pro gets 300 a minute”, because one customer can call from a hundred IPs and a hundred customers can share one office IP. Two problems, two keys, two mechanisms. What you could reasonably do is put both counters in Dragonfly; the reason we do not is that the pre-auth one runs on every request from everybody and a network round trip on that path is a real cost for a limiter whose job is to be cheap.

What limit should I actually pick? Start from what a legitimate client does, not from what feels safe. Open your browser’s network tab on your own product, count the requests one page load makes, multiply by three, and start there. Then look at the numbers again after a week of real traffic — Chapter 18 (Prometheus) gives you a per-user request rate you can look at instead of guessing. The numbers in this chapter (2 rps with burst 6 for IPs, 60/minute per user) are deliberately generous for humans and deliberately restrictive for scripts, which is the shape you want. There is no correct universal number, and anybody who gives you one has not seen your traffic.

Does this work if I run two copies of the app? The per-user limiter, yes — that is exactly why its counter lives in Dragonfly. The per-IP limiter, partly: each copy keeps its own map, so an attacker gets twice the allowance, which for a brute-force defence is an acceptable rounding error. If you ever need the IP limit to be exact across instances, Allow already does it — key it on "ip:"+ip instead of "user:"+id and accept the network round trip.

What is a mutex, really? I typed Lock and Unlock and things worked. It is a single token that only one goroutine can hold at a time. Lock() says “I want the token; if someone else has it, park me here until they give it back”. Unlock() returns it and wakes one waiter. That is the whole mechanism, and everything else about concurrency safety is a consequence: the only thing protecting your map is the convention that every piece of code touching it takes the same lock first. Go will not enforce that for you. If one function forgets, the crash comes back, and it comes back at the worst possible time.

Fail open sounds like a security hole. Isn’t “cache down means no limits” exactly what an attacker wants? It is a real trade-off and you are right to be uneasy. Two things make it defensible here. First, what this limiter protects is fairness between paying customers, not access to anything — an attacker who knocks over your cache to get unlimited task listings has gained the ability to read their own data faster. Second, the login endpoint is not protected this way: the IP limiter is in-memory, has no external dependency, and cannot be disabled by attacking anything. If you later add a limiter that counts failed passwords in Dragonfly, that one should fail closed, and the code for it is one word — return false, err.

Will this block Stripe’s webhooks? No, and the wiring is what stops it rather than luck. Chapter 16 (Stripe II) registers POST /v1/stripe/webhook on the /v1 router directly, outside both groups — the same placement as the healthcheck, for a related reason. Stripe carries no bearer token, so it could not pass the authenticated ring; and Stripe retries failed deliveries in bursts, so an IP limit would convert a transient failure into a lost payment event. Verification of those requests is done by checking a signature, which is a better gate than counting anyway.

This is a lot of code for “count to sixty”. Can I skip it? You can defer it, and if you are shipping to ten friends this week, deferring is defensible. What you cannot do is add it after the incident — by definition the moment you need a rate limiter is the moment you cannot deploy calmly. The honest accounting: the distributed limiter is about fifteen lines and worth every one of them. The in-memory limiter is fifty, and forty of those are the map lifecycle rather than the limiting, which is precisely the part a tutorial would leave out and your monitoring would find for you three weeks later.


13. Where we are

taskd is now fast where it is hot and fair under load. More to the point for the chapters ahead, it is structurally ready for tiers: there is a seam in middleware.go labelled limit := 60, waiting for billing to fill it. Money next.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go          # UPDATED: rateLimitIP group, rateLimitUser in the auth ring
│   ├── config.go
│   ├── db.go
│   ├── middleware.go      # UPDATED: rateLimitIP + rateLimitUser
│   ├── helpers.go
│   ├── errors.go          # UPDATED: rateLimitExceededResponse
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go
│   ├── users.go
│   └── tokens.go
├── internal/
│   ├── cache/
│   │   ├── cache.go
│   │   └── ratelimit.go   # NEW: the fixed-window Allow method
│   ├── data/              # filters.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output — unchanged this chapter
│   └── validator/
├── migrations/            # unchanged this chapter
├── sql/queries/           # unchanged this chapter
├── scripts/               # + limit-proof.sh, if you did Exercise 1
├── Makefile  sqlc.yaml  config.toml  docker-compose.yml
└── go.mod   go.sum        # + golang.org/x/time

What works end to end: register, log in, and create, list, read, update and delete tasks that belong to you alone — with repeat reads served from Dragonfly, brute-force attempts on the public endpoints stopped in memory per IP, and every authenticated request counted against a shared per-user budget that stays correct however many copies of taskd are running.

What is still fake or missing:

  • Everybody has the same limit. limit := 60 is hardcoded for every user on every plan. Chapter 17 (Entitlements and quotas) replaces that line with a real lookup, and that is the chapter where taskd starts having customers rather than users.
  • Nobody can see the limiting happen. There is no counter of how often the API returned 429, so a client stuck in a retry loop is invisible unless you grep the logs. Chapter 18 (Prometheus) is where that becomes a graph.
  • RemoteAddr is not yet the real client IP in any deployment with a proxy in front. Chapter 27 (Going live) mounts middleware.RealIP behind Caddy and explains the trust boundary that makes it safe.
  • Nobody pays. Chapters 15 and 16 add Stripe.

For your notes

Copy these into learnings/ch14.md, in your own words:

  1. A limit enforced in one process’s memory is multiplied by the number of processes. Three replicas times sixty is a hundred and eighty. Any number you have promised a customer belongs in shared storage; any number that is merely local defence can stay local.
  2. A Go map written by two goroutines at once kills the processfatal error: concurrent map writes, not a panic, not recoverable. The mutex is not a performance detail; it is the thing standing between your server and sudden death. Hold it for as few lines as possible, and never across a network call.
  3. A fixed-window limiter is integer division and one INCR. Embedding the window number in the key means windows roll over by arithmetic alone and expired keys are never addressed again. EXPIRE is housekeeping, not correctness.
  4. The limiter is the easy part; the lifecycle of its state is the part that bites. A map[ip]*rate.Limiter with no janitor is a memory leak with the whole internet as its input.
  5. Fail open or fail closed is a decision, made per component, written into the code. Fairness fails open. Money and authentication fail closed. Not choosing is choosing badly.
  6. A 429 without Retry-After is a refusal without instructions, and well-behaved clients will retry instantly and make the overload worse. The header is how your API tells clients how to behave.

Chapter 15 — Stripe I: tiers, customers, checkout

Everything so far has been free. This chapter is where taskd learns to ask for money — and, more interestingly, where it learns to ask for as little of the money problem as possible. We define three plans in one Go file, create two products in Stripe’s test mode, pair each user with a Stripe customer record the first time they click upgrade, and add one endpoint that hands back a payment page URL. No card number ever touches our server. At the end you will make a real test payment and find your own database completely unaware of it — on purpose, because that gap is exactly what Chapter 16 (Stripe II) exists to close.

What you’ll be able to do by the end

  • Create products and prices in Stripe’s test mode, and explain why their IDs live in config rather than in code.
  • Call POST /v1/billing/checkout and get back a working https://checkout.stripe.com/... URL.
  • Pay with a test card and watch Stripe record a subscription your database knows nothing about.
  • Say, in one sentence, why a browser arriving at your success URL must never grant anybody anything.
  • Read an INSERT ... ON CONFLICT statement and say what it does when the row already exists.

Time: ~50 minutes reading, ~45 minutes typing, plus about 10 minutes creating a Stripe account.

You need before starting: a working Chapter 14 (Rate limiting), which means everything from Chapter 11 (Stateful tokens) onwards still runs. Prove it in two commands:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

You should see 200. If you see 401, register that user again — the POST /v1/users endpoint is built in Chapter 10 (Users and passwords), and Chapter 11 (Stateful tokens) shows the exact command with this book’s example address.

Note

This chapter adds a prerequisite the front matter never mentioned: a Stripe account. It is free, takes a few minutes, and — in test mode — needs no company, no bank details and no legal entity. Step 1 walks through it. Chapter 16 additionally needs Stripe’s command-line tool, which we install here while we are in the neighbourhood.


1. The problem, in plain words

A customer wants to give you nine dollars a month. Between their intention and your bank account sits an industry.

The card number they type is one of the most heavily regulated pieces of data in commerce. The card industry publishes a standard — PCI DSS, maintained by a council the major card networks set up — describing what a system that handles raw card numbers must do: network segmentation, encryption at rest, quarterly scans, annual audits, logging you can prove is tamper-evident, and a written incident plan. Any machine that so much as sees a card number falls inside what the standard calls your scope. Your servers, your logs, your backups, your laptop if you ever debug with real data.

New word

PCI scope — the set of systems that touch raw card numbers, and therefore fall under the card industry’s compliance rules. The cheapest scope is an empty one. The entire architecture of this chapter is built to keep it empty.

That is not the only thing standing in the way. European rules (SCA, “Strong Customer Authentication”, implemented as the 3-D Secure flow) require many card payments to be confirmed by the cardholder in their banking app, which means your payment page needs to handle a challenge-and-return dance with a bank you have never heard of. Sales tax and VAT depend on where the customer is and what you sold them. Apple Pay and Google Pay have their own integration each. Cards expire, get cancelled, get declined for reasons the bank will not explain, and get retried on a schedule that is itself a business decision.

None of that is your product. Your product is a task list.

Think of it like

A shop takes card payments without ever learning your card number: you tap a terminal the bank supplied. The shopkeeper’s system hears “paid” and nothing else. Stripe Checkout is that terminal, rendered as a web page.

What breaks if you skip this chapter? Nothing technical — taskd works perfectly as a free product. What breaks is the business: there is no way to charge, so the Free tier is the only tier, so the plan limits in Chapter 17 (Entitlements and quotas) would be enforcing a distinction nobody can cross.

And there is a second failure this chapter exists to prevent, one that has nothing to do with plumbing. It is the single most common security hole in small SaaS products, and it looks entirely reasonable when you write it:

   the customer's browser comes back to /billing/success
                    │
                    ▼
        "great, they paid — mark them Pro"

Anyone can type that URL into a browser. We will come back to this repeatedly, because being convinced of it once is not enough.


2. New words in this chapter

Word What it means here
tier / plan A named package of features and limits at a price: Free, Pro, Business.
entitlement What a specific user is allowed to do right now, derived from their plan.
quota A numeric cap on something countable, e.g. 100 active tasks on the free plan.
feature gate A check that turns a feature on or off for a user based on their plan.
PCI scope The systems that touch raw card numbers, and so fall under card-industry audit rules.
SCA / 3-D Secure Rules requiring many card payments to be confirmed in the cardholder’s banking app.
Stripe Checkout A payment page Stripe hosts; the customer is redirected there and bounced back.
Stripe Elements / PaymentIntents Stripe’s lower-level parts for collecting cards yourself. The path not taken.
product (Stripe) Stripe’s record of a thing you sell, e.g. “taskd Pro”.
price ID Stripe’s identifier for what a product costs and how often, e.g. price_1Abc....
Stripe Customer Stripe’s record of one of your users, identified by an id like cus_QX9....
subscription A recurring charge that renews until cancelled.
Checkout Session One attempt at paying: a temporary object with a URL, identified by cs_test_....
test mode / live mode Stripe’s two parallel worlds. Test keys and test cards move no real money.
sk_test_ / price_ / cs_test_ Stripe’s id prefixes: secret key, price, checkout session.
SDK “Software Development Kit” — a vendor’s official library for calling their API from your language.
CLI “Command-Line Interface” — a tool you type commands at, such as stripe listen.
source of truth The one system whose answer wins when two systems disagree. For billing: Stripe.
projection A local copy of another system’s state, kept for speed, never treated as authoritative.
reconciliation Finding and fixing disagreements between two systems that both think they are right.
provisioning Actually granting a customer the thing they paid for.
webhook A URL on your server that another company calls when something happens on their side.
metadata Free-form key/value pairs you attach to a Stripe object; Stripe stores them and hands them back.
join key A value stored on both sides of a boundary, so a record over there can be matched to a record over here.
lazy creation Create the record the first time it is actually needed, rather than up front for everyone.
named type A Go type defined in terms of another, e.g. type Tier string, so the compiler can tell them apart.
package-level variable A variable declared outside any function; every function in the package shares one copy.
upsert Insert a row, or update it if it already exists — one statement.
EXCLUDED Inside an upsert, the name for the values you were trying to insert.
atomic A database operation that happens completely or not at all, with no visible half-way state.
idempotent Doing an operation twice has the same effect as doing it once.
idempotency ledger A table of already-processed event ids, so replaying an event does nothing the second time.
redirect An HTTP response that tells the browser “go to this other URL instead”.

3. The goal

Three plans defined in one Go file (Free / Pro / Business), Stripe test-mode products and prices created, each user lazily paired with a Stripe Customer, and POST /v1/billing/checkout returning a hosted Stripe Checkout URL for the chosen tier. No money logic of our own — Stripe hosts the payment page; we hold a URL and, soon, a webhook.


4. The thinking

4.1 Who owns the payment form?

This is the architecture decision that dwarfs all others here, and it has two honest answers.

Path (a): collect cards ourselves. Stripe publishes lower-level building blocks for this — Elements (drop-in card input fields for your own page) and PaymentIntents (the API object representing one attempt at moving money). You get maximal control over the look and the flow. You also now own PCI scope, the SCA/3-D Secure challenge flow, tax edge cases, wallet integrations, and a frontend project to hold it all.

Path (b): Stripe Checkout. You describe what you want to sell; Stripe gives you a URL; you send the customer’s browser there. Stripe’s page handles cards, wallets, SCA, taxes and localisation, then bounces the browser back to a URL you nominated.

(a) Elements / PaymentIntents (b) Hosted Checkout
Card data touches your servers Yes No
PCI scope Yours to defend Effectively none
SCA / 3-D Secure You build the challenge flow Stripe’s page handles it
Wallets (Apple/Google Pay) One integration each Included
Tax, currency, localisation Your problem Stripe’s problem
Frontend work A real project One redirect
Control over the look Total A logo and some colours
Right choice for A payments company An API-first SaaS run by a small team

For taskd, (b) is not even close. And the rule that generalises it is worth writing on a wall:

Remember this

Outsource everything adjacent to your product that a specialist does better, and keep only the integration seam.

That instinct comes from Kailash Nadh, one of the two influences named in How to read this book — the CTO whose teams run large systems on a small number of ordinary servers by refusing to build what they can borrow. The seam here is genuinely small: one HTTP call out, one URL back, and one webhook in the next chapter.

4.2 Who owns the truth?

Here is the decision to make now and never revisit:

Important

Stripe owns billing state. Our database holds a cached projection of it, updated only by webhooks.

The moment two systems both believe they are authoritative about who has paid, you have designed a reconciliation bug factory: nightly jobs comparing two sets of records, arguments about which is right, customers who paid and cannot use the product, customers who cancelled and still can.

New word

source of truth — the one system whose answer wins when two systems disagree. projection — a copy of that answer stored locally for speed, which is allowed to be briefly stale and is never allowed to be believed over the source.

The corollary — the one Chapter 16 enforces in code — follows immediately:

Warning

The user returning to your success_url proves nothing. A redirect can be forged (anyone can type the URL), abandoned (the customer closes the tab after paying), or beaten to the punch by the webhook (Stripe’s server-to-server call often lands first). Entitlements change when Stripe tells the server, never when a browser shows up claiming.

Say it back to yourself in the positive form: provisioning is triggered by a signed message from Stripe, not by a page view. If that sentence is boring to you by the end of Chapter 16, this book has done its job.

4.3 Where do the plans live: database table, or code?

A plans table sounds more “flexible” — change a limit without a deploy. Follow the thought through and the flexibility evaporates. Plan changes ship with code changes anyway, because the feature gates reference the features: adding “priorities are a paid feature” means writing an if somewhere regardless of where the flag is stored. And a table adds a database query to the hot path of every request that needs to know a user’s limits — which, from Chapter 17, is nearly all of them.

Plans in a database table Plans in one Go file
Changing a limit UPDATE in production A pull request, reviewed, deployed
Cost per request An extra query (or another cache layer) A map lookup, nanoseconds
Reviewable history Whatever your audit log captures git log
Typos Discovered by a customer Discovered by the compiler
Genuinely needs a table when Customers negotiate bespoke plans

Plans go in code: one file, a typed map, reviewed in pull requests like any other behaviour change. The only Stripe-side coupling is two price IDs — and those go in config, because they differ between test and live mode. Hardcoding them is a classic launch-day face-plant: the code that worked all through development calls a price that does not exist in the live account.

Remember this

Things that differ between environments are configuration. Things that differ between releases are code. Price IDs differ between environments; plan definitions do not.

4.4 Free is the absence of a subscription

A subtlety that saves a surprising amount of work: the Free tier is not a $0 Stripe subscription. It is no Stripe objects at all.

  • Fewer webhook states to handle.
  • No Stripe records for the 95% of signups who never open a wallet.
  • “Resolve this user’s tier” becomes one rule: subscription row present and healthy → its tier; otherwise → free.

That rule is the whole of Chapter 17’s resolver, and it is short because of this decision.

4.5 Customer creation: eager or lazy?

Stripe needs a Customer object — its record of one of your users — before it can attach a subscription. When do we create it?

Eager (at signup) Lazy (first upgrade click)
Third-party call on the registration path Yes — slower signup No
Signup fails when Stripe is down Yes No
Stripe account contents Thousands of customers who never paid Only people who tried to pay
Extra code None One if, in one helper

Lazy wins on every line that matters. Registration stays pure — a password hash and one insert — and Stripe stays clean. The cost is a single branch inside one helper function, which we write once and never think about again.

The result of that creation, stripe_customer_id, gets stored on the user row. It is the permanent join key between the two systems: given a taskd user you can find their Stripe records, and given a Stripe customer id you can find the taskd user.

New word

join key — a value stored on both sides of a boundary so that a record over there can be matched to a record over here. In SQL you join two tables on a key; here we are joining two companies’ databases.


5. A picture of it

The fence

Here is the whole chapter in one picture: two systems, a fence between them, and the keys that cross it.

        STRIPE OWNS THIS             │        OUR DATABASE HOLDS
                                     │
  ┌──────────────────────────────┐   │   ┌─────────────────────────────┐
  │ Product  "taskd Pro"         │   │   │ users                       │
  │ Price    price_1AbcPro...    │   │   │   id = 42                   │
  │          $9 every month      │   │   │   email, name               │
  ├──────────────────────────────┤   │   │   stripe_customer_id        │
  │ Customer cus_QX9...          │◀──┼───┼──   "cus_QX9..."   (key 1)  │
  │   metadata                   │   │   └─────────────────────────────┘
  │     taskd_user_id = "42"  ───┼───┼──▶  (key 2 — Chapter 16 uses it)
  ├──────────────────────────────┤   │
  │ Subscription sub_1Xyz...     │   │   ┌─────────────────────────────┐
  │   status = active            │───┼──▶│ subscriptions               │
  │   current_period_end         │webhook│   user_id, tier, status     │
  └──────────────────────────────┘ ch.16 │   current_period_end        │
                                     │   └─────────────────────────────┘
       THE SOURCE OF TRUTH           │       A CACHED PROJECTION
                                     │       (still empty after
                                     │        this chapter)

Walking it through:

  1. Product and Price are things you create once in Stripe’s dashboard. We never store them; we store only the price ids, in config.
  2. Customer is created by our code, lazily, on the first upgrade click.
  3. Key 1 is users.stripe_customer_id — our row pointing at their object.
  4. Key 2 is the taskd_user_id metadata — their object carrying our id. Two keys, pointing opposite ways, so that either side can start the lookup.
  5. Subscription is created by Stripe when the customer pays on Stripe’s page. Our subscriptions table is a copy of it that only the webhook is allowed to write — and in this chapter, nothing writes it at all.

The redirect flow

What actually happens when someone upgrades. The one arrow with an X on it is the point of the chapter.

  1  client                    2  taskd
  ┌──────────┐  POST /v1/billing/checkout  ┌──────────────────────────┐
  │ curl or  │ ──────────────────────────▶ │ createCheckoutHandler    │
  │ your app │                             │  which price?            │
  └──────────┘                             │  ensure a Customer  ─────┼─▶ Stripe
       ▲                                   │  session.New(...)   ─────┼─▶ Stripe
       │   {"checkout_url":"https://..."}  └────────────┬─────────────┘
       └────────────────────────────────────────────────┘
       │
       │  3  the browser opens that URL
       ▼
  ┌────────────────────────────────┐
  │ checkout.stripe.com            │   card number, wallet, 3-D Secure,
  │ Stripe's page, Stripe's        │   tax, receipt — none of it ours,
  │ servers, Stripe's compliance   │   none of it on our machines
  └───────────────┬────────────────┘
                  │  4  redirect to success_url
                  ▼
  ┌────────────────────────────────┐         ┌────────────────────────┐
  │ a "thanks!" page               │ ──╳───▶ │ grant Pro entitlements │
  │ (belongs to your web frontend) │         └────────────────────────┘
  └────────────────────────────────┘          NEVER. Anyone can GET it.

  5  separately, server to server, out of band:

     Stripe ──▶ POST /v1/stripe/webhook ──▶ writes `subscriptions`
                (Chapter 16 — the only code allowed to do that)

6. The steps

The original edition covers this in six steps. Here they are as ten, in the same order, with the Stripe-side setup and the wiring broken out so nothing happens off-camera.

Step What it touches
1–3 Stripe’s side: account, products, keys, tools
4 internal/data/plans.go — new file
5 migrations/000005_billing.*.sql — new files
6 sql/queries/billing.sql — new file, then make sqlc
7 cmd/api/billing.go — new file
8 cmd/api/main.go — the SDK key block
9 cmd/api/routes.go — three lines
10 Run it, pay, and look at the empty table

Part A — Stripe’s side of the fence

Step 1 — Get a test-mode account and create two products

Sign up at stripe.com. You do not need a company, a bank account or a tax number to use test mode, which is a complete parallel copy of Stripe: its own customers, its own products, its own payments, and cards that are not real.

New word

test mode / live mode — two separate worlds inside one Stripe account. Objects created in one are invisible in the other, and their ids are different. Test-mode secret keys start sk_test_; live ones start sk_live_. Nothing you do in test mode moves money.

In the dashboard, make sure the test-mode switch is on — Stripe puts a visible marker on every screen when it is — then create two products. Stripe’s dashboard wording shifts over time; the shape does not:

  1. Go to Products, add a product named taskd Pro.
  2. Give it a recurring price of $9, billed monthly. “Recurring” is the word that makes it a subscription rather than a one-off charge.
  3. Repeat for taskd Business at $29 per month.

Each price gets an id that looks like price_1AbcDefGhiJklMno. Copy both somewhere; you need them in the next step.

New word

product and price are two separate Stripe objects on purpose. A product is what you sell (“taskd Pro”); a price is what it costs and how often ($9/month). One product can carry several prices — monthly and yearly, dollars and euros — which is why the id we store is the price id, never the product id.

You also need your secret key. In the dashboard’s API-keys screen, in test mode, it starts with sk_test_.

Warning

A secret key is a password to your Stripe account. Anyone holding it can create charges, read your customer list and issue refunds. It belongs on your server and nowhere else — not in a frontend, not in a screenshot, not in a git commit. If you ever paste one somewhere public, go to the dashboard and roll (revoke and replace) it immediately; that button exists because everyone does it once.

Step 2 — Put the keys and price ids in config

Chapter 3 (Configuration and logging) already created the [stripe] block with empty values, and already loaded every one of these keys into cfg.stripe. Nothing new is needed in config.go; you are filling in blanks.

# config.toml — fill in the [stripe] block that has been empty since Chapter 3
[stripe]
secret_key            = "sk_test_...your test key..."
webhook_secret        = ""
price_id_pro          = "price_...your Pro price id..."
price_id_business     = "price_...your Business price id..."
success_url           = "http://localhost:4000/v1/billing/success"
cancel_url            = "http://localhost:4000/v1/billing/cancel"

webhook_secret stays empty; Chapter 16 (Stripe II) fills it.

Warning

config.toml is committed to git. Chapter 3’s secrets rule was: a real secret never lives in a committed file. For local development against test mode the risk is small — a sk_test_ key can only ever move fake money — but the habit is what you are building. The honest version, and the one you must use in production, is the environment override koanf already supports:

export TASKD_STRIPE__SECRET_KEY="sk_test_..."

Chapter 3 explains the __. translation that makes that variable land on the same key the TOML file uses. Put the line in your .envrc (which is not committed) rather than the TOML file if you want to build the muscle now.

About those two URLs. They are where Stripe sends the customer’s browser after the payment page finishes — one for success, one for “I changed my mind”. They are ordinary web pages that belong to your frontend, which taskd does not have.

Note

This book never registers a route at /v1/billing/success or /v1/billing/cancel. If you complete Step 10’s payment, your browser will land on taskd’s own 404 response — {"error":"the requested resource could not be found"}. That is expected, and it is a surprisingly good teacher: the page you return to genuinely does not matter, because it is not what grants access. If you already have a web frontend running, set the URLs to it instead (for example http://localhost:3000/billing/success) — it changes nothing about the API’s behaviour.

Step 3 — Install the SDK and the CLI

go get github.com/stripe/stripe-go/v78
# stripe CLI: https://docs.stripe.com/stripe-cli   (needed for ch. 16)

What these do

  • go get downloads a library and records it in go.mod, the file listing your project’s dependencies. You should see one or more go: downloading ... lines, then a line beginning go: added github.com/stripe/stripe-go/v78. If the module is already present it prints nothing new.
  • /v78 is part of the import path, not a decoration. Go’s rule for libraries at version 2 or above is that the major version appears in the path, so that v78 and a future v79 can coexist in one build. Chapter 16 imports from the same /v78 path.
  • The Stripe CLI is a separate program you install with your operating system’s package manager (brew install stripe/stripe-cli/stripe on macOS; the link above lists the rest). It is not needed today. It is needed in Chapter 16, where stripe listen forwards real Stripe events to your laptop, and installing it now means one less thing going wrong then.
New word

SDK (“Software Development Kit”) — a vendor’s official library for calling their API from your language, so you write customer.New(...) instead of hand-building an HTTP request, signing it, and parsing the JSON back. CLI (“Command-Line Interface”) — a program you type commands at.

Check the CLI landed:

stripe --version

You should see a line containing stripe version and a number. If your shell answers command not found: stripe, the CLI is not installed — that is fine for this chapter, but come back before Chapter 16.


Part B — Our side of the fence

Step 4 — Write the plans down, in code

A new file, and the only place in the codebase where “what does a plan include” is answered.

// internal/data/plans.go — new file
package data

type Tier string

const (
    TierFree     Tier = "free"
    TierPro      Tier = "pro"
    TierBusiness Tier = "business"
)

// Entitlements: -1 means unlimited.
type Entitlements struct {
    MaxActiveTasks int
    RatePerMinute  int
    Priorities     bool // may set priority != "none"
    Search         bool // may use ?search=
}

var Plans = map[Tier]Entitlements{
    TierFree:     {MaxActiveTasks: 100, RatePerMinute: 60, Priorities: false, Search: false},
    TierPro:      {MaxActiveTasks: 10_000, RatePerMinute: 300, Priorities: true, Search: true},
    TierBusiness: {MaxActiveTasks: -1, RatePerMinute: 1000, Priorities: true, Search: true},
}

What this code says, line by line

  • type Tier string declares a named type. Tier behaves like a string — you can compare it, print it, use it as a map key — but the compiler treats it as a distinct type. A function expecting a Tier will refuse a string variable, and vice versa, until you convert explicitly with Tier(s). That refusal is the point: it is impossible to pass a task’s status where a Tier was wanted, because they are different types even though both are text underneath. (The one exception is a bare literal like "free", which has no type of its own yet and takes whichever one the context asks for — which is why the const block below can write TierFree Tier = "free" without a conversion.)
  • const ( ... ) declares three constants of that type. Their values are lowercase strings, which matters because those strings end up in JSON requests ({"tier":"pro"}) and in a database column.
  • type Entitlements struct { ... } is a plain record of four fields: two numbers and two yes/no flags. A struct is Go’s way of grouping related values under one name.
  • -1 means unlimited is a convention, stated in a comment because Go has no way to state it in the type. 0 would have been a terrible choice for “unlimited” — it is also the zero value a forgotten field gets.
  • 10_000 is ten thousand. Go lets you put underscores inside numbers purely so long ones stay readable; the compiler ignores them.
  • var Plans = map[Tier]Entitlements{ ... } is a map — a lookup table from key to value. Read the type as “keys are Tier, values are Entitlements”. Plans[TierPro].Search is true.
  • The capital letters on Tier, Entitlements and Plans are what make them visible outside the data package. Go has no public keyword; the case of the first letter is the access rule.

Rendered as a table, which is how you should read it:

Free Pro Business
MaxActiveTasks 100 10,000 -1 (unlimited)
RatePerMinute 60 300 1000
Priorities no yes yes
Search no yes yes

Choosing which features gate which tiers is product design, not engineering. The engineering requirement is only that the gates are declarative data sitting in one file, so that changing the product does not mean going spelunking through handlers looking for if tier == "pro".

Note

RatePerMinute: 60 is not a coincidence. Chapter 14 (Rate limiting) wrote limit := 60 // req/min — replaced by entitlements in ch. 17 and called it a seam waiting to be filled. This map is what fills it. Nothing consumes Plans yet — Chapter 17 is where it starts doing work.

Note

Search: false on the free tier means the ?search= filter from Chapter 9 (Listing at scale) becomes a paid feature. Chapter 9’s own pitfall notes that its ILIKE '%term%' query cannot use an ordinary index, so selling it is a promise you will eventually have to make good on with a proper text index. Worth knowing now; nothing to do about it today.

Step 5 — The billing migration

Three schema changes in one migration: a column on users, and two new tables.

make db/migrations/new name=billing

You should see two file paths printed, ending migrations/000005_billing.up.sql and migrations/000005_billing.down.sql.

-- migrations/000005_billing.up.sql
ALTER TABLE users ADD COLUMN stripe_customer_id text UNIQUE;

CREATE TABLE subscriptions (
    user_id                bigint PRIMARY KEY REFERENCES users ON DELETE CASCADE,
    stripe_subscription_id text NOT NULL UNIQUE,
    tier                   text NOT NULL,
    status                 text NOT NULL,
    current_period_end     timestamptz NOT NULL,
    updated_at             timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE stripe_events (
    id          text PRIMARY KEY,
    received_at timestamptz NOT NULL DEFAULT now()
);

What this code says, line by line

  • ALTER TABLE users ADD COLUMN stripe_customer_id text UNIQUE; — a new column on the existing users table. It is nullable (no NOT NULL), and that is the whole lazy-creation decision expressed in the schema: most users will never have one. UNIQUE means Postgres will reject any attempt to point two of our users at the same Stripe customer. In Postgres, several rows may hold NULL in a unique column without conflicting, because NULL means “unknown” rather than a value — which is exactly what we need here.
  • user_id bigint PRIMARY KEY REFERENCES users ON DELETE CASCADE — three things at once, and the most interesting line in the file. See the callout below.
  • stripe_subscription_id text NOT NULL UNIQUE — Stripe’s id for the subscription (sub_...). Unique, because one Stripe subscription must not be claimed by two of our rows.
  • tier and status as text — copies of Stripe’s answer. status holds Stripe’s own vocabulary (active, trialing, past_due, canceled, unpaid), stored verbatim rather than translated, because translating a vocabulary you do not own is how you end up with a value you cannot represent.
  • current_period_end timestamptz NOT NULL — when the paid-for period runs out. timestamptz is a moment in time stored with its time zone, which Chapter 5 (PostgreSQL and migrations) argued is the only sane choice.
  • updated_at timestamptz NOT NULL DEFAULT now() — when we last wrote this row. Not a business field; a debugging field. When a customer says “I upgraded an hour ago”, this column answers “and we heard about it at 14:32”.
  • CREATE TABLE stripe_events (id text PRIMARY KEY, ...) — a table with essentially no contents: a list of event ids we have already seen. Chapter 16 explains why. It is created now so that the migration numbering stays tidy rather than gaining a stray 000006_stripe_events.
Important

user_id is the PRIMARY KEY of subscriptions, not merely a foreign key. A primary key is unique by definition, so this single word encodes a business rule directly into the schema: one subscription per user. Not “one, we promise, as long as every code path remembers” — one, enforced by Postgres, forever, no matter what future code does. When you can spend a keyword to make a rule unbreakable, spend it.

New word

REFERENCES users — a foreign key: this value must be the id of a real row in users. ON DELETE CASCADE — when that user row is deleted, delete this row too. Chapter 12 (Ownership) called cascades loaded weapons and meant it; here it is right, because a subscription with no user is not a record, it is litter.

Now the down file. Chapter 5 promised every migration would be paired with one that undoes it, and the original edition never printed this one.

-- migrations/000005_billing.down.sql
-- Shown here for the first time: the mechanical inverse of the up file.
DROP TABLE IF EXISTS stripe_events;
DROP TABLE IF EXISTS subscriptions;
ALTER TABLE users DROP COLUMN IF EXISTS stripe_customer_id;

The order matters. subscriptions has a foreign key pointing at users. Postgres will not let you drop a column another table depends on while that dependency exists, so the dependent table goes first and the column it referenced goes last. Reading a down migration bottom-to-top should give you the up migration; if it does not, one of them is wrong. IF EXISTS means “and do not complain if it is already gone”, which makes the file safe to run twice.

Apply it:

make db/migrations/up

You should see a line beginning 5/u billing followed by a duration in parentheses.

Checkpoint

Run make db/psql, then at the taskd=# prompt type \d subscriptions and press Enter. You should see the six columns listed, a line under Indexes: naming subscriptions_pkey as a PRIMARY KEY on user_id, and a Foreign-key constraints: line mentioning ON DELETE CASCADE. Type \q to leave.

Step 6 — The billing queries

A new query file. sqlc compiles every .sql file in sql/queries/, so creating this one is all the registration it needs.

-- sql/queries/billing.sql — new file

-- name: SetStripeCustomerID :exec
UPDATE users SET stripe_customer_id = $2 WHERE id = $1;

-- name: GetUserByStripeCustomerID :one
SELECT * FROM users WHERE stripe_customer_id = $1;

-- name: UpsertSubscription :exec
INSERT INTO subscriptions
    (user_id, stripe_subscription_id, tier, status, current_period_end)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id) DO UPDATE SET
    stripe_subscription_id = EXCLUDED.stripe_subscription_id,
    tier                    = EXCLUDED.tier,
    status                  = EXCLUDED.status,
    current_period_end      = EXCLUDED.current_period_end,
    updated_at              = now();

-- name: GetSubscription :one
SELECT * FROM subscriptions WHERE user_id = $1;

-- name: DeleteSubscription :exec
DELETE FROM subscriptions WHERE user_id = $1;

-- name: InsertStripeEvent :execrows
INSERT INTO stripe_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING;

Only one of these six is used in this chapter — SetStripeCustomerID. The rest are written now because we are already in the file, and Chapter 16 needs all of them. Two deserve unpacking.

UpsertSubscription, and what ON CONFLICT means. An upsert is “insert this row, or update it if it is already there”, in one statement.

  INSERT INTO subscriptions (...) VALUES (...)
        │
        ├── no row with this user_id ────────▶ insert it. Done.
        │
        └── a row with this user_id exists ──▶ ON CONFLICT (user_id) DO UPDATE
                                               overwrite its columns with
                                               the values I was inserting

EXCLUDED is Postgres’s name for “the row I was trying to insert but could not”. So tier = EXCLUDED.tier reads: set the existing row’s tier to the tier I brought with me. updated_at = now() is set from scratch rather than from EXCLUDED, because the insert did not carry one.

Why an upsert rather than “check, then insert or update”? Because Chapter 16 receives the same subscription several times — creation, renewal, plan switch, payment failure — in an order nobody promises. An upsert does not care whether the row exists, and it does not care how many times you run it. Code that asks first and writes second has to worry about what happens between the two.

InsertStripeEvent, a small masterpiece of boring engineering.

-- name: InsertStripeEvent :execrows
INSERT INTO stripe_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING;

:execrows tells sqlc to generate a function returning how many rows were affected. DO NOTHING says: if that id is already in the table, quietly do nothing. Put them together and one statement answers a question:

Rows returned Meaning
1 First time I have seen this event. Process it.
0 I have seen it before. Skip it.

And it answers that question atomically — the check and the write are a single database operation, so two copies of the event arriving at the same moment cannot both see “not there yet”.

New word

atomic — happens completely or not at all, with no observable half-way state. idempotent — doing it twice has the same effect as doing it once. idempotency ledger — a table of ids already processed, so a replayed message becomes a no-op.

Note

Chapter 7 (sqlc: SQL in, type-safe Go out) introduced :execrows and promised “we’ll want that one for idempotency in Chapter 16”. This is where the query is actually written; Chapter 16 is where it is called. If you went looking in Chapter 16 for the SQL, that is why you did not find it.

Regenerate the Go code:

make sqlc

That runs sqlc generate, which reads migrations/ for the table shapes and sql/queries/ for the queries. It prints nothing on success. Confirm what appeared:

ls internal/db/

You should see a new billing.sql.go alongside the existing generated files, and models.go will have gained Subscription and StripeEvent structs. Never edit these files; the header of each one says DO NOT EDIT and means it.

Three details in the generated code worth looking at, because they explain lines you are about to write in Go:

  1. User has gained a field StripeCustomerID *string — a pointer, because the column is nullable and sqlc.yaml sets emit_pointers_for_null_types: true. nil means SQL NULL means “this user has never been to Stripe”. That is why Step 7 tests full.StripeCustomerID != nil rather than comparing to "".
  2. SetStripeCustomerID takes a params struct with two fields, because its query has two parameters. The fields are ordered by parameter number, not by where the parameter appears in the SQL text: the UPDATE writes $2 first (in SET) and $1 afterwards (in WHERE), and the struct still comes out as {ID, StripeCustomerID}. sqlc names the fields, so you cannot get them backwards anyway.
  3. CurrentPeriodEnd is a plain time.Time. That is thanks to the timestamptz override Chapter 7 put in sqlc.yaml; without it the field would be a pgtype.Timestamptz wrapper and every assignment in Chapter 16 would need unwrapping.

Build, to prove nothing broke:

go build ./... && echo BILLING-SCHEMA-OK

You should see BILLING-SCHEMA-OK. Adding a field to a generated struct is harmless here because no code in the project constructs a db.User by listing every field.


Part C — The seam

Step 7 — The billing handler

This is the chapter’s Go, and it is one new file with two functions. Read it once for shape, then take the decode below it.

// cmd/api/billing.go — new file
package main

import (
    "net/http"

    "github.com/stripe/stripe-go/v78"
    "github.com/stripe/stripe-go/v78/checkout/session"
    "github.com/stripe/stripe-go/v78/customer"

    "github.com/yourname/taskd/internal/data"
    "github.com/yourname/taskd/internal/db"
    "github.com/yourname/taskd/internal/validator"
)

// ensureStripeCustomer implements the lazy-creation decision.
func (app *application) ensureStripeCustomer(r *http.Request,
    user *db.GetUserForTokenRow) (string, error) {

    // The context row (from the auth middleware) is the slim variant
    // without stripe_customer_id, so re-fetch the full user row.
    full, err := app.q.GetUserByEmail(r.Context(), user.Email)
    if err != nil {
        return "", err
    }
    // Already paired with Stripe? Done — lazy creation means this is
    // the common path after the first upgrade click.
    if full.StripeCustomerID != nil {
        return *full.StripeCustomerID, nil
    }

    // First contact: create the Customer over Stripe's API. The
    // metadata rides along on Stripe's side of the fence — our user ID
    // stamped onto their object, so webhooks can always find the way home.
    c, err := customer.New(&stripe.CustomerParams{
        Email: stripe.String(user.Email),
        Name:  stripe.String(user.Name),
        Params: stripe.Params{Metadata: map[string]string{
            "taskd_user_id": itoa(user.ID),
        }},
    })
    if err != nil {
        return "", err
    }
    err = app.q.SetStripeCustomerID(r.Context(), db.SetStripeCustomerIDParams{
        ID: user.ID, StripeCustomerID: &c.ID,
    })
    return c.ID, err
}

That is half the file. Here is what it is doing, drawn:

  POST /v1/billing/checkout
         │
         ▼
  ensureStripeCustomer(r, user)
         │
         ▼
  SELECT ... FROM users WHERE email = $1
         │
         ├── stripe_customer_id IS NOT NULL
         │        └──▶ return it            ◀── every click after the first
         │
         └── stripe_customer_id IS NULL
                  │
                  ├──▶ customer.New(...)  ──▶ Stripe replies "cus_QX9..."
                  │
                  └──▶ UPDATE users SET stripe_customer_id = 'cus_QX9...'
                             │
                             └──▶ return it  ◀── first click only, once ever

What this code says, line by line

  • func (app *application) ensureStripeCustomer(...) — the (app *application) before the name makes this a method on the application struct from Chapter 2 (The skeleton) — the one box holding the logger, config, database pool and queries — which is how it reaches app.q (the sqlc queries) without anything being passed in. Every handler in this codebase is written the same way.
  • user *db.GetUserForTokenRow — the user object Chapter 11’s authentication middleware put in the request context. It is a slim row: the auth query deliberately selects only six columns and omits password_hash, so the middleware’s user object physically cannot leak it. It also omits stripe_customer_id, which is why the next line exists.
  • app.q.GetUserByEmail(r.Context(), user.Email) — re-fetch the complete row. One extra query on a path that ends in a call to another company over the internet; the cost is not worth optimising.
  • r.Context() — the request’s context, carrying the deadline and the cancellation signal. If the client hangs up, this query is told to stop. Every database call in the book takes one.
  • if full.StripeCustomerID != nil — the field is a *string (a pointer) because the column is nullable. nil means the user has never been paired with Stripe.
  • return *full.StripeCustomerID, nil — the * in front dereferences the pointer: “give me the string this points at”. Reading through a nil pointer crashes the program, which is why this line sits inside the != nil branch and nowhere else.
  • customer.New(&stripe.CustomerParams{...}) — the outbound HTTP call to Stripe, wearing a Go costume. customer is the SDK package we imported; New creates a Customer; the & passes a pointer to the params struct because that is the signature the SDK offers.
  • stripe.String(user.Email) — a helper that returns a *string pointing at its argument. The SDK wants pointers everywhere so that it can tell “this field was not set” (nil) from “this field was set to empty” (""). It is the same distinction Chapter 8 (CRUD done properly) used for PATCH requests, where a missing field and a cleared field mean different things. You cannot write Email: user.Email — see the compile error in section 8.
  • Params: stripe.Params{Metadata: map[string]string{"taskd_user_id": itoa(user.ID)}}metadata is free-form key/value data that Stripe stores on its object and hands back on everything related to it. We stamp our user id onto their customer. That is join key 2 from the fence diagram. itoa is the two-line helper from Chapter 8 that turns an int64 into a string.
  • db.SetStripeCustomerIDParams{ID: user.ID, StripeCustomerID: &c.ID} — write the id back to our row. &c.ID takes the address of the field, producing the *string the generated params struct wants; a pointer here is how you say “not NULL, this value”.
  • return c.ID, err — deliberately returns the customer id together with whatever the UPDATE returned. If the write failed, the caller gets an error and stops.
Common mistake

The failure this code has, and why we keep it: if customer.New succeeds and the UPDATE then fails, Stripe holds a customer our database has never heard of. On the next click we create a second Stripe customer for the same person. It is untidy rather than dangerous — the metadata still names the right user, and no money is affected — and fixing it properly needs an idempotency key on the Stripe call, which Chapter 23 (Hardening the edge) introduces as a concept. Knowing where your code is imperfect is different from not having noticed.

Now the handler itself, in the same file:

// cmd/api/billing.go — the second half of the same file
func (app *application) createCheckoutHandler(w http.ResponseWriter, r *http.Request) {
    user := app.contextGetUser(r)

    var input struct {
        Tier string `json:"tier"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    var priceID string
    switch data.Tier(input.Tier) {
    case data.TierPro:
        priceID = app.config.stripe.priceIDPro
    case data.TierBusiness:
        priceID = app.config.stripe.priceIDBusiness
    default:
        v := validator.New()
        v.AddError("tier", "must be one of: pro, business")
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    custID, err := app.ensureStripeCustomer(r, user)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Describe the checkout we want Stripe to host. (stripe.String etc.
    // exist because the SDK wants *pointers* — nil distinguishes
    // "unset" from "empty", the same trick as our PATCH handler.)
    params := &stripe.CheckoutSessionParams{
        // subscription mode = recurring billing, not a one-off charge
        Mode:     stripe.String(string(stripe.CheckoutSessionModeSubscription)),
        Customer: stripe.String(custID),
        LineItems: []*stripe.CheckoutSessionLineItemParams{{
            Price: stripe.String(priceID), Quantity: stripe.Int64(1),
        }},
        // Where the browser bounces after paying / backing out. Display
        // only — NEVER a provisioning trigger (see the drumbeat below).
        SuccessURL: stripe.String(app.config.stripe.successURL),
        CancelURL:  stripe.String(app.config.stripe.cancelURL),
        // Our user ID, echoed back in webhook payloads: belt to the
        // customer-metadata braces.
        ClientReferenceID: stripe.String(itoa(user.ID)),
    }
    s, err := session.New(params)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusOK, envelope{"checkout_url": s.URL}, nil)
}

What this code says, line by line

  • app.contextGetUser(r) — pulls the authenticated user out of the request context, exactly as every task handler does since Chapter 12.
  • The var input struct { ... } block — an anonymous struct declared on the spot to receive the request body: one field, Tier, of type string. The backticked text after the type is a struct tag, and it tells the JSON decoder that this Go field corresponds to the key "tier" in the JSON body.
  • app.readJSON(w, r, &input) — Chapter 8’s helper: decode the body into that struct, with a size limit and human-readable errors. Failure is the client’s fault, hence badRequestResponse (400).
  • switch data.Tier(input.Tier)data.Tier(...) is a type conversion: take the plain string the client sent and re-label it as a Tier so it can be compared against the constants. The conversion does not validate anything; data.Tier("banana") is a perfectly legal Tier. The switch is the validation.
  • case data.TierPro: priceID = app.config.stripe.priceIDPro — this is the entire translation between our vocabulary and Stripe’s. Our "pro" becomes their price_1Abc..., and the mapping lives in config so that the same binary works against test and live accounts.
  • default: — anything else, including "free" and "", is a validation failure. Note that asking to check out on the free tier is correctly rejected: free is the absence of a subscription, so there is nothing to buy.
  • validator.New() / v.AddError(...) / failedValidationResponse — Chapter 8’s validation path, producing a 422 with a per-field message. Using it here rather than a bare 400 means the billing endpoint’s errors look like every other endpoint’s errors.
  • stripe.CheckoutSessionParams — the description of the payment page we want. A Checkout Session is one attempt at paying: it has a URL, a lifetime of about a day, and an id starting cs_test_.
  • Mode: ...CheckoutSessionModeSubscription — recurring billing, not a single charge. The double conversion (stripe.String(string(...))) is the SDK’s own constant being turned into a string, then into a pointer to a string. Ungainly, and not ours to fix.
  • LineItems: []*stripe.CheckoutSessionLineItemParams{{Price: ..., Quantity: ...}} — a slice (a list) holding one item: this price, quantity one. The doubled braces are Go letting you write {...} instead of repeating the element type inside a slice literal.
  • SuccessURL / CancelURL — where Stripe sends the browser afterwards. Display only. See the comment; see the warning; see the pitfall. It is repeated because it is the mistake.
  • ClientReferenceID: stripe.String(itoa(user.ID)) — our user id again, this time attached to the session rather than the customer. Two independent ways for Chapter 16 to identify who paid. Belt and braces: if the customer metadata is ever lost or mangled, the correlation still works.
  • session.New(params) — the second outbound call to Stripe. Everything before this was preparation.
  • s.URL — the hosted page’s address. This is the only thing our API returns.
  • app.writeJSON(w, http.StatusOK, envelope{"checkout_url": s.URL}, nil) — Chapter 8’s JSON writer, wrapping the value in the same {"key": ...} envelope every response in this API uses.
Think of it like

This handler is a receptionist, not a cashier. It checks you are a customer, works out which counter you need, phones ahead, and hands you a slip with the counter’s address on it. It never sees your wallet.

Step 8 — Give the SDK its key

The Stripe SDK does not take your key as an argument. It reads a package-level variable — one copy shared by every function in the package — that you set once at startup.

// cmd/api/main.go — imports gain "github.com/stripe/stripe-go/v78"
// in main(), after loadConfig:
stripe.Key = cfg.stripe.secretKey
if stripe.Key == "" {
    logger.Warn("stripe.secret_key is empty; billing endpoints will fail")
}

For orientation, that block goes here, between the logger and the database:

// cmd/api/main.go — where the Stripe block lands inside main()
    cfg, err := loadConfig(*configPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    logger := newLogger(cfg)

    // --- Stripe SDK key (ch. 15): package-level, set once ---
    stripe.Key = cfg.stripe.secretKey
    if stripe.Key == "" {
        logger.Warn("stripe.secret_key is empty; billing endpoints will fail")
    }

    pool, err := openDB(cfg)
    // ... unchanged from here down

What this code says

  • stripe.Key is a variable inside the stripe package, not a field on our application struct. Assigning to it configures every subsequent SDK call in the whole program.
  • It must be set before anything calls Stripe, which is why it sits immediately after the config loads and long before the server starts accepting requests.
  • The Warn line is doing real work. Without it, an empty key produces a 500 from the checkout endpoint and a Stripe error in the logs that says nothing about configuration. With it, the boot log answers the question before it is asked.
Note

A package-level global variable is un-Go-like — this codebase otherwise carries every dependency explicitly on the application struct so that nothing is hidden. But it is the SDK’s design, and arguing with a library’s design costs more than accepting it. Knowing which rule you are breaking, and why, is the difference between pragmatism and sloppiness.

Tip

If you did Chapter 3’s optional logConfig exercise, its startup line already prints stripe_key_set=true or false. Two independent signals in the boot log, both free.

Step 9 — Register the route

The endpoint requires a logged-in user, so it goes inside the authenticated group Chapter 11 created and Chapter 14 added the per-user rate limiter to.

// cmd/api/routes.go — add inside the r.Group that uses requireAuthenticatedUser
r.Route("/billing", func(r chi.Router) {
    r.Post("/checkout", app.createCheckoutHandler)
})

In context, the authenticated group now looks like this:

// cmd/api/routes.go — the authenticated group, for orientation
    r.Group(func(r chi.Router) {
        r.Use(app.requireAuthenticatedUser)
        r.Use(app.rateLimitUser)              // ch. 14

        r.Route("/tasks", func(r chi.Router) {
            // ... the five task routes from ch. 8 and ch. 9
        })

        r.Route("/billing", func(r chi.Router) {
            r.Post("/checkout", app.createCheckoutHandler)
        })
    })

What this code says

  • r.Route("/billing", ...) creates a sub-router at that prefix. Paths registered inside it are relative, so r.Post("/checkout", ...) becomes POST /v1/billing/checkout.
  • It is inside the group, so requireAuthenticatedUser runs first and an anonymous request gets a 401 before the handler ever runs. contextGetUser can therefore assume a real user.
  • It is also inside rateLimitUser, which matters more than it looks: each request here can cost two calls to Stripe’s API, and Stripe rate-limits you.
Warning

Later chapters add more billing routes, and they belong inside this same sub-routerr.Get("/plan", ...) in Chapter 17, not r.Get("/billing/plan", ...) alongside it. chi treats r.Route as a mount, and registering sibling patterns under a prefix that is already mounted is fragile at best and a startup panic at worst. Appendix F prints the complete final routes.go if you ever want to check where a route really ended up.

Step 10 — Run it, pay, and look at the empty table

Rebuild and start the server:

go build ./... && make run/api

In another terminal, get a token and ask for a checkout URL:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

curl -H "Authorization: Bearer $TOKEN" -d '{"tier":"pro"}' \
  localhost:4000/v1/billing/checkout
# {"checkout_url":"https://checkout.stripe.com/c/pay/cs_test_..."}

What you should see: a single JSON object with one key, checkout_url, whose value begins https://checkout.stripe.com/c/pay/cs_test_ followed by a long opaque string.

Open that URL in a browser. You get Stripe’s page, showing “taskd Pro” and $9.00 per month. Pay with Stripe’s magic test card:

  card number   4242 4242 4242 4242
  expiry        any date in the future    (12 / 34 works)
  CVC           any three digits
  postcode      any value it asks for
Important

No real money moves. 4242 4242 4242 4242 is a published test number that only exists in Stripe’s test mode. It is not connected to a bank. There are others — cards that always decline, cards that always demand 3-D Secure — listed in Stripe’s testing documentation.

After payment the browser is redirected to your success_url and lands on taskd’s own 404: {"error":"the requested resource could not be found"}. As promised in Step 2, that is expected.

Now the part that matters. Look at Stripe: in the dashboard, under Customers, there is a customer with your email and an active subscription to taskd Pro. Then look at us:

make db/psql
-- typed at the taskd=# prompt
SELECT * FROM subscriptions;

What you should see: the six column headings, then (0 rows).

-- typed at the taskd=# prompt
SELECT id, email, stripe_customer_id FROM users;

What you should see: your user, with a stripe_customer_id starting cus_. Type \q to leave.

Checkpoint

A payment happened. Stripe knows. Our subscriptions table is empty. Both facts are correct.

Sit with that gap for a moment, because it is the entire argument of the next chapter. Stripe is the source of truth and it has changed its mind about this customer. Nothing has told us. The browser came back to our success URL and we ignored it, correctly, because a browser arriving at a URL is not evidence of anything.

The thing that closes the gap is a message from Stripe’s servers to ours, signed with a shared secret, arriving whether or not any browser cooperates. That is a webhook, and it is Chapter 16.

New word

webhook — a URL on your server that another company calls when something happens on their side. The reverse of the calls you make to them: instead of you phoning Stripe every minute to ask “anything new?”, Stripe rings your doorbell.


7. Checkpoint: prove it works

Five checks, from the schema outwards. Run them in order.

# 1. the schema arrived
make db/psql

At the taskd=# prompt: \d subscriptions should list six columns, with subscriptions_pkey as a primary key on user_id. \d stripe_events should show two columns. \d users should include stripe_customer_id | text among the columns and a unique index on it. Type \q.

# 2. the generated code arrived
ls internal/db/billing.sql.go
go build ./... && echo BILLING-BUILDS

You should see the path printed, then BILLING-BUILDS.

# 3. the key reached the SDK
make run/api

In the startup output you should not see the line stripe.secret_key is empty; billing endpoints will fail. If you do, config is not reaching the program.

# 4. a bad tier is rejected before anything reaches Stripe
curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"enterprise"}' \
  localhost:4000/v1/billing/checkout

You should see {"error":{"tier":"must be one of: pro, business"}}, with HTTP status 422. Add -o /dev/null -w '%{http_code}\n' to see the code itself.

# 5. a good tier produces a real Stripe URL
curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"business"}' \
  localhost:4000/v1/billing/checkout

You should see a checkout_url beginning https://checkout.stripe.com/c/pay/cs_test_.

If you got something else

You got Cause Fix
{"error":"the server encountered a problem and could not process your request"} and a log line containing No such price The price id in config does not exist in the account the secret key belongs to — usually a test key with a live price id, or a copy-paste of a product id Re-copy both price_... ids from the dashboard in test mode, alongside the sk_test_ key
The same 500, with a log line about an invalid API key secret_key is wrong, or an environment variable is overriding the file with something stale echo $TASKD_STRIPE__SECRET_KEY — the environment wins over the file, by design
{"error":"invalid or missing authentication token"} $TOKEN is empty or expired Re-run the token command; tokens last 24 hours
404 from /v1/billing/checkout The route is registered outside the /v1 sub-router, or the file was saved without rebuilding Check routes.go; restart the server
{"error":{"tier":"must be one of: pro, business"}} when you meant to send pro The value did not arrive as that exact lowercase string. "Pro", " pro" and any typo all fall through the switch to default: — the comparison is a plain string match, not a fuzzy one Send {"tier":"pro"} character for character
A 400 with a message beginning body contains badly-formed JSON The shell ate the braces or the quotes, so what reached the server was not JSON at all. This is readJSON from Chapter 8 (CRUD done properly) rejecting the body before any billing code runs Wrap the body in single quotes exactly as printed: -d '{"tier":"pro"}'

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: at startup, level=WARN msg="stripe.secret_key is empty; billing endpoints will fail", then a 500 from the checkout endpoint. It means: exactly what it says. The config key is empty, so every SDK call is unauthenticated. Fix: fill in secret_key in config.toml, or export TASKD_STRIPE__SECRET_KEY, and restart. The lesson is bigger than the bug: read your boot logs. The original author put that line there specifically so this failure would explain itself.

Common mistake

You’ll see: cannot use custID (variable of type string) as *string value in struct literal It means: you wrote Customer: custID where the SDK wants a *string. Fix: Customer: stripe.String(custID). The SDK insists on pointers so that it can distinguish a field you did not set (nil) from a field you set to empty ("") — the same reason Chapter 8’s PATCH handler uses pointers for optional fields.

Common mistake

You’ll see: a 500, with a log line containing No such price: 'price_1Abc...' It means: the key and the price id come from different worlds — a sk_test_ key looking for a price that only exists in live mode, or vice versa. It reads like your bug and is not. Fix: keys and price ids travel as a matched set. Copy all three from the same mode, in one sitting.

Common mistake

You’ll see: undefined: itoa It means: helpers.go never got the two-line helper from Chapter 8 (CRUD done properly). Fix: add func itoa(i int64) string { return strconv.FormatInt(i, 10) } to cmd/api/helpers.go, with strconv in that file’s imports.

Common mistake

You’ll see: make sqlc failing with a complaint about subscriptions — the exact wording depends on your sqlc version, and it will name the table. It means: sqlc reads migrations/ to learn the table shapes, and it cannot find the table your query mentions. The migration file is missing, misnamed, or was written after the query. Fix: migration first, query second, make sqlc third. The file must be in migrations/ and end .up.sql; sqlc reads the folder, not the database.

Two more that produce no error at all, which is what makes them the dangerous ones:

Symptom What it means Fix
Everything works, and you left ClientReferenceID (or the customer metadata) out Nothing fails today. In Chapter 16 you cannot map a webhook back to a user except by email — mutable, reusable, and the source of legendary “the wrong customer got upgraded” incidents Put both back. They cost one line each and they are the only durable link between the two systems
Everything works, and config.toml with a real key is committed and pushed Nothing fails today. A test key is low-stakes; the habit is not git rm --cached config.toml is the wrong fix here — the file is meant to be committed. Move the secret to the environment, then roll the key in the dashboard, because it is in your git history forever

9. Pitfalls

Test/live key crossover. sk_test_ with price_ ids from live mode (or the other way round) fails with a “No such price” that reads like your bug. Keys and price ids travel as a matched set through config — which is precisely why they are config and not constants. The failure is worse in production, where you discover it on launch day with customers watching.

Trusting success_url. Restated as a drumbeat because it is the number one billing security hole in hobby SaaS: anyone can GET your success URL. It is a “thanks!” page, never a provisioning trigger. Three separate things go wrong if you forget:

  1. forged      someone types /billing/success and is now on Pro
  2. abandoned   a real payer closes the tab; the redirect never fires;
                 they paid and got nothing
  3. raced       Stripe's webhook lands first and the redirect arrives
                 second, so your "grant" runs twice on different data

The webhook has none of those problems, because it is signed, retried until acknowledged, and carries the object itself.

There is a related honesty note, which Step 2 already made and which is worth repeating where the traps live: taskd never registers a route at success_url or cancel_url. Both point at localhost:4000/v1/billing/..., and both land on the API’s own 404 envelope, {"error":"the requested resource could not be found"}. Nothing depends on those pages existing, which is the point — but if you are building a real product, they belong to your web frontend, on a different origin, and repointing them there (http://localhost:3000/billing/success) is a config edit and nothing more.

Skipping the metadata and reference ids. Without taskd_user_id on the customer and ClientReferenceID on the session, correlating a webhook back to a user means matching on email. Email addresses are mutable (people change them), reusable (companies recycle staff addresses), and sometimes shared. Matching money to people on a mutable key is how the wrong customer gets upgraded.

Secret keys in the frontend or in logs. sk_ keys are server-side only. The koanf environment path exists precisely so they never sit in a committed file in production. Note also that Stripe’s errors can echo the parameters of the request that failed — our serverErrorResponse logs the error but never the config, and the boundary between those two habits is worth keeping sharp.

No timeout on the call to Stripe. Worth naming even though the book never fixes it: the SDK’s default HTTP client is used as-is, so if Stripe becomes slow rather than unavailable, this handler blocks until the server’s own 10-second WriteTimeout from Chapter 4 (A server that dies well) cuts the response. Two Stripe calls per checkout means twice the exposure.

Note

The original edition does not address this, and neither does the rest of the book — every Stripe call in taskd uses the SDK’s default backend. If you take this codebase to production with real traffic, configuring the SDK with an http.Client that carries an explicit timeout is a half-hour job and the first thing on the list. It is flagged here rather than fixed because changing it would change the book’s code, and you should know the difference between “this book did not do it” and “this does not need doing”.


10. Check yourself — quiz

  1. Why does Stripe host the payment page rather than us? Give the answer in terms of what lands on our servers.
  2. A colleague suggests creating the Stripe Customer during registration, “so it’s ready”. Give two concrete costs of that.
  3. Why is the Free tier the absence of a subscription rather than a $0 Stripe subscription?
  4. Plan definitions live in code; price ids live in config. What is the rule that separates them?
  5. There are two ids linking a taskd user to their Stripe records. Name both, say which side each one is stored on, and explain why one is not enough.
  6. You complete a test payment and SELECT * FROM subscriptions returns (0 rows). Is this a bug? What exactly is missing?
  7. InsertStripeEvent uses :execrows with ON CONFLICT (id) DO NOTHING. What does a return value of 0 mean, and why does it matter that the check and the write are one statement?
  8. Read this line and say what breaks: params := &stripe.CheckoutSessionParams{Customer: custID}
Answers
  1. Because card numbers never touch our machines. Anything that sees a raw card number falls inside PCI scope — audits, scans, segregated networks, an incident plan — and Stripe’s hosted page keeps that scope empty. It also absorbs SCA/3-D Secure, wallets, tax and localisation, none of which is our product.

  2. First, it puts a third-party HTTP call on the registration path: signup gets slower, and signup now fails when Stripe is having a bad day. Second, it fills your Stripe account with customers who will never pay — around 95% of signups for a typical freemium product — which makes every dashboard and export noisier. Lazy creation costs one if and avoids both.

  3. Because it removes work in three places. There are no Stripe objects for people who never pay; no extra webhook states to handle for a subscription that never changes; and the tier-resolution rule collapses to “row present and healthy → its tier, otherwise free”, which is Chapter 17’s whole resolver.

  4. Things that differ between environments are configuration; things that differ between releases are code. A price id is different in test mode and live mode, so it is config. A plan definition is the same everywhere until you deliberately change the product, so it is code — and it changes alongside the feature gates that reference it, which is a code change anyway.

  5. users.stripe_customer_id (a cus_... value stored on our side, pointing at their object) and the taskd_user_id metadata on the Stripe Customer plus ClientReferenceID on the session (our id stored on their side). One is not enough because the lookup runs in both directions: our handler starts from a user and needs their customer; Chapter 16’s webhook starts from a Stripe object and needs our user. Having both also means a mangled or missing value on one side is survivable.

  6. Not a bug — it is the designed state at the end of this chapter. What is missing is the webhook endpoint: the only code permitted to write subscriptions. Stripe changed its mind about this customer and has no way to tell us yet. The browser’s return to success_url is not evidence and is deliberately ignored.

  7. 0 means “this event id was already in the table, so I have processed it before — skip it”. 1 means “first time”. It matters that it is one statement because the check and the write are then atomic: two copies of the same event arriving simultaneously cannot both be told “not there yet”. A read followed by a separate write has a window between them, and that window is where duplicate charges and duplicate emails come from.

  8. It does not compile: cannot use custID (variable of type string) as *string value in struct literal. The field is a *string, because the SDK uses nil to mean “not set” and needs to distinguish that from "". The fix is Customer: stripe.String(custID).


11. Practice

Exercise 1 — Add a fourth tier as data only (easy)

Add a TierTeam between Pro and Business — 5,000 active tasks, 600 requests per minute, priorities and search both on — and observe how much of the codebase you have to touch.

Answer

Two edits, both in one file:

// internal/data/plans.go — add the constant to the existing const block
const (
    TierFree     Tier = "free"
    TierPro      Tier = "pro"
    TierTeam     Tier = "team"
    TierBusiness Tier = "business"
)
// internal/data/plans.go — add the row to the existing Plans map
var Plans = map[Tier]Entitlements{
    TierFree:     {MaxActiveTasks: 100, RatePerMinute: 60, Priorities: false, Search: false},
    TierPro:      {MaxActiveTasks: 10_000, RatePerMinute: 300, Priorities: true, Search: true},
    TierTeam:     {MaxActiveTasks: 5_000, RatePerMinute: 600, Priorities: true, Search: true},
    TierBusiness: {MaxActiveTasks: -1, RatePerMinute: 1000, Priorities: true, Search: true},
}

Verify:

go build ./... && echo TEAM-TIER-BUILDS

You should see TEAM-TIER-BUILDS. Nothing else in the codebase needed changing, because nothing else knows how many tiers exist — Chapter 17’s resolver will read whatever is in the map.

Now try to buy it:

curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"team"}' \
  localhost:4000/v1/billing/checkout

You should see {"error":{"tier":"must be one of: pro, business"}}. That is the lesson. The plan exists as data; it has no price, so it cannot be sold. Making it purchasable means a third product in the Stripe dashboard, a price_id_team key in [stripe] and in config.go, and a case data.TierTeam: in the handler’s switch — the Stripe coupling, and nothing else, grows with the number of paid tiers.

Revert both edits before continuing; the rest of the book assumes three tiers.

Exercise 2 — Watch the validation happen before Stripe does (medium)

Confirm that an invalid tier is rejected by our code, not by Stripe — that is, that no outbound HTTP call is made at all.

Answer

The direct proof is to remove Stripe’s ability to answer, and see that the request still behaves. Stop the server, blank the key in config.toml, and restart:

# config.toml — temporarily, for this exercise only
[stripe]
secret_key            = ""
make run/api

The boot log now contains stripe.secret_key is empty; billing endpoints will fail. If it does not, your key is coming from the environment rather than the file — the environment wins, by design, so unset TASKD_STRIPE__SECRET_KEY in that shell first. With the warning showing:

curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
  -d '{"tier":"enterprise"}' localhost:4000/v1/billing/checkout   # 422

curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
  -d '{"tier":"pro"}' localhost:4000/v1/billing/checkout          # 500

The invalid tier returns 422 even though Stripe is unreachable, because the switch returns before ensureStripeCustomer is called. The valid tier returns 500, because that path does reach Stripe and Stripe rejects an empty key.

Read the handler again with that in mind: validate, then resolve identity, then call outward. Every step that can fail cheaply happens before every step that costs a network round trip. That ordering is not an accident and is worth copying into your own handlers.

Restore your key and restart before continuing.

Exercise 3 — Make the gap real, and write down why (harder)

Complete a checkout with the 4242 card, then prove in psql that the two systems disagree — and record, in your own words, why that is correct.

Answer
curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"pro"}' \
  localhost:4000/v1/billing/checkout | jq -r .checkout_url

Open the URL, pay with 4242 4242 4242 4242, any future expiry, any CVC. Then:

make db/psql
-- typed at the taskd=# prompt
SELECT id, email, stripe_customer_id FROM users;
SELECT count(*) FROM subscriptions;
SELECT count(*) FROM stripe_events;

You should see your user with a cus_... value, then 0, then 0. Meanwhile the Stripe dashboard shows an active subscription for that customer.

Now write in learnings/ch15.md, from memory rather than by copying:

  • Which system is right? Stripe. It is the source of truth for billing; our tables are a projection that has not been updated yet. Nothing here is corrupt — one side is merely behind.
  • Why didn’t the browser’s return to success_url update anything? Because a browser arriving at a URL is not evidence. It can be forged by anyone who knows the address, skipped by a payer who closes the tab, or arrive after the webhook has already done the work. Provisioning is triggered by a signed server-to-server message, never by a page view.
  • What exactly closes the gap? One endpoint that Stripe calls, that proves the caller is Stripe by checking a signature over the raw bytes, that is safe to call twice, and that is the only code in the system permitted to write subscriptions. That is Chapter 16.
  • What would the stripe_events table have in it if the webhook existed? One row per event id already processed — the ledger that makes a replayed event a no-op.

Keep the subscription active in Stripe; Chapter 16 replays events against it.


12. FAQ

Will any of this charge me real money? No. Everything in this chapter happens in Stripe’s test mode: separate keys (sk_test_), separate objects, separate dashboard, and card numbers that exist only as test fixtures. 4242 4242 4242 4242 is not connected to any bank. You cannot accidentally charge yourself, and you cannot accidentally charge anyone else, because live mode requires a completely different key that you do not have until you complete Stripe’s business onboarding.

Do I need a company or a bank account to follow along? Not for test mode. You need an email address. You need a registered business and bank details only when you want to receive money, which is a live-mode concern and outside this book.

Why not collect card details myself? It would look better. It would, and the cost is enormous and mostly invisible until it arrives. You take on PCI scope (audits, network segregation, evidence for your auditor), the SCA/3-D Secure challenge flow, tax determination, wallet integrations, and a frontend project that has to keep working while card networks change their rules. Stripe employs specialists for each of those. You have a task list to build. If you are ever in a position where the checkout page’s exact styling is your competitive advantage, you will also be in a position to hire someone to own it.

What is a price id, and why isn’t it in the code? It is Stripe’s identifier for “this thing, at this amount, on this schedule” — price_1Abc.... It is not in the code because it is different in test mode and live mode. A hardcoded price id means either your tests hit live prices or your production hits test prices, and you find out on launch day. Configuration exists exactly for values that differ between environments.

What happens if the user closes the Stripe page without paying? Nothing. The Checkout Session expires by itself, no Customer is harmed, and our database is unchanged — it was unchanged anyway. If they click the “back” link on Stripe’s page they are sent to your cancel_url, which, like success_url, is a display-only destination. There is no cleanup to write, which is one more benefit of holding no state of our own here.

Why does my database not know about the payment? That feels broken. It feels broken because you watched money change hands and nothing happened locally, and that reaction is exactly what this chapter wanted to produce. The design says our tables are a copy of Stripe’s answer, and the only thing allowed to write the copy is a signed message from Stripe. That message handler is the next chapter. Building it in this order is deliberate: you now know precisely what problem webhooks solve, because you have seen the hole they fill.

Is this really how real companies do it? Yes, and the details are recognisable. Hosted checkout, a customer id as the join key, entitlements derived from a locally-cached subscription state, and webhooks as the only writer, is the standard shape of a small-to-medium SaaS billing integration. What larger companies add is a billing service of their own in front of Stripe, an events pipeline instead of direct writes, and a reconciliation job that compares the projection against Stripe nightly. The shape does not change; the number of moving parts does.


13. Where we are

Half a billing system: Stripe knows how to charge our customers, and we know how to send customers to Stripe. What we do not have is the return path — the moment where Stripe’s opinion becomes our data. Chapter 16 (Stripe II: webhooks, the source of truth) writes the only endpoint permitted to touch the subscriptions table, and Chapter 17 (Entitlements and quotas) finally spends the Plans map on enforcing what each tier can do.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go             # UPDATED: stripe.Key + the empty-key warning
│   ├── server.go
│   ├── routes.go           # UPDATED: /billing sub-router
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go
│   ├── users.go
│   ├── tokens.go
│   └── billing.go          # NEW: ensureStripeCustomer + createCheckoutHandler
├── internal/
│   ├── cache/              # cache.go ratelimit.go
│   ├── data/
│   │   ├── filters.go tasks.go users.go tokens.go
│   │   └── plans.go        # NEW: Tier, Entitlements, Plans
│   ├── db/                 # sqlc output: billing.sql.go NEW, models.go redone
│   └── validator/
├── migrations/             # NEW: 000005_billing up + down
├── sql/queries/
│   ├── tasks.sql  users.sql  tokens.sql
│   └── billing.sql         # NEW: six queries, one used so far
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum         # UPDATED: stripe-go/v78

What works end to end: a logged-in user can POST /v1/billing/checkout with a tier, get a real Stripe-hosted payment page, and pay on it. The first such request creates their Stripe Customer and stores the id on their user row; every later request reuses it.

What is still fake or missing:

  • subscriptions is never written. Chapter 16’s webhook is the only thing that will ever write it. Until then, paying changes nothing on our side.
  • Plans is never read. Chapter 17 turns it into rate limits, quotas and feature gates. Chapter 14’s limit := 60 is still hardcoded.
  • webhook_secret is empty. Chapter 16 fills it, from the Stripe CLI rather than the dashboard.
  • There is no way to cancel or change a card. Chapter 16’s closing note adds the Stripe Customer Portal, which is one handler for an entire account-management surface.
  • success_url and cancel_url lead to taskd’s 404. They are frontend destinations, and taskd has no frontend. Nothing depends on them working.
  • The five other billing queries are unused. They exist because we were already in the file.

For your notes

Copy these into learnings/ch15.md, in your own words:

  1. Stripe owns billing state; our database holds a projection updated only by webhooks. The moment two systems both claim to be authoritative about who paid, you have built a reconciliation bug factory.
  2. A browser arriving at your success URL proves nothing. It can be forged, abandoned, or beaten by the webhook. Entitlements change when Stripe tells the server, never when a browser shows up claiming.
  3. Outsource what a specialist does better; keep only the seam. Here the seam is two API calls out and one webhook in — and the whole PCI, SCA, tax and wallet problem stays on the other side of it.
  4. Things that differ between environments are config; things that differ between releases are code. Price ids are config. Plan definitions are code.
  5. INSERT ... ON CONFLICT DO NOTHING with :execrows is atomic idempotency in one statement. One row means first time, zero means duplicate — no read-then-write window for two copies of the same event to slip through.

Chapter 16 — Stripe II: webhooks, the source of truth

Chapter 15 (Stripe I) ended with a deliberate hole in the floor. You made a real test payment, Stripe recorded a subscription, and your subscriptions table stayed empty. This chapter builds the one piece that closes it: a single URL that Stripe calls when something happens on its side. That URL is the only code in taskd allowed to write the subscriptions table, and it has to survive being called by strangers, being called twice with the same message, and being called in the wrong order. Three problems, three small pieces of engineering, one endpoint.

What you’ll be able to do by the end

  • Explain what a webhook is, and why a payment system cannot work without one.
  • Say what an HMAC signature proves, what it does not prove, and why verifying it means never touching the request body with a JSON decoder first.
  • Make a handler safe to call twice with the same message, using one SQL statement.
  • Let Stripe reach a laptop that has no address on the internet, using stripe listen.
  • Run the acceptance test of this chapter: pay and watch a row appear, cancel and watch it vanish, replay an old event and watch nothing happen.

Time: ~45 minutes reading, ~35 minutes typing.

You need before starting: a working Chapter 15 (Stripe I). Two commands prove it:

stripe --version

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"pro"}' \
  localhost:4000/v1/billing/checkout

The first prints a version line for the Stripe command-line tool (install it from https://docs.stripe.com/stripe-cli if the shell says command not found). The second logs in and requests a checkout URL; it returns a JSON body with a checkout_url key whose value starts https://checkout.stripe.com/. If it returns {"error":"the server encountered a problem and could not process your request"}, your stripe.secret_key is wrong or empty — go back to Chapter 15’s Step 2 (filling in the [stripe] block) and Step 8 (handing the SDK that key).


1. The problem, in plain words

You order a parcel. You do not phone the depot every thirty seconds to ask whether it has arrived. The driver comes to your house and rings the doorbell. One interruption, at the moment something actually happened, instead of two thousand pointless phone calls.

That is the whole idea. Our server could ask Stripe once a minute, for every user, whether anything changed. That is called polling, and it is wasteful in a very particular way: it is slowest exactly when it matters (a customer who just paid waits up to a minute) and busiest exactly when it does not (thousands of “nope, nothing” answers per hour).

Polling Webhook
Who starts the call us, on a timer Stripe, when something happened
Delay before we know up to one poll interval typically under a second
Cost when nothing happens every poll, forever zero
What we must build a scheduler and a cursor one HTTP endpoint
Who can call it only us anyone on the internet

That last row is the price. A doorbell has no idea who is pressing it. Our webhook endpoint is a URL sitting in public, and the message that arrives claims to say “customer X just subscribed to Pro”. If we believe it without proof, anybody who guesses the URL can hand themselves a paid account. So the first job of this endpoint is not billing. It is proving the caller is Stripe.

The second job is stranger. Stripe promises to deliver every event at least once, which is an honest way of saying it will sometimes deliver one twice — because a network hiccup that eats our “got it” reply is indistinguishable, from Stripe’s side, from our server being dead. So it tries again. Our handler must therefore be safe to run twice with the same message. And because retries happen on their own schedule, two events about the same subscription can arrive in the wrong order.

Three requirements, then, and none of them are about money:

  1. Prove the sender knew a secret only Stripe and we know.
  2. Do nothing the second time the same message arrives.
  3. Be correct even when messages arrive out of order.

Get those wrong and the failure is not a crash. It is a customer who paid you and did not get what they paid for — the most expensive bug class a small SaaS has.


2. New words in this chapter

Word What it means here
webhook A URL on your server that another company calls when something happens on their side — the reverse of you calling them.
polling Repeatedly asking “anything new?” on a timer. What webhooks replace.
payload The bytes in the body of an HTTP request. Here, a JSON document describing one event.
raw body The body as the exact sequence of bytes that arrived, before anything interprets it as JSON.
HMAC A cryptographic fingerprint computed over a message using a shared secret; matching it proves the sender knew the secret and the bytes were not altered.
signing secret / whsec_ The shared secret used to compute and check that fingerprint.
signature verification Recomputing the fingerprint over the exact received bytes and comparing it to the one in the header.
replay attack Re-sending a captured, genuinely valid message later, to make the same thing happen again.
clock skew Two machines disagreeing about what time it is.
at-least-once delivery The sender guarantees you get every event, but may send some twice, and out of order.
idempotent Doing the operation twice has the same effect as doing it once.
idempotency ledger A table of already-processed event IDs, so replaying an event does nothing the second time.
upsert Insert a row, or update it if it already exists — one statement, safe to repeat.
projection Chapter 15’s word for our subscriptions row: a local copy of Stripe’s state, never the authority.
ON CONFLICT The Postgres clause that says what to do when an insert collides with an existing row.
EXCLUDED Inside an upsert, the name for the values that were being inserted, used to overwrite the existing row.
state machine Code that tracks “what state are we in, and what event moves us to the next one”. Deliberately avoided here.
convergence over choreography Treat every event as “here is the current truth, write it down”, rather than as a step in a sequence.
epoch time A moment stored as the number of seconds since 1 January 1970 UTC. Stripe sends dates this way.
dunning The retry-and-remind process a payment provider runs after a card is declined, before giving up.
past_due The subscription status during dunning: the renewal charge failed, but Stripe has not given up.
acknowledge (ack) Replying with a 2xx status so the sender stops retrying.
Customer Portal A Stripe-hosted page where your users update cards, switch plans and cancel.

3. The goal

POST /v1/stripe/webhook — signature-verified, idempotent, and the only code path that writes the subscriptions table. After this chapter, paying in test mode flips a user to Pro in our database within a second, cancellation flips them back, and replaying old events does nothing. Local development runs through stripe listen.


4. The thinking

A webhook endpoint is unlike every other handler in the codebase, and three of its differences are load-bearing.

4.1 Authentication is cryptographic, not token-based

Every other protected endpoint in taskd works the same way: the client sends Authorization: Bearer <token>, and Chapter 11 (Stateful tokens) looks that token up in the tokens table. Stripe cannot do that. Stripe has no account with us, holds no token of ours, and will never register for one.

What Stripe does have is a signing secret: a string starting whsec_ that Stripe generates and shows to you once. Both sides now know the same secret. That is enough.

New word

HMAC — short for hash-based message authentication code. A hash function (Chapter 11 used SHA-256 to fingerprint authentication tokens) turns any input into a fixed-size fingerprint and cannot be run backwards. HMAC is the same idea with a secret mixed in: fingerprint = f(secret, message). Anyone can compute a plain hash of a message. Only someone holding the secret can compute the right HMAC of it.

Here is exactly what Stripe does, and what we do, using the real recipe from the Stripe Go library:

  STRIPE'S SIDE                          OUR SIDE
  ┌───────────────────────────┐          ┌───────────────────────────┐
  │ body bytes  {"id":"evt_…} │          │ body bytes  {"id":"evt_…} │
  │ timestamp   1755300000    │          │ timestamp   (from header) │
  │ secret      whsec_…       │          │ secret      whsec_…       │
  └────────────┬──────────────┘          └────────────┬──────────────┘
               │  HMAC-SHA256 over                    │  same recipe,
               │  "<timestamp>.<body>"                │  same secret
               ▼                                      ▼
        ┌────────────┐                         ┌────────────┐
        │ 3f9a…c1    │ ── sent in the header ─▶│ 3f9a…c1    │
        └────────────┘  Stripe-Signature:      └─────┬──────┘
                        t=1755300000,v1=3f9a…c1      │
                                                     ▼
                                            equal?  yes → it's Stripe
                                                    no  → 400, log, ignore

Three consequences fall out of that picture, and all three matter.

The signature covers the exact bytes. Not “the JSON”, not “the data” — the bytes. Change one space, reorder two keys, drop a trailing newline, and the fingerprint changes completely. That is the point: it is what makes tampering detectable.

Warning

This is why readJSON — our helper from Chapter 8 (CRUD done properly) — cannot be used here. readJSON consumes the body and hands back a filled-in Go struct. The original bytes are gone, and re-encoding that struct produces different bytes with the same meaning. Different bytes, different fingerprint, failed verification on a perfectly valid event. This is the one endpoint in taskd that reads r.Body raw and capped, by hand.

The design lesson is bigger than this endpoint. readJSON is a good helper. It bundles a size cap, strict field checking, and friendly error messages, and every other handler should use it. But it also bundles an assumption: that the caller cares about the meaning of the body, not its bytes. This endpoint’s threat model breaks that assumption.

Remember this

Helpers encode assumptions. Know when an endpoint’s threat model breaks them.

What HMAC proves and what it does not. It proves the sender knew the secret, and that the bytes were not altered on the way. It does not prove the message is new — a captured valid request could be sent again later, which is a replay attack. Stripe’s fix is the timestamp inside the signature: the library rejects anything signed more than five minutes ago. That defends against replay and, as a side effect, means your machine’s clock has to be roughly right.

Bearer token (Chapter 11) HMAC signature (here)
What the caller sends a secret we issued them a fingerprint of the message
Where the secret travels in every request never — only the fingerprint does
What we check it against a row in tokens a recomputation using our copy of the secret
Protects the body from tampering no yes
Works for a caller with no account no yes

4.2 Delivery is at-least-once and unordered

Stripe retries on any non-2xx response, with increasing gaps, for days. It may deliver duplicates, and it may deliver events out of order.

New word

at-least-once delivery — the sender guarantees you will receive every message, and makes no promise about receiving it only once, or in order. Idempotent — an operation you can perform twice and get the same result as performing it once, like pressing a lift button that is already lit. At-least-once delivery is bearable precisely because the receiver is idempotent.

Two answers follow.

(a) Idempotency. We keep a ledger of event IDs we have already handled. Chapter 15 created the table and the query for exactly this moment:

-- sql/queries/billing.sql — written in Chapter 15, used for the first time here
-- name: InsertStripeEvent :execrows
INSERT INTO stripe_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING;

id is the primary key of stripe_events, so a second insert of the same ID collides. DO NOTHING says “collision is fine, change nothing”, and :execrows makes sqlc return the number of rows affected. One row means we have never seen this event. Zero rows means we have. One statement, no race, no read-then-write gap.

(b) Convergence over choreography. The tempting design is a state machine: created means the user just subscribed, updated means they changed something, so handle each as a transition from the previous state. That design assumes order. Order is exactly what we do not have.

New word

upsert — one SQL statement that inserts a row, or updates it if a row with that key is already there. Postgres spells it INSERT … ON CONFLICT (key) DO UPDATE SET …. Running it twice with the same values leaves the same single row, which is why it is the natural tool for a handler that may be called twice. projection — Chapter 15’s word for our subscriptions row: a local copy of Stripe’s state, kept because reading our own database is fast, never treated as the authority.

Watch it break. Two events about the same subscription: it was created as Pro, then upgraded to Business a second later. Stripe retried the first one, so they arrive backwards.

Arrival order State-machine handler Our upsert handler
updated(Business), created(Pro) “created after updated? that shouldn’t happen” — writes Pro, or refuses writes Business, then writes Pro
created(Pro), updated(Business) writes Pro, then Business writes Pro, then Business

In the top row both handlers end up holding Pro when the customer is on Business, which looks like a tie. It is not, for two reasons.

First, the upsert always leaves a complete, valid row — one that says exactly what some event said, in full. The state machine can land in a state no event described, or refuse the event and write nothing at all, leaving a paying customer with no row.

Second, and more important: Stripe’s events each carry a full copy of the current object, not a list of changes. So the next event about that subscription — a renewal, a status change, or a re-delivery you trigger from the dashboard — hands the upsert the current truth, and the row becomes correct. The state machine has no such self-healing: it tracks a story, and its story is now permanently out of step with reality.

Remember this

Every subscription event carries the whole subscription. So do not interpret events as steps in a story; treat each one as “here is the current object” and overwrite your copy. Upserts are order-insensitive. State machines built on event ordering are where webhook handlers go to die.

4.3 Respond fast, work idempotent

Stripe gives your endpoint a short deadline. Be slow and it treats you as failed and retries — which creates duplicate work at precisely the moment you were already struggling.

Our processing is two indexed writes, so we do it inline before replying. If it ever grew — send an email, generate an invoice, render a PDF — the pattern is: verify → record the event → reply 200 → process in the background. Know the shape before you need it.

The status code we return is not decoration. It is protocol, and Stripe acts on it:

We reply Stripe concludes Stripe then
200 OK (any 2xx) delivered forgets about it
any non-2xx — 400, 404, 500 not delivered retries with backoff, for days, then gives up and flags the endpoint
nothing (timeout) not delivered the same retries

Note the shape of that table: Stripe does not read a 400 as “don’t bother trying again”. There are only two outcomes it recognises — 2xx, and everything else. That is why an event type we do not recognise must still be answered 200.

So why return 400 on a bad signature at all, if it will not stop a retry? Because a request with a bad signature was almost certainly not sent by Stripe in the first place, so there is no Stripe retry to stop. 400 is the honest status for “this request is not something I can accept”, aimed at whoever did send it. The 500 on a database failure is the one status genuinely chosen for Stripe’s benefit: it is us asking for the retry, because a retry might succeed.

4.4 Which events?

The minimal honest set for subscriptions:

Event type What it means What we do
checkout.session.completed the customer finished the hosted payment page log and acknowledge — the subscription events carry the real state
customer.subscription.created a subscription now exists upsert our projection of it
customer.subscription.updated anything about it changed: renewal, plan switch, past_due upsert our projection of it
customer.subscription.deleted the subscription ended delete the row → the user reverts to free
everything else not our business 200 OK, and a debug log line
Warning

An unhandled event type must never produce a 4xx or 5xx. Stripe will faithfully retry your indifference for a week, fill your logs, and eventually disable the endpoint for misbehaving. Unknown is not an error. Acknowledge it.


5. A picture of it

What you are looking at: one event’s complete journey, from a card being charged to a row in your database. In development, the dashed box is the Stripe CLI running on your machine.

  ┌────────────┐   card charged / plan changed / subscription cancelled
  │   STRIPE   │
  └──────┬─────┘
         │ POST, JSON body, Stripe-Signature header
         ▼
  ┌───────────────────────────────────────────────────┐
  │ stripe listen  —  DEVELOPMENT ONLY                │
  │                                                   │
  │ Your laptop has no public address. The CLI dials  │
  │ OUT to Stripe and holds the connection open;      │
  │ Stripe pushes events down it, and the CLI re-     │
  │ POSTs each one to localhost. It mints its own     │
  │ whsec_ signing secret.                            │
  └──────┬────────────────────────────────────────────┘
         │ POST localhost:4000/v1/stripe/webhook
         ▼
  ┌──────────────────────────────────────────────────┐
  │ stripeWebhookHandler                             │
  │  1. read raw body, max 64 KB                     │
  │  2. verify HMAC ────────────── fail ──▶ 400 + log│
  │  3. InsertStripeEvent(id) ──── 0 rows ─▶ 200     │
  │  4. switch on event type                         │
  │       created / updated ──▶ applySubscription    │
  │       deleted           ──▶ DeleteSubscription   │
  │       anything else     ──▶ debug log            │
  │  5. 200 OK                                       │
  └────────────────────┬─────────────────────────────┘
                       ▼
              ┌──────────────────┐
              │ subscriptions    │  one row per paying user
              │ stripe_events    │  one row per event ever seen
              └──────────────────┘

Walking it through:

  1. Something happens inside Stripe. Stripe builds an event object and signs it.
  2. In production Stripe POSTs straight to your public URL. In development it cannot reach your laptop, so the stripe listen process — which you started, so the connection goes outward — receives the event and re-POSTs it to localhost.
  3. The handler reads the body as bytes, capped, and checks the signature. A failure ends here.
  4. The event ID goes into the ledger. If it was already there, we are done; reply 200.
  5. Only now do we look at what kind of event it is, and write to subscriptions.

The ledger step, drawn on its own, because it is the part beginners find surprising:

   first delivery                      retry of the same event
   ─────────────                       ───────────────────────
   INSERT evt_123  ──▶ 1 row           INSERT evt_123 ──▶ 0 rows
        │                                   │
        ▼                                   ▼
   process it, write subscriptions      skip everything
        │                                   │
        ▼                                   ▼
      200 OK                              200 OK

And the translation the handler performs, which is where Stripe’s vocabulary becomes ours:

   Stripe says                      config.toml says          we store
   ───────────                      ────────────────          ────────
   sub.Items.Data[0].Price.ID  ─▶   price_id_pro       ──▶    tier = "pro"
                               ─▶   price_id_business  ──▶    tier = "business"
                               ─▶   (no match)         ──▶    tier = "free"

Because the price IDs come from config, the same compiled binary works in test mode and live mode. That is not a small thing: hardcoding price IDs is a classic launch-day face-plant.


6. The steps

Step 1 — Write the webhook handler

This is a new file. It holds two functions; here is the first, with the imports it needs.

// cmd/api/webhooks.go — new file
package main

import (
    "encoding/json"
    "io"
    "net/http"
    "time"

    "github.com/stripe/stripe-go/v78"
    "github.com/stripe/stripe-go/v78/webhook"

    "github.com/yourname/taskd/internal/data"
    "github.com/yourname/taskd/internal/db"
)

func (app *application) stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
    // RAW body, capped at 64KB — read by hand because the signature is
    // an HMAC over these exact bytes; readJSON's re-framing would
    // destroy what we're about to verify.
    payload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 65536))
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // The authentication step: recompute the HMAC with our shared
    // signing secret and compare against the Stripe-Signature header.
    // Passes only if the sender KNOWS the secret (it's Stripe) and the
    // payload is byte-for-byte untampered. Fails → some rando is
    // POSTing at our webhook; log and shrug.
    event, err := webhook.ConstructEvent(payload,
        r.Header.Get("Stripe-Signature"), app.config.stripe.webhookSecret)
    if err != nil {
        app.logger.Warn("webhook signature verification failed", "error", err)
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // Idempotency ledger: 0 rows affected → we've processed this event.
    rows, err := app.q.InsertStripeEvent(r.Context(), event.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err) // 5xx → Stripe retries; correct
        return
    }
    if rows == 0 {
        w.WriteHeader(http.StatusOK) // duplicate; ack and move on
        return
    }

    switch event.Type {
    case "customer.subscription.created",
        "customer.subscription.updated":
        var sub stripe.Subscription
        if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }
        if err := app.applySubscription(r, &sub); err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }

    case "customer.subscription.deleted":
        var sub stripe.Subscription
        if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }
        // Unknown customer → acknowledge and skip. That tolerance is
        // deliberate: Chapter 22's account deletion cancels the Stripe
        // subscription and removes our user first, so this event can
        // legitimately arrive after the row it refers to is gone.
        user, err := app.q.GetUserByStripeCustomerID(r.Context(), &sub.Customer.ID)
        if err == nil {
            _ = app.q.DeleteSubscription(r.Context(), user.ID)
            app.invalidateEntitlements(r, user.ID) // ch. 17
        }

    default:
        app.logger.Debug("unhandled stripe event", "type", event.Type)
    }

    w.WriteHeader(http.StatusOK)
}

What this code says, line by line

  • http.MaxBytesReader(w, r.Body, 65536) — wraps the body in a reader that refuses to hand over more than 65,536 bytes (64 KiB) and then errors. Same defence Chapter 8 built into readJSON: without it, a hostile caller streams gigabytes at you and your memory is their weapon.
  • io.ReadAll(...) — drain that reader into a []byte. payload is now the bytes, in order, as they arrived. Nothing has interpreted them.
  • r.Header.Get("Stripe-Signature") — pull one header by name. Its value looks like t=1755300000,v1=3f9a…: a Unix timestamp and a hex fingerprint.
  • webhook.ConstructEvent(payload, header, secret) — the Stripe library does the whole verification in one call: parses that header, recomputes HMAC-SHA256 over "<timestamp>.<payload>" with our secret, compares the two, and only if they match, parses the JSON into a stripe.Event. It also refuses anything whose timestamp is more than five minutes old, and anything whose api_version field disagrees with the Stripe API version this library release was built against — 2024-04-10 for stripe-go/v78. See the note below if that bites you.
  • app.logger.Warn(...) then w.WriteHeader(http.StatusBadRequest) — a bare 400 with no body. We deliberately do not use badRequestResponse here: there is no legitimate client to explain ourselves to, and telling an attacker why their forgery failed is free help.
  • app.q.InsertStripeEvent(r.Context(), event.ID) — the ledger insert. event.ID is Stripe’s ID for this event, a string beginning evt_. The call returns (int64, error): the number of rows the statement actually inserted.
  • if rows == 0 — the row already existed, so this is a redelivery. Reply 200 and touch nothing.
  • switch event.Typeevent.Type is a named string type, so it compares cleanly against string literals. Two case values on one arm means “either of these”.
  • var sub stripe.Subscription — declare an empty struct of Stripe’s subscription type.
  • json.Unmarshal(event.Data.Raw, &sub)event.Data.Raw is the untouched JSON of the object the event is about. &sub passes the address of our empty struct so Unmarshal can fill it in; without the &, Go would hand the function a copy and your struct would stay empty.
  • &sub.Customer.ID — the address of the customer ID string. GetUserByStripeCustomerID takes a *string, not a string, because users.stripe_customer_id is a nullable column and Chapter 7 (sqlc) set emit_pointers_for_null_types: true: a pointer is how Go says “this may be absent”, and nil means SQL NULL.
  • _ = app.q.DeleteSubscription(...)_ = explicitly throws the error away. That is a statement, not sloppiness: if the delete fails we still want to acknowledge, because retrying will not fix it and the subscription is gone from Stripe either way.
  • default: with logger.Debug — every event type we do not handle lands here, gets one debug line, and falls through to the 200 below. That includes checkout.session.completed, which is exactly the “log and acknowledge” treatment section 4.4 promised it.
  • app.invalidateEntitlements(r, user.ID) — does not exist yet. Chapter 17 (Entitlements and quotas) writes the real version; until then it is a no-op, so the file compiles and the call site is already in the right place.

That stub is a file of its own, so Chapter 17 has somewhere to grow:

// cmd/api/entitlements.go — new file, temporary stub; Chapter 17 replaces it
package main

import "net/http"

// Chapter 17 makes this delete this user's cached entitlements so an
// upgrade takes effect at once. Until then it does nothing, which is
// correct: there is no cache entry to invalidate yet.
func (app *application) invalidateEntitlements(r *http.Request, userID int64) {}

Go allows unused function parameters, so an empty body compiles. It does not allow unused imports, which is why net/http earns its place: the signature mentions *http.Request.

Note

Version drift, honestly. Every Stripe account has a default API version, and every stripe-go release is built against one. stripe-go/v78 expects 2024-04-10. If your account renders events in a newer format, ConstructEvent rejects otherwise-valid events with an error beginning Received event with API version, and the fix is to make the two agree. Two ways: upgrade stripe-go to a release built for your account’s API version, or set the API version on the webhook endpoint itself, which the dashboard lets you choose per endpoint. (Events forwarded by stripe listen are rendered with your account’s default version, so locally the upgrade route is usually the shorter one.) The book pins v78 so the code in it stays reproducible.

Note

One ordering concern worth naming out loud. The ledger row is committed before the subscription is written. If applySubscription then fails on a transient database blip, we return 500 and Stripe retries — but the retry takes the rows == 0 branch and returns 200 without ever writing the subscription. A paid upgrade would be silently lost. The two orderings that close the hole are: process first and record afterwards, or write both inside a single transaction. The code here stays as printed so this book’s codebase remains one consistent thing, but write the concern down; it is the kind of bug that only surfaces on the day your database wobbles.

Step 2 — Translate a Stripe subscription into our row

The second function in the same file. It answers one question: given Stripe’s object, what row should subscriptions hold?

// cmd/api/webhooks.go — add below stripeWebhookHandler
func (app *application) applySubscription(r *http.Request, sub *stripe.Subscription) error {
    user, err := app.q.GetUserByStripeCustomerID(r.Context(), &sub.Customer.ID)
    if err != nil {
        return err
    }

    // Translate Stripe's vocabulary (a price ID) into ours (a tier).
    // The price IDs came from config, so this mapping works in test
    // and live mode alike without a code change.
    tier := data.TierFree
    if len(sub.Items.Data) > 0 {
        switch sub.Items.Data[0].Price.ID {
        case app.config.stripe.priceIDPro:
            tier = data.TierPro
        case app.config.stripe.priceIDBusiness:
            tier = data.TierBusiness
        }
    }

    err = app.q.UpsertSubscription(r.Context(), db.UpsertSubscriptionParams{
        UserID:               user.ID,
        StripeSubscriptionID: sub.ID,
        Tier:                 string(tier),
        Status:               string(sub.Status),
        CurrentPeriodEnd:     time.Unix(sub.CurrentPeriodEnd, 0),
    })
    if err != nil {
        return err
    }
    app.invalidateEntitlements(r, user.ID) // ch. 17; stub as no-op for now
    return nil
}

What this code says, line by line

  • GetUserByStripeCustomerID — the join between the two systems. Chapter 15 stored stripe_customer_id on the user the first time they clicked upgrade; this is that key being used in the other direction.
  • tier := data.TierFree — start pessimistic. If the price ID matches nothing we know, the user gets free, not a crash and not an accidental upgrade.
  • if len(sub.Items.Data) > 0 — a subscription can in principle have several line items, and in principle none. Reading Data[0] on an empty slice would panic and take the request down; the length check is the guard.
  • sub.Items.Data[0].Price.ID — the price the customer is actually being billed for.
  • string(tier) and string(sub.Status) — both are named string types (data.Tier and Stripe’s SubscriptionStatus). Go will not let a named type slide into a plain string parameter, so the conversion is explicit. That strictness is why TierPro can never be confused with a status.
  • time.Unix(sub.CurrentPeriodEnd, 0) — Stripe sends dates as epoch time: a plain integer counting seconds since 1 January 1970 UTC. time.Unix(seconds, nanoseconds) turns that into a Go time.Time, which is what the timestamptz column wants.
  • UpsertSubscription — Chapter 15’s INSERT … ON CONFLICT (user_id) DO UPDATE SET …. Because user_id is the primary key of subscriptions, the second write for the same user updates in place rather than failing. That one clause is what makes this handler order-insensitive.
Note

applySubscription returns GetUserByStripeCustomerID’s error when the customer is unknown to us, and the caller turns that into a 500. That is a deliberate asymmetry with the deleted branch above, which tolerates an unknown customer. It also has a practical consequence you will meet in Step 5: synthetic events created by stripe trigger belong to a brand-new Stripe customer that your database has never heard of, so they end in a 500 rather than a row.

Step 3 — Mount the route in the right place

Where this route sits is a design decision, not a formality.

// cmd/api/routes.go — inside r.Route("/v1", ...), before the authenticated group
r.Post("/stripe/webhook", app.stripeWebhookHandler)

Two placements to get right:

  • Outside the authentication gate. Stripe carries no bearer token, so requireAuthenticatedUser would reject every event with a 401 and Stripe would retry forever. (The global authenticate middleware from Chapter 11 still runs; it identifies whoever is asking and tolerates anonymity, which is exactly right here.)
  • Outside the per-IP rate limiter. Stripe retries in bursts from a small set of IP addresses. Throttle those and you convert a temporary problem into permanently dropped billing events.
Common mistake

You’ll see: every event failing, and {"error":"you must be authenticated to access this resource"} in the stripe listen output. It means: the webhook route ended up inside the authenticated group. Fix: move the r.Post("/stripe/webhook", …) line above r.Group(func(r chi.Router) { … }), not inside it.

Step 4 — Point Stripe at your laptop

Your laptop has no address on the public internet. Stripe cannot dial it. The Stripe CLI solves this by having you open the connection outward and hold it open; Stripe then pushes events down it, and the CLI re-POSTs each one to a local URL you choose.

Run these in a second terminal, and leave the second command running:

stripe login
stripe listen --forward-to localhost:4000/v1/stripe/webhook
# > Ready! Your webhook signing secret is whsec_XXXX

stripe login opens a browser to pair the CLI with your Stripe account; you confirm a pairing code. stripe listen prints a banner ending in a signing secret starting whsec_. Copy it into config:

# config.toml — fill in the [stripe] block's webhook_secret
[stripe]
secret_key            = "sk_test_..."
webhook_secret        = "whsec_..."   # from `stripe listen`, NOT the dashboard
price_id_pro          = "price_..."
price_id_business     = "price_..."
success_url           = "http://localhost:4000/v1/billing/success"
cancel_url            = "http://localhost:4000/v1/billing/cancel"

Restart the API so it reloads config. In production this value arrives as TASKD_STRIPE__WEBHOOK_SECRET instead — Chapter 3 (Configuration and logging) built that override path precisely so secrets never live in a committed file.

Warning

stripe listen mints its own signing secret, different from the one the Stripe dashboard shows for a registered endpoint. Using the dashboard’s secret locally, or the CLI’s secret in production, fails verification — and the error does not say why. When webhooks “suddenly stopped working”, check this first.

Step 5 — Make an event happen and watch it land

Two ways. The honest one first.

Real path (produces a row). Do a test checkout exactly as in Chapter 15: call POST /v1/billing/checkout, open the returned checkout_url, and pay with Stripe’s test card 4242 4242 4242 4242, any future expiry, any CVC. Because that checkout was created for your user’s Stripe customer, the webhook can find the way home.

Synthetic path (exercises the plumbing).

stripe trigger customer.subscription.created

This asks Stripe to fabricate an event so you can see the pipe work end to end. Expect several deliveries rather than one: fabricating a subscription means fabricating a customer, a product and a price first, and each of those is an event in its own right.

Whichever path you take, the stripe listen window is where you watch. Each delivery prints two lines: one marked --> naming the event type Stripe sent, and one marked <-- carrying the HTTP status your server replied with, in square brackets. That second line is the fastest debugging tool you have all chapter.

Common mistake

You’ll see: a --> line for customer.subscription.created, then a <-- line reporting status 500. In the API log, an error line whose message is no rows in result set. It means: nothing is broken. stripe trigger invents a new Stripe customer, which no row in your users table is paired with, so GetUserByStripeCustomerID finds nobody. Fix: none needed — you have proved signature verification and the ledger both work. Use the real checkout path to see a subscriptions row appear.

Step 6 — The Customer Portal, in twenty lines

Note

This handler is printed here for the first time. The original edition mentioned it in a single sentence and never showed the code, which left this chapter’s closing argument unprovable. Here are the twenty lines it promised.

Every account-management feature a subscription product needs — update an expiring card, switch plans, cancel — is a page Stripe already hosts. We only have to ask for a link to it.

// cmd/api/billing.go — add below createCheckoutHandler
// Everything a user changes in the portal comes back to us as
// customer.subscription.updated/deleted — already handled by webhooks.go.
func (app *application) createPortalHandler(w http.ResponseWriter, r *http.Request) {
    user := app.contextGetUser(r)

    custID, err := app.ensureStripeCustomer(r, user)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    s, err := portalsession.New(&stripe.BillingPortalSessionParams{
        Customer: stripe.String(custID),
        // Where Stripe sends the browser when the user clicks "return".
        // We reuse success_url rather than adding a fourth URL to config.
        ReturnURL: stripe.String(app.config.stripe.successURL),
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusOK, envelope{"portal_url": s.URL}, nil)
}

The import and the route:

// cmd/api/billing.go — add to the import block
portalsession "github.com/stripe/stripe-go/v78/billingportal/session"
// cmd/api/routes.go — add inside the existing r.Route("/billing", ...)
r.Post("/portal", app.createPortalHandler)

What this code says, line by line

  • portalsession "github.com/…/billingportal/session" — an import alias. This package is also called session, and billing.go already imports Stripe’s checkout session. Two packages cannot share a name in one file, so we rename one at the point of import.
  • ensureStripeCustomer — Chapter 15’s lazy-creation helper, reused unchanged. A user who never paid gets a Stripe customer created on the spot, which is what makes the portal work for everybody.
  • ReturnURL — the portal needs somewhere to send the browser afterwards. We point it at success_url. A dedicated portal_return_url in config would be tidier; it is one extra key in [stripe] and one extra k.String line in loadConfig if you want it.
  • The handler returns a URL and nothing else. That is the entire account-management surface: no card form, no plan-switching UI, no cancellation flow, no proration arithmetic. All of it arrives back at the webhook you just built, as event types it already handles.
Remember this

This is the outsourcing decision of Chapter 15 paying compound interest. Twenty lines bought a feature that would otherwise be a project.

You can call it exactly like any other authenticated endpoint:

curl -s -H "Authorization: Bearer $TOKEN" -X POST \
  localhost:4000/v1/billing/portal

You should get a JSON body with a portal_url key whose value starts https://billing.stripe.com/. If you get a 500, check the API log: the most common cause is that the Customer Portal has never been configured in your Stripe dashboard, under Settings → Billing → Customer portal.

Step 7 — What changes in production

Nothing about the handler. Three things about the environment, all of which belong to the deployment checklist in Chapter 27 (Going live):

  1. Register the real endpoint URL (https://yourdomain/v1/stripe/webhook) in the Stripe dashboard and select the four event types from section 4.4.
  2. Copy that endpoint’s signing secret — a different whsec_ from the CLI’s — and supply it as TASKD_STRIPE__WEBHOOK_SECRET.
  3. Switch on the Customer Portal in the dashboard so Step 6’s handler has a page to link to.

7. Checkpoint: prove it works

Three demonstrations. Together they are the acceptance test of this chapter: money in, money out, and a replay that changes nothing.

Terminal 1: make run/api. Terminal 2: stripe listen --forward-to localhost:4000/v1/stripe/webhook. Terminal 3: everything below. Terminal 3 is a fresh shell, so re-run the TOKEN=$(curl …) line from the top of this chapter there before the first command.

1. Pay, and watch a row appear.

curl -s -H "Authorization: Bearer $TOKEN" -d '{"tier":"pro"}' \
  localhost:4000/v1/billing/checkout

Open the checkout_url in a browser, pay with 4242 4242 4242 4242. Then:

make db/psql
-- run inside psql: the row Stripe just wrote
SELECT user_id, tier, status, current_period_end FROM subscriptions;

You should see one row, with tier = pro, status = active, and a current_period_end about a month in the future. That row was written by Stripe’s word alone.

2. Cancel, and watch it vanish. In the Stripe dashboard, open the subscription and cancel it immediately. Re-run the SELECT. You should see zero rows. (The stripe listen window will show a customer.subscription.deleted delivery answered [200].)

3. Replay an old event, and watch nothing happen. Copy the evt_… ID of the customer.subscription.created delivery from the stripe listen window, and ask Stripe to send it again:

stripe events resend evt_...

The stripe listen window shows another delivery, answered [200]. The subscriptions table is unchanged — still zero rows after the cancellation. The reason is in the ledger:

-- run inside psql: the idempotency ledger
SELECT id FROM stripe_events;

That event’s ID is already in the list, from the first time it arrived. So the redelivery took the rows == 0 path: acknowledged, and nothing else.

Checkpoint

A signature failure is worth seeing on purpose. Run: curl -i -X POST -d '{}' localhost:4000/v1/stripe/webhook You should get HTTP/1.1 400 Bad Request with an empty body, and the API log should contain a line at WARN level whose msg is webhook signature verification failed and whose error is webhook has no Stripe-Signature header.

If it did not work:

You got Cause Fix
[400] on every real delivery wrong signing secret in config copy the whsec_ from the running stripe listen, restart the API
nothing arrives at all stripe listen is not running, or forwards to the wrong path check the --forward-to URL ends /v1/stripe/webhook
[500] and no rows in result set the event’s customer is not paired with any user you used stripe trigger; do a real test checkout instead
[404] in the listen window route not registered re-check Step 3 and restart the API

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
webhook has no Stripe-Signature header the request had no signature at all — a curl, a scanner, or a browser nothing to fix; this is the endpoint working
webhook had no valid signature a signature was present but did not match wrong webhook_secret for this sender, or a middleware altered the body
webhook has invalid Stripe-Signature header the header was malformed almost always a hand-crafted request, not Stripe
timestamp wasn't within tolerance the signed timestamp is over five minutes old your machine’s clock is wrong, or you replayed a captured request by hand
Received event with API version …, but stripe-go … expects API version 2024-04-10 your Stripe account renders events in a newer format than stripe-go/v78 knows make the two agree: pin the endpoint or listener to 2024-04-10, or upgrade stripe-go
cmd/api/webhooks.go:73:8: app.invalidateEntitlements undefined (type *application has no field or method invalidateEntitlements) Chapter 17’s function does not exist yet add the no-op stub from Step 1
cmd/api/webhooks.go:105:25: cannot use tier (variable of string type data.Tier) as string value in struct literal named types do not auto-convert string(tier), as printed
every delivery answered [401] the route is inside the authenticated group move it out, per Step 3
the row appears but shows tier = free after paying for Pro the price ID in config does not match the one on the subscription check price_id_pro against the price in the Stripe dashboard, test mode

9. Pitfalls

  • Body already consumed. Any middleware that reads the body — request logging that records payloads, generic decompression — running upstream of this handler breaks signature verification with a maddening “signature mismatch” on valid events. Once something reads r.Body, the bytes are gone; io.ReadAll in the handler then gets nothing. Keep the webhook path middleware-minimal, and if you later add body-reading middleware, exclude this route explicitly.
  • Returning 5xx on unknown event types. Stripe retries; your logs fill; eventually the endpoint gets auto-disabled for misbehaviour. Unknown is not an error. Return 200.
  • Local secret versus dashboard secret. stripe listen mints its own whsec_; deploying with the CLI’s secret, or developing with the dashboard’s, fails verification and the error does not say why. When webhooks “suddenly stopped working”, check this first.
  • past_due and grace. A failed renewal sets status past_due, not deleted — Stripe keeps retrying the charge according to your dunning settings, which are the rules for how many times and how far apart. Because we upsert the status rather than making a decision here, Chapter 17 (Entitlements and quotas) gets to decide the policy: it grants access on active, trialing and past_due, cutting off only at cancellation, instead of us hard-coding cruelty or charity in a webhook handler.
  • Clock skew paranoia. ConstructEvent enforces a five-minute tolerance on the signed timestamp as its replay defence. If verification fails only on some machines, check their clocks before your code. Containers with a drifting host clock are the usual culprit.

10. Check yourself — quiz

  1. In one sentence, what is a webhook, and which direction does the call go?
  2. What does a valid HMAC signature prove? Name one thing it does not prove, and what Stripe adds to cover it.
  3. Why can this handler not use readJSON, when every other handler in taskd does?
  4. InsertStripeEvent returns 1. Then the same event is delivered again. What does it return, and what does the handler do next?
  5. Two events for one subscription arrive in the wrong order, each carrying the full current object. Why does an upsert survive that and a state machine does not?
  6. You return 500 to Stripe for an event type you do not recognise. Describe what happens over the next week.
  7. Which four event types does section 4.4 name, and how many of them does the switch statement actually have a case for? What happens to the odd one out?
  8. Give both reasons the webhook route sits outside the authenticated group and outside the per-IP rate limiter.
Answers
  1. A webhook is a URL on your server that another company calls when something happens on their side. The call goes from them to you — the reverse of the usual direction, which is why the endpoint is public and needs its own authentication scheme.

  2. It proves the sender knew the shared signing secret, and that the bytes were not altered in transit. It does not prove the message is new: a captured valid request could be replayed. Stripe covers that by including a timestamp in the signed data and rejecting anything signed more than five minutes ago.

  3. Because the signature is computed over the exact bytes that arrived. readJSON consumes the body and produces a Go value; the original bytes are gone, and re-encoding the value yields different bytes with the same meaning. Verification would fail on perfectly valid events.

  4. It returns 0, because ON CONFLICT (id) DO NOTHING inserted nothing. The handler takes the rows == 0 branch, writes 200 OK and returns immediately — no subscription write, no entitlement invalidation, no further work.

  5. Every event carries the whole current subscription object, so an upsert writes complete state each time; whichever event lands last, the next event about that subscription corrects the row. A state machine interprets each event as a transition from an assumed previous state, so once it is fed a wrong order it has no mechanism to recover — its story stays wrong.

  6. Stripe treats every non-2xx reply the same way — “not delivered, try again” — so it retries with growing gaps for days. Your logs fill with the same event, your error rate looks terrible, and Stripe eventually disables the endpoint — at which point real billing events stop arriving too.

  7. checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. The switch has cases for three of them. The odd one out is checkout.session.completed, which falls to default:, is logged at debug level, and is answered 200 — deliberately, because the subscription events carry the real state.

  8. Outside authentication: Stripe sends no bearer token, so a gate would 401 every event and Stripe would retry forever. Outside the IP limiter: Stripe’s retries arrive in bursts from a few IP addresses, and throttling them turns a temporary delivery problem into permanently lost billing events.


11. Practice

Exercise 1 — Run the triple demo as a drill (easy)

Do the three checkpoint demonstrations in order and record, for each one, the status line in the stripe listen window and the row count in subscriptions afterwards. Write the three-line result into learnings/ch16.md.

Solution

The shape you are recording, with your own event IDs:

Step stripe listen shows SELECT count(*) FROM subscriptions
test checkout paid customer.subscription.created answered [200] 1
cancelled in dashboard customer.subscription.deleted answered [200] 0
old event re-delivered same event type answered [200] 0

The third line is the whole point: the same [200], and no change. Verify why with

-- run inside psql
SELECT id, received_at FROM stripe_events ORDER BY received_at;

The re-delivered event’s ID is already in that table, from the first time it arrived.

Exercise 2 — Attack your own endpoint (easy)

POST to the webhook with no signature, then with a deliberately wrong one. Capture the status code and the exact log line each produces.

Solution
curl -i -X POST -d '{"id":"evt_fake"}' localhost:4000/v1/stripe/webhook

curl -i -X POST -d '{"id":"evt_fake"}' \
  -H "Stripe-Signature: t=1755300000,v1=deadbeef" \
  localhost:4000/v1/stripe/webhook

Both return HTTP/1.1 400 Bad Request with an empty body. In the API log, both produce a WARN line with msg="webhook signature verification failed". The error value differs: the first is webhook has no Stripe-Signature header; the second is timestamp wasn't within tolerance if you used the timestamp above, or webhook had no valid signature if you used a current one.

Now check the ledger:

-- run inside psql
SELECT count(*) FROM stripe_events WHERE id = 'evt_fake';

Zero. Verification runs before the ledger insert, so a forgery never gets to write anything at all — not even a row in a bookkeeping table. That ordering is deliberate.

Exercise 3 — Prove the projection converges (harder)

Without touching the code, show that processing the same subscription twice leaves exactly one row. Then explain, in learnings/ch16.md, which line of SQL makes that true.

Solution

Do a test checkout so a row exists, then in the Stripe dashboard change the subscription’s plan from Pro to Business. That fires customer.subscription.updated with a different price ID.

-- run inside psql
SELECT user_id, tier, status, updated_at FROM subscriptions;

Still exactly one row, now with tier = business and a fresher updated_at. Two events, one row.

The line responsible is in Chapter 15’s UpsertSubscription:

-- sql/queries/billing.sql — the clause inside UpsertSubscription
ON CONFLICT (user_id) DO UPDATE SET ...

user_id is the primary key of subscriptions, so a second insert for the same user cannot create a second row — it can only overwrite the first. The schema enforces “one subscription per user” regardless of what any future handler does, which is the point of putting the rule in the primary key rather than in Go.

If you want to see it without spending a test card, insert the same values twice by hand. Get a real user id first, because subscriptions.user_id is a foreign key into users — and pick a user who has no subscription row yet, so the clean-up at the end does not leave someone else’s real row rewritten:

-- run inside psql: a user that has no subscription row yet
SELECT u.id FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE s.user_id IS NULL
LIMIT 1;
-- replace 1 with the id you just got
INSERT INTO subscriptions (user_id, stripe_subscription_id, tier, status, current_period_end)
VALUES (1, 'sub_manual', 'pro', 'active', now() + interval '30 days')
ON CONFLICT (user_id) DO UPDATE SET tier = EXCLUDED.tier, updated_at = now();

Run it twice. One row, both times. EXCLUDED.tier is how the update arm reaches the value the insert would have written. Then clean up:

-- run inside psql: clean up
DELETE FROM subscriptions WHERE stripe_subscription_id = 'sub_manual';

12. FAQ

How does Stripe reach my laptop? It has no address on the internet. It does not. stripe listen opens a connection outward from your machine to Stripe and holds it open — the same direction any browser request travels, which is why no router or firewall configuration is needed. Stripe pushes events down that open connection, and the CLI turns each one into a normal local HTTP POST. Close the CLI and delivery stops.

Why can I not just check success_url and mark the user Pro? Because anyone can type that URL into a browser. It is a page, not a proof. It can also be skipped entirely — customers close the tab — and it can arrive before Stripe has finished creating the subscription. Chapter 15 said it once; here is the mechanism that makes the alternative real: entitlements change when Stripe tells the server, over a signed channel, never when a browser shows up claiming.

What if my server is down when Stripe calls? Nothing is lost. A failed delivery is retried with increasing gaps for days. When you come back up, the queued events arrive, the ledger stops any that already landed, and the upserts converge on the current truth. This is exactly what at-least-once delivery buys you, and it is why the design tolerates duplicates rather than trying to prevent them.

Why store event IDs forever? Won’t that table grow without limit? It would, and for now it does. Chapter 21 (Background work and email) adds a PruneStripeEvents query that deletes rows older than thirty days, and a janitor goroutine that runs it hourly. The ledger only has to out-remember Stripe’s retry horizon, which is days, not decades — so thirty days is comfortably safe, and nothing that could still be re-delivered is ever forgotten.

Do I need a public URL, a domain or a deployed server to develop this? No. stripe listen is the whole answer for local development. You need a public URL only when you register a real endpoint in the dashboard, which is a Chapter 27 (Going live) task.

This is a lot of machinery for “the user paid”. Is this really how companies do it? Yes, and the machinery is smaller than it looks: one signature check, one insert, one switch, one upsert. What makes it feel heavy is that each piece defends against a different failure — forgery, duplication, disorder — and beginners meet all three at once. Every payment integration in production has these three parts, under one name or another.


13. Where we are

The hole Chapter 15 left in the floor is closed. Money now moves state: a payment writes a row, a cancellation removes it, and neither depends on a browser doing anything.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go          # UPDATED: /stripe/webhook, /billing/portal
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go
│   ├── users.go
│   ├── tokens.go
│   ├── billing.go         # UPDATED: createPortalHandler
│   ├── entitlements.go    # NEW: invalidateEntitlements stub (ch. 17 fills it)
│   └── webhooks.go        # NEW: stripeWebhookHandler, applySubscription
├── internal/
│   ├── cache/
│   ├── data/              # filters.go plans.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output, incl. billing.sql.go
│   └── validator/
├── migrations/            # 000005_billing: subscriptions + stripe_events
├── sql/queries/           # billing.sql tasks.sql tokens.sql users.sql
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: a user registers, logs in, requests a checkout URL, pays with a test card, and within a second the subscriptions table holds their tier, status and renewal date — written only by a cryptographically verified message from Stripe. Cancelling removes the row. Replaying old events changes nothing. Users can be handed a Customer Portal link to manage their own billing, and every change they make there returns as an event this handler already understands.

What is still fake or missing:

  • Nothing reads the subscriptions table. A Pro user gets exactly the same limits as a free one. Chapter 17 (Entitlements and quotas) turns the row into enforced behaviour.
  • invalidateEntitlements does nothing. It is a stub. Chapter 17 makes it delete the cached entitlements so an upgrade takes effect immediately instead of after a cache TTL.
  • stripe_events grows forever. Chapter 21 (Background work and email) writes the pruning query and the janitor that runs it hourly.
  • past_due is stored but has no meaning. Chapter 17 decides the grace policy.
  • The webhook is registered nowhere but your laptop. Chapter 27 (Going live) registers the real endpoint and its production signing secret.

For your notes

Copy these into learnings/ch16.md, in your own words:

  1. A webhook is authenticated by cryptography, not by a token. Stripe and you share a secret; Stripe fingerprints the exact bytes with it; you recompute and compare. Matching proves the sender knew the secret and nothing changed in transit.
  2. Signatures cover bytes, not meaning. So the raw body must reach the verifier untouched — no decoding, no re-encoding, no body-reading middleware upstream. Helpers encode assumptions; know when an endpoint’s threat model breaks them.
  3. INSERT … ON CONFLICT DO NOTHING with :execrows is idempotency in one statement. One row means first time; zero rows means duplicate. No read-then-write race, no extra table logic.
  4. Convergence beats choreography. Each event carries the whole object, so overwrite your copy rather than interpreting transitions. An upsert always leaves a valid row and self-corrects on the next event; a state machine fed the wrong order can land somewhere no event described and stay there.
  5. HTTP status codes are protocol, not decoration. To Stripe there are exactly two answers: 2xx means “stop retrying”, and everything else means “retry, for days”. So an event type you do not recognise still gets a 200; answering 4xx or 5xx there gets your endpoint disabled.

Chapter 17 — Entitlements and quotas: enforcing the tiers

Chapter 15 (Stripe I) built the shop window and Chapter 16 (Stripe II) built the till. A user can pay you real money, a webhook writes a row saying they are on Pro, and then — nothing happens. The Pro customer gets exactly the same rate limit, the same features and the same task quota as somebody who has never paid you a penny. This chapter closes that gap. You will write one function that answers the question “what is this user allowed to do right now”, and wire it into the three places that need an answer: the rate limiter, the paid features, and the task quota. You will also learn why the honest reply when a user hits a limit is not an error page but a sales offer.

What you’ll be able to do by the end

  • Say what an entitlement is, and write the single function that produces one from a user ID.
  • Point at the gates in taskd that ask that function, and explain why they ask there rather than in the authentication middleware.
  • Explain fail-to-free, and why it is the opposite decision to Chapter 12’s fail-closed — on purpose.
  • Return 402 Payment Required with a body a client program can turn into an upgrade button.
  • Watch a paid feature switch on within a second of a webhook arriving, with nothing restarted.

Time: ~50 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 16 (Stripe II: webhooks, the source of truth). Two commands prove it:

go build ./...
grep -c invalidateEntitlements cmd/api/webhooks.go

The first prints nothing at all — that is what success looks like for a Go build. The second prints 2: the two places in the webhook code where Chapter 16 called the no-op stub you are about to replace with something real. If it prints 0, go back to Chapter 16’s Step 1 and Step 2.


1. The problem, in plain words

A cinema sells three ticket types. Standard gets you a seat. Premium gets you a bigger seat and a drink. VIP gets you the balcony, unlimited refills and the private bar.

Now think about how the cinema enforces that. It does not print the rules on the ticket and hope. There is a person at the balcony stairs, a person at the bar, and a person at the refill counter, and every one of them asks the same question of your ticket: what does this admit you to? They do not each carry their own private list of which ticket types are allowed upstairs. If they did, the day management adds a fourth ticket type, two of the three staff would be told and the third would not, and for a fortnight some customers would get the balcony and some would not, seemingly at random.

Software has the same failure, and it is more expensive. The version of this bug that lives in codebases looks like this, sprinkled through twenty handlers:

// NOT our code — the thing we are designing against
if tier == "pro" || tier == "business" {
    // allow search
}

Each of those lines was correct on the day it was typed. Then somebody adds a Team tier and updates seventeen of the twenty. Now three features silently disagree about who is a paying customer. Two kinds of person notice: the customer who paid for search and cannot search, who writes an angry email; and the customer who did not pay for search and can search, who writes nothing at all and costs you money forever. Drift in billing enforcement is either angry customers or free riders, and you find out about only one of them.

So the shape of this chapter is fixed by that observation. One function owns the rules. Everything else asks it and believes the answer.

There is a second problem hiding behind the first, and it is about when you are allowed to say no. Suppose your database has a bad thirty seconds. The entitlement lookup fails. What do you do with the paying customer whose request is in your hands right now? Lock them out, because you cannot prove they paid? Or let them through as a free user, because a billing hiccup that becomes a total outage has turned a small problem into a large one? That is not a technical question. It is a policy question, and this chapter answers it out loud rather than by accident.


2. New words in this chapter

Word What it means here
entitlement What a specific user is allowed to do right now, derived from their plan — limits and feature switches.
tier / plan A named package of features and limits at a price: Free, Pro, Business. Chapter 15’s word.
resolver The single function that turns a user ID into their current entitlements, so no handler decides plan logic for itself.
tier resolution The rule that reads a subscription row and decides which tier it means.
grace set The subscription statuses we treat as “paying”: active, trialing, past_due.
grace period Continuing to grant access during dunning rather than cutting a customer off at the first failed charge.
dunning The retry-and-remind process a payment provider runs after a card is declined, before giving up. Chapter 16’s word.
feature gate A check that turns a feature on or off for a user based on their plan.
quota A numeric cap on something countable — here, 100 active tasks on the free plan.
soft limit A limit where a small overshoot is harmless, so cheap enforcement is acceptable.
fail-to-free This book’s policy: if billing lookups break, treat the user as free-tier rather than locking them out entirely.
staleness How out of date a cached answer is allowed to be before it is wrong enough to matter.
invalidation Deleting a cached answer on purpose, because you know it has become wrong.
402 Payment Required The HTTP status code meaning “you have hit a paid-plan limit; upgrade to continue”.
machine-readable error An error body containing a stable short code a program can branch on, not only a sentence a human reads.
race condition Two things happening at once producing a result neither would produce alone. Chapter 14’s word, back again.
advisory lock (pg_advisory_xact_lock) A named lock you ask Postgres to hold so only one transaction at a time runs a given piece of logic.
database trigger Code the database runs automatically whenever a row changes.
upsell Offering a customer a bigger plan than the one they are on.
CTA (call to action) The button or link that invites the next step — here, “Upgrade to Pro”.
funnel The sequence of steps from visitor to paying customer. Your error responses are part of it.
downgrade stranding A user drops to a cheaper plan while holding more data than it allows. A policy question, not a code question.

3. The goal

A single resolveEntitlements(userID) function — cached in Dragonfly, invalidated by webhooks — feeding three enforcement points: the plan-aware rate limit (replacing Chapter 14’s hardcoded 60), feature gates (priorities, search), and the task quota. Over-limit responses that sell: 402 Payment Required with a machine-readable upgrade hint.

By the end of this chapter every line of that sentence is running code. The Plans map you wrote in Chapter 15 and have not read once since finally does a job.


4. The thinking

4.1 One resolver, many gates

The failure mode to design against is entitlement logic smeared across handlers (if tier == pro || tier == business twenty times over) — it drifts, and drift in billing enforcement means either angry customers or free riders. So: one function turns userID → Entitlements, and everything else consumes the struct.

Plan checks written at each gate One resolver, many gates
Adding a tier edit every gate; miss one and it drifts edit Plans, one file
Changing the grace policy edit every gate edit one switch
Reading the rules grep and hope open entitlements.go
Caching the lookup a cache per gate, or none one cache, one key
Cost of the extra layer none one function call per gate

The right-hand column wins on every row except the last, and the last costs a function call.

Notice how little the resolver actually decides. The tier-to-features mapping already lives in data.Plans, written in Chapter 15 (Stripe I) — that is the rulebook, in code, reviewed like any other behaviour. The resolver’s only real job is tier resolution: look at the subscription row and decide which tier it means.

The rule is short enough to state in one sentence. A subscription row whose status is in active | trialing | past_due gives you its tier. Anything else — no row at all, canceled, unpaid — is free.

New word

grace set — the set of subscription statuses we choose to treat as paying. Ours is active, trialing, past_due. trialing is in it because a free trial is meant to feel like the product. past_due is in it because a declined card is usually an expired card, not a decision to leave, and cutting off a customer mid-dunning is how you turn a payment retry into a cancellation.

4.2 Caching it

The resolver runs on roughly every authenticated request — the rate limiter alone calls it — so it must not be a Postgres query per request. The answer is the one Chapter 13 (Caching with DragonflyDB) built: Dragonfly, with a 60-second TTL.

New word

TTL — “time to live”, the number of seconds a cached value is allowed to survive before the cache deletes it by itself. It is a ceiling on staleness: how out of date the answer can be.

But a TTL on its own is not good enough here, and it is worth being precise about why. If the only mechanism were the TTL, a customer who just paid would sit on the free plan for up to a minute while staring at a “thanks for upgrading” page. That is a bad minute — it is the exact moment they are deciding whether they trust you.

So we use both mechanisms at once:

Mechanism What it gives us What it costs
60-second TTL a bound on staleness even if everything else fails up to 60 s of wrongness
Webhook invalidation upgrades feel instant one DEL per subscription event

That invalidation is the invalidateEntitlements call Chapter 16 seeded in two places in webhooks.go and left as an empty function. This chapter fills it in.

The two mechanisms cover for each other. If an invalidation is ever lost — the cache was unreachable for that one second, say — the TTL still corrects the answer within a minute. Belt and braces, and neither is expensive.

Then the failure question. Cache unreachable? Resolve from Postgres. Postgres unreachable too? Fail to the free tier, not to closed: a billing-system hiccup must degrade features, never lock paying customers out entirely.

Remember this

Fail open or fail closed is decided per gate, from what is behind the gate. Chapter 12 (Ownership: making it multi-tenant) fails closed, because behind that gate is other people’s data. Chapter 13’s cache and Chapter 14’s fairness limiter fail open, because behind them is speed and politeness. This chapter fails to free, because behind this gate is your revenue — and an hour of accidental free features costs less than an hour of locked-out paying customers. Same fork, third answer, chosen out loud.

Note carefully what fail-to-free means for a paid feature: a user with a broken lookup gets free entitlements, and free entitlements say Priorities: false. So the feature is denied — that gate fails closed — while the service stays open. Both statements are true at once, and confusing them is how people end up arguing about fail-open versus fail-closed as if it were a personality trait rather than a per-door decision.

4.3 The quota race, examined honestly

The quota check is “count the user’s tasks; if fewer than the maximum, insert”. Read it as a sequence and it looks airtight. Run two of them at the same instant and it is not.

New word

race condition — two operations happening at once producing a result that neither would produce alone. You met this in Chapter 14 with the map and the mutex. Here nothing crashes; the count is momentarily wrong, which is worse in a way, because it is invisible.

Two simultaneous creates by the same user, sitting at 99 out of 100, both read 99, both conclude they are under the limit, and both insert. The user ends up with 101. Nothing crashed; the limit was not the limit.

There are three fixes, and they are not equally priced:

Fix What it does Cost
(a) Accept the overshoot document that a soft business limit may be exceeded by a handful nothing
(b) Advisory lock wrap count-and-insert in pg_advisory_xact_lock(user_id) so one transaction per user runs it at a time a transaction, a lock, contention per user
© Counter column keep a task_count column maintained by a database trigger, with a CHECK constraint a trigger, a migration, a new class of bug
New word

advisory lock — a lock you ask Postgres for by number, on something Postgres itself knows nothing about (here, “the right to count and insert for user 42”). pg_advisory_xact_lock holds it until the surrounding transaction ends, so you cannot forget to release it. Transaction-scoped is the important word: the lock’s life is the transaction’s life.

database trigger — code the database runs by itself whenever a row changes, so a count column can never drift from the rows it counts.

We choose (a) and document it. The judgment is: match the strength of enforcement to what the limit protects. Nobody is defrauded by a free user holding 101 tasks instead of 100; nothing breaks; no invariant a later query depends on is violated. A payment ledger — “never let the balance go below zero” — would get (b) without a moment’s hesitation, because there the overshoot is money.

Remember this

Knowing why you are allowed to be relaxed is a completely different thing from not having thought about it. Write the reason in a comment next to the relaxed code, so the next person can tell the two apart.

4.4 The 402 philosophy

A request that hits a quota is not an error. Nothing went wrong. The user asked for something their plan does not include, which is the most predictable event in a subscription business, and it is the one moment you have their full attention.

So the response is designed as a sales moment, not a failure:

Option What a client can do with it Verdict
403 Forbidden show “forbidden” wrong meaning: 403 says you will never be allowed, and money fixes this
429 Too Many Requests back off and retry wrong meaning: retrying will not help; the plan is the limit
200 OK with a flag in the body anything, if it reads the flag dishonest — the request did not succeed
402 Payment Required + a stable code + an upgrade path render an upgrade button what we do

402 was reserved in the original HTTP specification for exactly this and left unused for decades; it is increasingly used by APIs for precisely this case. A stable, short code string in the body — upgrade_required — is what makes the response machine-readable: a client program can branch on code without matching English text that you might reword next Tuesday. And the upgrade field carries the path the client should send them to, so the mobile app does not have to hardcode your billing URL. Between them, those two fields are everything a client needs to render an upgrade CTA — the call-to-action button — instead of a generic failure toast.

Remember this

Your API’s error design is part of your funnel. A generic failure toast tells the user your product is broken. A 402 with an upgrade path tells them your product has more of what they just tried to use.


5. A picture of it

One resolver, many gates

What you are looking at: four call sites on the left, one function in the middle, two data stores on the right. The middle box is the only code in taskd that knows what a tier means.

The goal statement counts three enforcement points — the rate limit, the feature gates and the quota — because priorities and search are two instances of the same idea. Counted as places in the code where a gate is written, there are four, and that is how Part B numbers its steps.

   WHO ASKS                     THE ONE ANSWER            WHERE IT LOOKS

   rateLimitUser   ──┐
   (requests/min)    │
                     │      ┌───────────────────────┐    ┌───────────────┐
   priority gate   ──┤      │  resolveEntitlements  │───▶│  Dragonfly    │
   (create, update)  ├─────▶│  userID → Entitlements│◀───│  key ent:42   │
                     │      │                       │    │  TTL 60s      │
   search gate     ──┤      │  1. cache             │    └───────────────┘
   (list)            │      │  2. else Postgres     │    ┌───────────────┐
                     │      │  3. else free tier    │───▶│  Postgres     │
   quota check     ──┘      └───────────────────────┘◀───│ subscriptions │
   (create)                                              └───────────────┘
  1. Four call sites ask the same question and get the same Entitlements struct back.
  2. The resolver tries Dragonfly first, because it will be asked on nearly every request.
  3. On a miss it asks Postgres for the subscription row and applies the tier-resolution rule.
  4. If both are unavailable it returns the free plan rather than an error. Nobody is locked out.

How a tier is resolved

What you are looking at: the decision the resolver makes, from top to bottom. Every path ends in an Entitlements value; none ends in an error.

                    resolveEntitlements(ctx, 42)
                              │
                              ▼
                 ┌─────────────────────────┐   hit + decodes
                 │  cache GET ent:42       │──────────────────▶ return it
                 └─────────────────────────┘
                              │ miss, no cache, or bad JSON
                              ▼
                 ┌─────────────────────────────────────┐
                 │ SELECT * FROM subscriptions         │
                 │   WHERE user_id = 42                │
                 └─────────────────────────────────────┘
                              │
        ┌─────────────────┬───┴────────────┬──────────────────┐
        ▼                 ▼                ▼                  ▼
     no row          status in         canceled            DB error
  (never paid)   active / trialing /   or unpaid        (or DB is down)
                     past_due
        │                 │                │                  │
        ▼                 ▼                ▼                  ▼
      free           that row's          free               free
                        tier
        └─────────────────┴────────────────┴──────────────────┘
                              │
                              ▼
              Plans[tier]  →  cache SET ent:42 (60s)  →  return

Why both a TTL and an invalidation

What you are looking at: the same user’s upgrade, twice. Above the line, invalidation works. Below it, the invalidation is lost and the TTL cleans up after it.

   t=0s   user pays ─▶ Stripe ─▶ webhook ─▶ upsert row ─▶ DEL ent:42
   t=1s   next request: cache MISS ─▶ Postgres says pro ─▶ Pro features
          ────────────────────────────────────────────────────────────
   t=0s   user pays ─▶ webhook ─▶ upsert row ─▶ DEL fails (cache blip)
   t=1s   next request: cache HIT (stale) ─▶ still free  ← the bad minute
   t=60s  TTL expires ─▶ cache MISS ─▶ Postgres says pro ─▶ Pro features

The quota race, drawn

What you are looking at: two requests from the same user, interleaved. Neither one is wrong on its own.

   time    request A (user 42)          request B (user 42)
   ──────────────────────────────────────────────────────────────
    t0     CountActiveTasks → 99
    t1                                  CountActiveTasks → 99
    t2     99 >= 100 ? no → continue
    t3                                  99 >= 100 ? no → continue
    t4     INSERT task        (100)
    t5                                  INSERT task        (101)
   ──────────────────────────────────────────────────────────────
   result: a free user holds 101 active tasks. The limit said 100.

The 402, as the client sees it

What you are looking at: the response body on the left, and what a client application does with each field on the right.

   HTTP/1.1 402 Payment Required
   {
     "error": {
       "code":    "upgrade_required",  ─▶ branch on THIS, never on prose
       "message": "task priorities are ─▶ show to the human
                   available on Pro and
                   Business plans",
       "upgrade": "/v1/billing/checkout" ─▶ the button's destination
     }
   }

6. The steps

Ten steps, in four parts. Part A builds the one function. Part B wires it into the four gates. Part C prints the two task handlers whole, because after five chapters of edits you deserve to see them in one piece. Part D gives the client a way to ask what plan it is on, and then you run the whole loop end to end.


Part A — One function that answers “what may this user do”

Step 1 — Give the cache a Delete method

The resolver caches each user’s entitlements under one key, ent:42, and the webhook needs to remove exactly that key when a subscription changes. Chapter 13’s cache package can Get, Set and InvalidateUser, but it cannot delete a single key. Four lines fix that.

// internal/cache/cache.go — add this method, directly after Set
// Delete removes a single key — best-effort, same policy as Set. (ch. 17)
func (c *Cache) Delete(ctx context.Context, key string) {
    _ = c.rdb.Del(ctx, key).Err()
}
Note

This method is printed here for the first time in the book. The original edition says only “(Add the trivial Delete method to internal/cache.)” and never shows it, so a reader typing the next step in gets a compile error with no listing to compare against. If you did Chapter 13’s Exercise 2 you already have this method and can move on — grep -n "func (c \*Cache) Delete" internal/cache/cache.go prints one line if it is there.

What this code says, line by line

  • func (c *Cache) Delete(...) — the (c *Cache) part is the receiver: it makes this a method on the Cache type rather than a loose function, so you call it as app.cache.Delete(...).
  • c.rdb is the go-redis client Chapter 13 put inside the Cache struct. Dragonfly speaks the Redis protocol, so the Redis client library talks to it without knowing the difference.
  • Del is the Redis command for “remove these keys”. It returns a result object; .Err() pulls the error out of it.
  • _ = throws that error away, and the blank identifier _ is Go’s way of saying I am ignoring this on purpose. Go would compile without the assignment at all; writing it makes the decision visible. The policy matches Set: a cache operation that fails is not a request that failed.
  • No return value. If Delete returned an error, every call site would have to decide what to do about it, and the honest answer at each one is “nothing”.

What you should see

go build ./...

Nothing. A Go build prints output only when something is wrong.

Step 2 — Write the resolver

This replaces the whole of cmd/api/entitlements.go — the file Chapter 16 created holding a single empty function so the webhook would compile. Delete what is in it and type this.

// cmd/api/entitlements.go — replaces the whole file (was ch. 16's stub)
package main

import (
    "context"
    "encoding/json"
    "net/http"
    "time"

    "github.com/yourname/taskd/internal/data"
)

func entKey(userID int64) string { return "ent:" + itoa(userID) }

func (app *application) resolveEntitlements(ctx context.Context, userID int64) data.Entitlements {
    if app.cache != nil {
        if b, ok := app.cache.Get(ctx, entKey(userID)); ok {
            var e data.Entitlements
            if json.Unmarshal(b, &e) == nil {
                return e
            }
        }
    }

    // The tier-resolution rule from "The thinking", as code: start from
    // free, upgrade only if a subscription row exists AND its status is
    // in the grace set. No row (never paid), canceled, unpaid — and any
    // DB error at all — leave the user at free. Fail-to-free.
    tier := data.TierFree
    sub, err := app.q.GetSubscription(ctx, userID)
    if err == nil {
        switch sub.Status {
        case "active", "trialing", "past_due":
            tier = data.Tier(sub.Tier)
        }
    } // ErrNoRows or DB error → free (fail-to-free policy)

    e := data.Plans[tier]
    if app.cache != nil {
        if b, err := json.Marshal(e); err == nil {
            app.cache.Set(ctx, entKey(userID), b, 60*time.Second)
        }
    }
    return e
}

func (app *application) invalidateEntitlements(r *http.Request, userID int64) {
    if app.cache != nil {
        app.cache.Delete(r.Context(), entKey(userID)) // one-line Del wrapper in cache pkg
    }
}

What this code says, line by line

  • func entKey(userID int64) string { return "ent:" + itoa(userID) } — one key per user, built the same way every time. Writing it as a function rather than sprinkling "ent:" + ... around is what guarantees the resolver and the invalidator never disagree about the name of the key. itoa is the two-line strconv.FormatInt wrapper from helpers.go, added in Chapter 8 (CRUD done properly) because Go’s built-in strconv.Itoa only accepts int and our IDs are int64.

  • ctx context.Context as the first parameter, rather than r *http.Request. A context is the value Go passes down a call chain carrying “this request’s deadline and cancellation” — Chapter 6 (Connecting with pgx) introduced it. Taking a context.Context rather than a request is what lets middleware, handlers and (later) background code all call this function. The middleware in Step 4 will pass r.Context().

  • data.Entitlements as the return type, not (data.Entitlements, error). That is the fail-to-free policy expressed in the type system: this function has no failure mode to report, because every failure has a defined answer. Callers cannot forget to handle an error that cannot be returned.

  • if app.cache != nil — Chapter 13 made a missing cache a legal, documented state of the application struct, meaning “we booted without Dragonfly today”. Every cache call in the codebase is guarded like this. Without the guard the next line would panic on every request.

  • if b, ok := app.cache.Get(ctx, entKey(userID)); ok {Get returns two values: the bytes, and a boolean that is true only on a hit. The if x, y := ...; y shape declares both variables and tests one of them in a single line; both exist only inside the if.

  • var e data.Entitlements — declare an empty struct of that type. Go zeroes it: two zero numbers and two false booleans.

  • if json.Unmarshal(b, &e) == nilUnmarshal reads JSON bytes and fills in a Go value; &e passes the address of our struct so it can be written into. Its return value is an error, and in Go nil error means success. So this line reads: if decoding worked, return what we decoded. If the cached bytes are corrupt or from an older shape of the struct, we fall through and resolve properly. That is a deliberate belt-and-braces: bad cached data can never be worse than a slow request.

  • tier := data.TierFree — start pessimistic. Every path that does not explicitly find a paying subscription leaves this alone.

  • sub, err := app.q.GetSubscription(ctx, userID) — the sqlc-generated function from Chapter 15, running SELECT * FROM subscriptions WHERE user_id = $1. If there is no row, it returns pgx.ErrNoRows.

  • if err == nil { switch sub.Status { ... } } — note what this does not do. It does not distinguish “no such row” from “the database is on fire”. Both leave err non-nil, and both leave the tier at free. That single if is the whole fail-to-free policy, and the trailing comment is there so a future reader knows it is a decision and not an oversight.

  • case "active", "trialing", "past_due": — one case with three values means “any of these”. A Go switch does not fall through to the next case, so no break is needed. Any other status — canceled, unpaid, incomplete, anything Stripe invents next year — matches no case and the tier stays free.

  • tier = data.Tier(sub.Tier)sub.Tier is a plain string (it came out of a text column). data.Tier is a named type built on string, from Chapter 15. Go will not let one slide into the other, so the conversion is written out. That strictness is precisely why a status can never be accidentally used where a tier belongs.

  • e := data.Plans[tier] — a map lookup: Plans maps a Tier to an Entitlements. This is the line where the rulebook you wrote in Chapter 15 finally gets read.

  • json.Marshal(e) then app.cache.Set(ctx, entKey(userID), b, 60*time.Second) — turn the struct into bytes and store it under the user’s key for sixty seconds. Set is best-effort and returns nothing; a failed cache write costs one extra database query next time, and nothing else.

  • invalidateEntitlements(r *http.Request, userID int64) — the function Chapter 16 called twice and left empty. Now it deletes the key, which is why a paid upgrade takes effect on the very next request instead of within the minute.

Note

The two functions take different first arguments — one a context.Context, one an *http.Request. That is inherited from the call sites: Chapter 16 already wrote app.invalidateEntitlements(r, user.ID) in two places in webhooks.go, and this book does not change code it has already printed. If you were designing from scratch you would take a context.Context in both and keep the whole package consistent.

Note

A map lookup for a key that is not in the map returns the value type’s zero value rather than an error. So if subscriptions ever held a tier string that Plans has no row for — "team", say, typed in by hand — data.Plans[tier] yields Entitlements{0, 0, false, false}: no tasks, no requests per minute, no features. That is a harsher outcome than free. It cannot happen through the normal path, because Chapter 16’s applySubscription only ever writes free, pro or business, but it is worth knowing before you edit that table by hand. Exercise 2 has you do exactly that, correctly.

Step 3 — The 402 that sells

One more helper in the error file, joining the seven *Response functions from Chapter 8 (CRUD done properly) and Chapter 11 (Stateful tokens).

// cmd/api/errors.go — add at the end, under a "ch. 17: entitlements" comment
func (app *application) upgradeRequiredResponse(w http.ResponseWriter, r *http.Request,
    message string) {
    app.errorResponse(w, r, http.StatusPaymentRequired, map[string]string{
        "code":    "upgrade_required",
        "message": message,
        "upgrade": "/v1/billing/checkout",
    })
}

What this code says, line by line

  • http.StatusPaymentRequired is Go’s name for 402. Using the constant rather than the bare number is the habit the whole errors.go file follows: the name is checkable by the compiler and readable by a human.
  • The third argument to errorResponse is typed any, which is why a map can go where every other helper passes a string. Chapter 8 made it any on purpose so that failedValidationResponse could pass a map of field errors; that decision pays off again here.
  • map[string]string{...} — a small lookup table written inline, three keys to three strings.
  • "code": "upgrade_required" — the stable part. Client code branches on this. Never on the message: you will reword messages, and rewording a message should not break an app.
  • message is the only parameter, so each call site says which limit was hit in words a human reads.
  • "upgrade": "/v1/billing/checkout" — the path the client should send the user to. A hardcoded string here is deliberate: there is exactly one checkout endpoint, and putting its address in the response means the mobile app never has to know it in advance.

The resulting body, exactly as writeJSON will render it (map keys come out alphabetically, which is why code leads):

{"error":{"code":"upgrade_required","message":"...","upgrade":"/v1/billing/checkout"}}

Everything the API has ever emitted has been shaped {"error": ...}. This is still that shape; the value is an object instead of a sentence. Clients that only ever printed error still work, and clients that know about code get more.

What you should see

go build ./...

Nothing again. Nothing calls the new helper yet — Go permits unused functions, only unused imports and unused local variables are errors.


Part B — Four gates that ask it

Step 4 — Close Chapter 14’s seam

Chapter 14 (Rate limiting) ended with a single hardcoded integer and a comment naming this chapter. Here is the line, as you left it:

// cmd/api/middleware.go — the line as Chapter 14 left it
        limit := 60 // req/min — replaced by entitlements in ch. 17

Replace it with two lines:

// cmd/api/middleware.go — replaces that one line, inside rateLimitUser
        ent := app.resolveEntitlements(r.Context(), user.ID)
        limit := ent.RatePerMinute

Because a two-line change in the middle of a function is exactly the kind of edit that is easy to land in the wrong place, here is the whole function afterwards:

// cmd/api/middleware.go — rateLimitUser, complete, after the change
func (app *application) rateLimitUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !app.config.limiter.enabled || app.cache == nil {
            next.ServeHTTP(w, r)
            return
        }
        user := app.contextGetUser(r)

        ent := app.resolveEntitlements(r.Context(), user.ID)
        limit := ent.RatePerMinute
        ok, err := app.cache.Allow(r.Context(),
            "user:"+itoa(user.ID), limit, time.Minute)
        if err != nil {
            app.logger.Warn("rate limiter unavailable", "error", err)
        }
        if !ok {
            app.rateLimitExceededResponse(w, r)
            return
        }
        next.ServeHTTP(w, r)
    })
}

What this code says

  • r.Context() is the request’s context, which the resolver wants as its first argument. If the client hangs up mid-request, that context is cancelled and the Postgres query inside the resolver is cancelled with it.
  • user.ID comes from app.contextGetUser(r) two lines above, which is safe here because requireAuthenticatedUser is registered before this middleware in the same route group.
  • ent.RatePerMinute reads one field off the struct. Free is 60, Pro is 300, Business is 1000 — Chapter 15’s Plans map, now doing something.
  • Nothing else in the function changed. That is what a well-placed seam looks like: the future change had exactly one address.
Remember this

One integer with a comment naming the chapter that will replace it is a legitimate design artefact, not a to-do. Leaving the seam visible is how a codebase tells you where it expects to grow.

What you should see

go build ./...

Nothing. You can also prove the seam is closed by grepping for the number that used to be there:

grep -n "limit :=" cmd/api/middleware.go

One line, and it now reads limit := ent.RatePerMinute.

Step 5 — Feature gate: priorities

Priorities are a paid feature: on the free plan every task is "none". Two handlers can set a priority, and they hold it in two different Go types, so the gate is written twice — differently.

In createTaskHandler, priority is a plain string that has already been defaulted to "none" if the client omitted it. Add this after the validation block and after user := app.contextGetUser(r):

// cmd/api/tasks.go — inside createTaskHandler, after user := app.contextGetUser(r)
    // Feature gate (ch. 17): priorities are a paid feature. Here priority
    // is a plain string, so "none" is the free-tier-safe value.
    ent := app.resolveEntitlements(r.Context(), user.ID)
    if input.Priority != "none" && !ent.Priorities {
        app.upgradeRequiredResponse(w, r, "task priorities are available on Pro and Business plans")
        return
    }

In updateTaskHandler, every input field is a *string — a pointer — because Chapter 8 needed nil to mean “the client did not send this field, keep the current value”. So the gate has one extra clause. Add it after the validation block:

// cmd/api/tasks.go — inside updateTaskHandler, after the v.Valid() check
    ent := app.resolveEntitlements(r.Context(), user.ID)
    if input.Priority != nil && *input.Priority != "none" && !ent.Priorities {
        app.upgradeRequiredResponse(w, r, "task priorities are available on Pro and Business plans")
        return
    }

What this code says, line by line

  • input.Priority != nil — “did the client send a priority at all?” A PATCH that only changes the title must not be blocked because of a field it never mentioned.
  • *input.Priority — the * dereferences the pointer: “the string at that address”. Writing *input.Priority when input.Priority is nil crashes the program, which is exactly why the nil check comes first and why Go’s && stops evaluating as soon as a clause is false.
  • In the create handler there is no pointer and therefore no nil check — the field is a string, which always has a value. Writing *input.Priority there does not compile.
  • != "none" in both — setting priority to "none" is not using the feature, so a free user can send it explicitly without being sold anything.
  • !ent.Priorities — the ! reads “not”. The condition as a whole is “they are trying to use the feature, and their plan does not include it”.
  • return immediately after the response. Forgetting it means the handler carries on and writes a second response body onto the same connection.
Common mistake

You’ll see: ./cmd/api/tasks.go:62:26: invalid operation: cannot indirect input.Priority (variable of type string) It means: you used the update handler’s pointer version of the gate in the create handler. “Indirect” is the compiler’s word for the * dereference. Fix: in createTaskHandler, drop both the input.Priority != nil && clause and the *.

Note

The original edition prints only the pointer version and adds a parenthesis: “(In createTaskHandler, priority is a plain string — adjust the nil-check accordingly.)” Both forms are printed here in full, because “adjust accordingly” is advice you can only follow once you already know the answer.

Step 6 — Feature gate: search

Search is the other paid feature. listTasksHandler reads its query-string parameters near the top; the gate goes after the validation block and after the user is fetched from the context.

// cmd/api/tasks.go — inside listTasksHandler, after user := app.contextGetUser(r)
    ent := app.resolveEntitlements(r.Context(), user.ID)
    if search != "" && !ent.Search {
        app.upgradeRequiredResponse(w, r, "search is available on Pro and Business plans")
        return
    }

What this code says

  • search is the value of ?search= as read by app.readString(qs, "search", "") further up. Empty string means the client did not ask for a search, so a free user listing their tasks is untouched.
  • The gate sits before the cache lookup and before the database query. A blocked request should cost you nothing — no Dragonfly round trip, no Postgres query. Order matters for cost, not only for correctness.

Step 7 — The quota

The last gate, in createTaskHandler, immediately before the insert.

// cmd/api/tasks.go — inside createTaskHandler, after the priority gate
    // The quota. -1 means unlimited. The count-then-insert race can
    // overshoot by a handful under concurrency; that is accepted and
    // documented for a soft business limit (ch. 17).
    if ent.MaxActiveTasks != -1 {
        n, err := app.q.CountActiveTasks(r.Context(), user.ID)
        if err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }
        if n >= int64(ent.MaxActiveTasks) {
            app.upgradeRequiredResponse(w, r,
                "active task limit reached for your plan")
            return
        }
    }

What this code says, line by line

  • if ent.MaxActiveTasks != -1 — Chapter 15 chose -1 as the sentinel for “unlimited”. Business users skip the count entirely, which means the unlimited plan is also the cheapest one to serve. A sentinel value is a magic number, and it earns its place only because it is written down in one place — the comment on the Entitlements struct.
  • app.q.CountActiveTasks(r.Context(), user.ID) — the sqlc function added back in Chapter 12 (Ownership: making it multi-tenant) and used by nothing since. Its SQL is SELECT count(*) FROM tasks WHERE user_id = $1 AND status <> 'archived'. Archived tasks do not count against the quota, which is a product decision hiding inside a query: archiving is the free user’s escape hatch.
  • if err != nil { app.serverErrorResponse(...) } — note the asymmetry with the resolver. A failed count is a 500, not a silent pass. The resolver fails to free because it can define a safe answer; a count has no safe answer, since guessing low lets the quota be bypassed and guessing high locks out a paying customer.
  • n >= int64(ent.MaxActiveTasks)CountActiveTasks returns int64 (Postgres count(*) is a bigint) and MaxActiveTasks is an int. Go compares only matching types, so the conversion is explicit. >= and not >: at exactly 100 of 100 the next create is the one over the line.
  • ent is reused from the priority gate above, so this costs no second resolution. That is the practical payoff of resolving once per handler.

What you should see

go build ./...

Nothing. Four gates are now wired to one resolver.


Part C — The two handlers, whole

createTaskHandler was first printed in Chapter 8 and has been amended four times since: Chapter 12 added UserID, Chapter 13 added cache invalidation, and this chapter added two gates. listTasksHandler has a similar history. Assembling a function from five chapters’ worth of “add this after that” is a real risk of getting one block in the wrong place, so both are printed here in full. Compare yours against them line by line.

Step 8 — The finished write path

// cmd/api/tasks.go — createTaskHandler, complete as of this chapter
func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) {
    // An anonymous struct declared right where it's used: this is the
    // request's SHAPE. The `json:"..."` tags map JSON keys to fields;
    // anything a client sends outside these four keys is rejected by
    // readJSON's DisallowUnknownFields.
    var input struct {
        Title    string     `json:"title"`
        Notes    string     `json:"notes"`
        Priority string     `json:"priority"`
        DueAt    *time.Time `json:"due_at"` // pointer: nil = not provided
    }

    // STEP 1 — decode. Any failure here is the client's fault: 400.
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    if input.Priority == "" {
        input.Priority = "none" // sensible default beats a required field
    }

    v := validator.New()
    data.ValidateTask(v, input.Title, input.Notes, "open", input.Priority, input.DueAt)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user := app.contextGetUser(r)

    // Feature gate (ch. 17): priorities are a paid feature. Here priority
    // is a plain string, so "none" is the free-tier-safe value.
    ent := app.resolveEntitlements(r.Context(), user.ID)
    if input.Priority != "none" && !ent.Priorities {
        app.upgradeRequiredResponse(w, r, "task priorities are available on Pro and Business plans")
        return
    }

    // The quota. -1 means unlimited. The count-then-insert race can
    // overshoot by a handful under concurrency; that is accepted and
    // documented for a soft business limit (ch. 17).
    if ent.MaxActiveTasks != -1 {
        n, err := app.q.CountActiveTasks(r.Context(), user.ID)
        if err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }
        if n >= int64(ent.MaxActiveTasks) {
            app.upgradeRequiredResponse(w, r,
                "active task limit reached for your plan")
            return
        }
    }

    // STEP 3 — the database, through sqlc's typed function. r.Context()
    // ties the query's lifetime to the request: client disconnects,
    // query cancels. A failure here is OUR fault (or Postgres'): 500.
    task, err := app.q.CreateTask(r.Context(), db.CreateTaskParams{
        UserID:   user.ID,
        Title:    input.Title,
        Notes:    input.Notes,
        Priority: input.Priority,
        DueAt:    input.DueAt,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    if app.cache != nil {
        app.cache.InvalidateUser(r.Context(), user.ID)
    }

    // STEP 4 — respond: 201 Created, a Location header pointing at the
    // new resource (REST manners), and the row itself in the envelope.
    headers := make(http.Header)
    headers.Set("Location", "/v1/tasks/"+itoa(task.ID))
    app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers)
}

Four concerns, in one function, in this order. Read the handler top to bottom and name them:

Order Concern Chapter Why here and not elsewhere
1 Shape and validity 8 Reject nonsense before spending anything on it
2 Tenancy (user.ID) 12 Every row belongs to somebody; nothing runs anonymously
3 Entitlement + quota 17 Cheap checks before the expensive write
4 Cache invalidation 13 After the write succeeds, never before

The ordering is not decoration. Validation before entitlement means a malformed request gets a clear 422 rather than a confusing 402. Entitlement before insert means a blocked request costs one cached lookup instead of a write. Invalidation after insert means a failed write never throws away a good cache.

Step 9 — The finished read path

// cmd/api/tasks.go — listTasksHandler, complete as of this chapter
func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) {
    v := validator.New()
    qs := r.URL.Query()

    status := app.readString(qs, "status", "")
    priority := app.readString(qs, "priority", "")
    search := app.readString(qs, "search", "")

    f := data.Filters{
        Page:     app.readInt(qs, "page", 1, v),
        PageSize: app.readInt(qs, "page_size", 20, v),
        Sort:     app.readString(qs, "sort", "-created_at"),
        SortSafelist: []string{"created_at", "-created_at",
            "due_at", "-due_at", "priority"},
    }
    if status != "" {
        v.Check(validator.PermittedValue(status, data.Statuses...), "status", "invalid")
    }
    if priority != "" {
        v.Check(validator.PermittedValue(priority, data.Priorities...), "priority", "invalid")
    }
    data.ValidateFilters(v, f)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user := app.contextGetUser(r)

    ent := app.resolveEntitlements(r.Context(), user.ID)
    if search != "" && !ent.Search {
        app.upgradeRequiredResponse(w, r, "search is available on Pro and Business plans")
        return
    }

    // The fingerprint captures everything that changes the RESULT: two
    // requests with identical filters/sort/page share a cache entry;
    // any difference produces a different key.
    fingerprint := fmt.Sprintf("%s|%s|%s|%s|%d|%d",
        status, priority, search, f.Sort, f.Page, f.PageSize)

    var key string
    if app.cache != nil {
        if k, err := app.cache.ListKey(r.Context(), user.ID, fingerprint); err == nil {
            key = k
            if b, ok := app.cache.Get(r.Context(), key); ok {
                w.Header().Set("Content-Type", "application/json")
                w.Header().Set("X-Cache", "HIT")
                w.Write(b)
                return
            }
        }
    }

    // singleflight.Do: if ten goroutines arrive here with the same key
    // at once, ONE runs this function; the other nine wait and share
    // its return value. Ten cache misses become one database query.
    body, err, _ := app.sfGroup.Do(key+fingerprint, func() (any, error) {
        rows, err := app.q.ListTasks(r.Context(), db.ListTasksParams{
            UserID:     user.ID,
            Status:     nilIfEmpty(status),
            Priority:   nilIfEmpty(priority),
            Search:     nilIfEmpty(search),
            Sort:       f.Sort,
            PageLimit:  f.Limit(),
            PageOffset: f.Offset(),
        })
        if err != nil {
            return nil, err
        }

        // Each generated row is {Task, TotalCount} thanks to sqlc.embed —
        // peel the tasks out, and grab the count (identical on every row,
        // courtesy of the window function).
        tasks := make([]db.Task, 0, len(rows)) // 0-length, NOT nil: encodes as []
        var total int64
        for _, row := range rows {
            tasks = append(tasks, row.Task)
            total = row.TotalCount
        }

        md := data.CalculateMetadata(total, f.Page, f.PageSize)

        js, err := json.Marshal(envelope{"tasks": tasks, "metadata": md})
        return js, err
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    js := body.([]byte)
    if app.cache != nil && key != "" {
        app.cache.Set(r.Context(), key, js, 60*time.Second)
    }

    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Cache", "MISS")
    w.Write(js)
}

Only one block is new since Chapter 13 (Caching with DragonflyDB) printed this handler: the four lines of search gate. Everything else you have seen before.

Note

Chapter 18 (Prometheus) adds exactly two more lines to this function — cacheOps.WithLabelValues("hit").Inc() and ...("miss").Inc() — at the two places that set the X-Cache header. They are not here because the counter they touch does not exist yet. This is the handler in its Chapter 17 state, which is what should be on your disk right now.


Part D — Telling the client what it has

Step 10 — A self-service plan endpoint

Every SaaS needs its client able to ask “what am I?” — so the app can grey out the priority picker, show “97 of 100 tasks used”, and put the upgrade button in the right place. One handler.

// cmd/api/billing.go — add below createCheckoutHandler
func (app *application) showPlanHandler(w http.ResponseWriter, r *http.Request) {
    user := app.contextGetUser(r)
    ent := app.resolveEntitlements(r.Context(), user.ID)

    tier := data.TierFree
    if sub, err := app.q.GetSubscription(r.Context(), user.ID); err == nil {
        switch sub.Status {
        case "active", "trialing", "past_due":
            tier = data.Tier(sub.Tier)
        }
    }
    app.writeJSON(w, http.StatusOK, envelope{
        "tier": tier, "entitlements": ent,
    }, nil)
}

What this code says

  • The entitlements come from the resolver, so this endpoint reports exactly what the gates will enforce — including a cached answer. If the client sees Search: false, search will refuse. The two can never disagree, which is the whole point.
  • The tier is resolved a second time, directly from Postgres, because Entitlements does not carry its own tier name. It is a set of capabilities, not a label. This handler wants both, and the second lookup is the price.
  • The same three-status switch appears here and in the resolver. Two copies of one rule is a real smell, and worth noticing: if you ever change the grace set, grep -n "trialing" cmd/api/ finds both sites. Exercise 3 in Chapter 13’s style would be to extract it; this book leaves it as the original wrote it.
Note

The Entitlements struct has no json:"..." tags, so this endpoint renders its fields under their Go names — MaxActiveTasks, not max_active_tasks. Every other body in taskd is snake_case. It is the original code and it stays, but if you were shipping this to third-party developers you would add the four tags for consistency, and you would do it before anyone integrated against it.

Now the route. Chapter 15 mounted a /billing sub-router; the new route belongs inside it:

// cmd/api/routes.go — add inside the existing r.Route("/billing", ...) block
        r.Get("/plan", app.showPlanHandler) // ch. 17

In context, the billing sub-router now reads:

// cmd/api/routes.go — the billing sub-router, for orientation
        r.Route("/billing", func(r chi.Router) {
            r.Post("/checkout", app.createCheckoutHandler) // ch. 15
            r.Post("/portal", app.createPortalHandler)     // ch. 16
            r.Get("/plan", app.showPlanHandler)            // ch. 17
        })
Warning

The original edition writes this as r.Get("/billing/plan", app.showPlanHandler) registered beside the sub-router rather than inside it. chi treats r.Route as a mount, and registering sibling patterns under a prefix that is already mounted is fragile at best and a startup panic at worst. Chapter 15 flagged this in advance; the form printed above is the consistent one. Appendix F holds the complete final routes.go if you want to check where a route really ended up.

What you should see

go build ./...

Nothing. Restart the server with make run/api.

Step 11 — Run the whole loop

This is the payoff, and it is worth doing slowly. You need three terminals: the server (make run/api), the Stripe listener (stripe listen --forward-to localhost:4000/v1/stripe/webhook, from Chapter 16), and one for curl.

Register a brand-new user so nothing is cached and no subscription exists:

curl -s -d '{"name":"Ravi","email":"ravi@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users

TOKEN=$(curl -s -d '{"email":"ravi@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

1. Ask what plan you are on.

curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/billing/plan

You should see, on one line:

{"entitlements":{"MaxActiveTasks":100,"RatePerMinute":60,"Priorities":false,"Search":false},"tier":"free"}

2. Try to use a paid feature.

curl -i -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"ship the thing","priority":"high"}' localhost:4000/v1/tasks

The first line of the response is HTTP/1.1 402 Payment Required and the body is the upgrade object from Step 3, with the priorities message.

3. Pay. Request a checkout URL, open it in a browser, and pay with Stripe’s test card 4242 4242 4242 4242, any future expiry, any CVC:

curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"tier":"pro"}' localhost:4000/v1/billing/checkout

The reply is a JSON body with a checkout_url key whose value starts https://checkout.stripe.com/. Paste that into a browser and complete the payment.

4. Watch the webhook land. The stripe listen terminal prints a line per forwarded event, each ending in the status your server returned — [200] for the ones it handled. Your server’s own log prints a request line for POST /v1/stripe/webhook.

5. Ask again — immediately.

curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/billing/plan

"tier":"pro", "Priorities":true, "Search":true, "MaxActiveTasks":10000, "RatePerMinute":300. Not in sixty seconds. Now — because the webhook deleted the cache key.

6. Repeat the exact request that was refused.

curl -i -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"ship the thing","priority":"high"}' localhost:4000/v1/tasks

HTTP/1.1 201 Created, a Location header, and a task object whose "priority" is "high".

You have built, end to end, the thing people mean by “SaaS”: features that respond to money, within a second, with no human in the loop.


7. Checkpoint: prove it works

Five drills. The server is running (make run/api) and $TOKEN holds a valid token for a user with no subscription.

Drill 1 — It compiles

go build ./...

No output.

Drill 2 — The plan endpoint answers

curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/billing/plan
{"entitlements":{"MaxActiveTasks":100,"RatePerMinute":60,"Priorities":false,"Search":false},"tier":"free"}

Drill 3 — Both feature gates refuse

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"gate test","priority":"low"}' localhost:4000/v1/tasks

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  "localhost:4000/v1/tasks?search=anything"

Two lines, both 402. (-o /dev/null throws the body away; -w "%{http_code}\n" prints only the status.)

Drill 4 — Free-tier requests still work

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"a perfectly ordinary task"}' localhost:4000/v1/tasks

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  localhost:4000/v1/tasks

201 then 200. The gates block the two paid features and nothing else.

Drill 5 — The cache really holds the answer

Find your user’s ID, then read the key straight out of Dragonfly:

docker compose exec db psql -U taskd -d taskd -c "SELECT id, email FROM users;"

docker compose exec cache redis-cli GET ent:1
docker compose exec cache redis-cli TTL ent:1

Substitute your own ID for the 1. GET prints the JSON the resolver stored — {"MaxActiveTasks":100,"RatePerMinute":60,"Priorities":false,"Search":false} for a free user. TTL prints the seconds remaining, a number between 1 and 60. Run TTL again a few seconds later and watch it fall.

If you got something else

You got Cause Fix
401 with you must be authenticated to access this resource $TOKEN is empty or expired (tokens last 24 hours) re-run the login command that sets TOKEN
404 on /v1/billing/plan the route was added outside the /billing sub-router, or the server was not restarted put r.Get("/plan", ...) inside r.Route("/billing", ...); restart
201 where you expected 402 either the gate landed after the insert, or this user’s entitlements were cached while they were on Pro check the order in the printed handler; docker compose exec cache redis-cli DEL ent:<id>
TTL prints -2 the key does not exist — nothing has resolved this user’s entitlements since the last restart or flush make one authenticated request first, then read it again
(nil) from GET ent:1 same as above, or you have the wrong user ID re-check the SELECT id, email FROM users output

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
./cmd/api/entitlements.go:49:13: app.cache.Delete undefined (type *cache.Cache has no field or method Delete) the cache package has no Delete yet do Step 1
./cmd/api/tasks.go:62:26: invalid operation: cannot indirect input.Priority (variable of type string) you used the update handler’s pointer gate inside the create handler drop the nil check and the *; see Step 5
./cmd/api/tasks.go:52:7: app.upgradeRequiredResponse undefined (type *application has no field or method upgradeRequiredResponse) the 402 helper is missing do Step 3
./cmd/api/entitlements.go:47:22: method application.invalidateEntitlements already declared at ./cmd/api/entitlements_old.go:8:22 you added the new file alongside Chapter 16’s stub instead of replacing it delete the old file; Step 2 replaces entitlements.go whole
undefined: ent you put a gate above the ent := app.resolveEntitlements(...) line that produces it move the gate below it, or move the resolution up
undefined: itoa helpers.go never got the two-line wrapper from Chapter 8 add func itoa(i int64) string { return strconv.FormatInt(i, 10) }
Upgrade paid for, but features stay off for about a minute the webhook fired but invalidateEntitlements did nothing you are still on Chapter 16’s stub; Step 2 replaces it
Upgrade paid for, features never turn on the webhook never arrived is stripe listen --forward-to localhost:4000/v1/stripe/webhook running? Chapter 16’s Step 5
Everything 402s, including for a Business user subscriptions.tier holds a string that is not a key in Plans, so the map returns the zero value SELECT tier FROM subscriptions; — it must be free, pro or business
The rate limiter allows nobody same cause: zero-value entitlements mean RatePerMinute: 0 as above
panic: missing user value in request context, logged as a 500 a handler calling contextGetUser is mounted outside the authenticated group /v1/billing/plan belongs inside the group that uses requireAuthenticatedUser

9. Pitfalls

Resolving entitlements in authenticate. It is tempting: one lookup per request, stashed in the context, every gate reads it for free. Do not. authenticate runs globally — on /v1/healthcheck, on /metrics, on every anonymous request that gates nothing — so you would be adding a billing lookup to traffic that has no interest in billing. Worse, you would couple authentication to billing availability: a wobble in the entitlement path would become a wobble in logging in. Resolve at the gates. The cache makes repeated resolution almost free, and the rate limiter’s call already sits inside the authenticated group, where the tax is proportionate.

Downgrade stranding. A Business user with 5,000 tasks downgrades to free, where the limit is 100. What now? Delete 4,900 tasks? Refuse the downgrade? Lock the account? This is a policy question, not a code question. We chose the kind default: existing data is untouched, and new creates are blocked until they are back under the limit. Read the quota code again and you will see that is exactly what it does — it counts before inserting and never touches an existing row. Whatever you choose, choose it in a product decision log, because support tickets will ask and the answer must not depend on who replies.

past_due generosity has a clock, and the clock is not in your code. We grant access during dunning. Stripe eventually gives up and transitions the subscription to canceled or unpaid according to your dashboard’s retry settings, and that transition arrives as a webhook, flips the row, and drops the user to free through the path you already built. So the length of your grace period is configured in the Stripe dashboard, not in Go. Go and look at those settings; a colleague reading your code will never find them.

Gating in the client only. A web app that hides the priority picker from free users has improved its user experience and done nothing whatsoever for its revenue. The API is the enforcement boundary. Anyone with curl — which, as a reader of this book, now includes you — bypasses a frontend without effort.

Warning

Every check that protects money or data must exist on the server. A check in the client is a convenience for honest users, and honest users are not the ones you are defending against.

Caching the answer without a way to invalidate it. A 60-second TTL alone would be defensible. What is not defensible is a long TTL with no invalidation, because then the length of your worst customer moment is a number somebody picked once. If you ever raise the TTL here, check that invalidateEntitlements is still called on every path that can change a tier.

Assuming the quota query means what its name says. CountActiveTasks excludes archived tasks. That is a real product rule living in a WHERE clause. If somebody later adds a deleted status and forgets this query, quotas quietly get more generous. Rules that live in SQL need the same review as rules that live in Go.


10. Check yourself — quiz

  1. In one sentence each: what is a tier, and what is an entitlement? Why does taskd need both words?
  2. Name the three subscription statuses that grant a paid tier. Why is past_due among them, and what eventually removes a user from it?
  3. The resolver caches for 60 seconds and the webhook deletes the key. Why is either one alone not good enough?
  4. Postgres is unreachable. A Pro customer sends GET /v1/tasks?search=report. What do they get, and why is that the chosen answer rather than a 500?
  5. Two of the same user’s creates arrive at the same instant while they hold 99 of 100 tasks. How many tasks can exist afterwards? Why is that accepted here, and what would have to be true for it not to be?
  6. Why 402 rather than 403? Answer in terms of what a client program should do next.
  7. Chapter 14 left limit := 60 in rateLimitUser. What replaced it, and how many other lines of that function changed?
  8. Read this line from updateTaskHandler: if input.Priority != nil && *input.Priority != "none" && !ent.Priorities. Why does createTaskHandler’s version of the same gate have only two clauses?
Answers
  1. A tier is a named package you sell — Free, Pro, Business — and it is a label. An entitlement is what a specific user may do right now: the four fields of the Entitlements struct. You need both because handlers should never ask “which tier is this?” (a question that invites if tier == pro || ... everywhere); they ask “may this user do X?”, and only one function knows how the label becomes the answer.

  2. active, trialing, past_due. past_due is included because a failed renewal charge is usually an expired card rather than a decision to leave, and cutting a customer off at the first decline turns a payment problem into a cancellation. They leave it when Stripe finishes dunning and transitions them to canceled or unpaid, which arrives as a webhook and drops them to free through the existing path. How long that takes is a Stripe dashboard setting, not code.

  3. The TTL alone means a customer who just paid can sit on the free plan for up to sixty seconds — at the exact moment they are deciding whether to trust you. Invalidation alone means that if a single DEL is ever lost (cache blip, dropped connection), the stale answer sticks around indefinitely with nothing to correct it. Together, invalidation makes the common case instant and the TTL puts a ceiling on the uncommon one.

  4. They get the free-tier answer, so the search is refused with a 402. That is the fail-to-free policy: GetSubscription returns an error, err == nil is false, the tier stays TierFree. A 500 would be worse because a billing-lookup outage would become a total outage for every customer, paying or not — degraded features beat a locked door. Note the honest cost: for that window, a paying customer is told to upgrade, which is why the outage still needs fixing fast.

  5. Up to 101. Both requests count 99, both conclude they are under the limit, both insert. It is accepted because this is a soft limit on a business allowance: nothing breaks, no invariant is violated, and nobody is defrauded by one extra task. It would not be acceptable if the count guarded something where the overshoot is money or safety — a payment ledger’s balance, seats in a room — in which case the fix is pg_advisory_xact_lock(user_id) around count-and-insert.

  6. 403 Forbidden means “you are not allowed, and asking again will not help” — a client should stop. 402 Payment Required means “your plan does not include this”, which the client can fix by sending the user to the upgrade path in the same response body. One response ends the interaction, the other continues it. The status code is an instruction to a program, so it should say which of those two things is true.

  7. limit := 60 became two lines: ent := app.resolveEntitlements(r.Context(), user.ID) and limit := ent.RatePerMinute. No other line of rateLimitUser changed. That is the point of a seam — the future change has exactly one address.

  8. Because in createTaskHandler the field is a plain string, not a *string. readJSON fills it with "" if the client omitted it, and the handler defaults "" to "none" a few lines later, so there is always a value and there is nothing to nil-check. In updateTaskHandler every field is a pointer, because nil is how a PATCH says “I did not send this field”; blocking a title-only PATCH because of a priority the client never mentioned would be a bug, and dereferencing a nil pointer with * would crash the request.


11. Practice

Exercise 1 — Feel the quota without typing 100 curls (easy)

The free quota is 100 active tasks. Proving it by hand is 100 requests and a lost evening. Lower the limit, prove the 402, then put it back.

Answer

Step 1 — lower the limit.

// internal/data/plans.go — TEMPORARY: restore 100 when you are done
var Plans = map[Tier]Entitlements{
    TierFree:     {MaxActiveTasks: 3, RatePerMinute: 60, Priorities: false, Search: false},
    TierPro:      {MaxActiveTasks: 10_000, RatePerMinute: 300, Priorities: true, Search: true},
    TierBusiness: {MaxActiveTasks: -1, RatePerMinute: 1000, Priorities: true, Search: true},
}

Step 2 — restart, and clear the stale cached entitlements. This is the step people forget. The Plans map lives in the binary, but the answer lives in Dragonfly for up to sixty seconds, so a restart alone does not take effect:

docker compose exec cache redis-cli FLUSHALL

FLUSHALL deletes every key and prints OK. It is a laptop command; never type it against a production cache.

Step 3 — register a brand-new user (so their task count starts at zero) and get a token, as in Step 11. Then:

for i in 1 2 3 4; do
  curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"title\":\"quota test $i\"}" localhost:4000/v1/tasks
done

Four lines: 201, 201, 201, 402. The fourth is refused because the count reached 3.

Step 4 — capture the body of the refusal.

curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"one too many"}' localhost:4000/v1/tasks
{"error":{"code":"upgrade_required","message":"active task limit reached for your plan","upgrade":"/v1/billing/checkout"}}

Step 5 — prove the archive escape hatch. Archive one of the three tasks (PATCH its status to "archived") and create again: it succeeds, because CountActiveTasks excludes archived rows.

Step 6 — put MaxActiveTasks: 100 back, restart, and FLUSHALL once more.

Exercise 2 — Prove the resolver reads your database, not Stripe (medium)

Give yourself a Pro subscription by hand, with no payment and no webhook, and watch the paid features switch on. This is worth doing because it separates two things beginners tend to fuse: Stripe is the source of truth about money, but your subscriptions table is what your code actually reads.

Answer

Open a database shell:

make db/psql

Find your user, then insert a subscription row (substitute your own ID for the 1):

SELECT id, email FROM users;

INSERT INTO subscriptions
  (user_id, stripe_subscription_id, tier, status, current_period_end)
VALUES
  (1, 'sub_manual_test', 'pro', 'active', now() + interval '30 days');

Postgres replies INSERT 0 1. Type \q to leave psql.

The resolver may still be holding a cached free answer, so clear that one key:

docker compose exec cache redis-cli DEL ent:1

It prints 1 — the number of keys removed. (0 means nothing was cached, which is equally fine.) Now ask again:

curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/billing/plan

curl -i -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"now with priority","priority":"high"}' localhost:4000/v1/tasks

The plan endpoint reports "tier":"pro" with "Priorities":true, and the create returns HTTP/1.1 201 Created with the priority set. Nothing was paid and Stripe was never contacted.

Then try the same trick with a status outside the grace set. Back in psql:

UPDATE subscriptions SET status = 'canceled' WHERE user_id = 1;

DEL ent:1 again, and the priority create returns 402. The three-value switch is the entire difference.

Clean up, so the rest of the book runs against an honest database:

DELETE FROM subscriptions WHERE stripe_subscription_id = 'sub_manual_test';

and docker compose exec cache redis-cli DEL ent:1 one more time.

What this proves. The gates read one table. Stripe’s job is to keep that table true, via the webhook. If you ever debug “customer paid but has no features”, this is the order to check: is there a row, does its status sit in the grace set, is its tier a key in Plans, and is a stale entitlement cached.

Exercise 3 — Add a fourth entitlement and gate an endpoint on it (harder)

Business customers get outgoing webhooks — taskd calling their server when a task changes. You are not building that. You are building the entitlement, the gate and the endpoint that refuses, which is the part this chapter is about.

Answer

Step 1 — the entitlement. One field and three values:

// internal/data/plans.go — replaces the struct and the map
// Entitlements: -1 means unlimited.
type Entitlements struct {
    MaxActiveTasks int
    RatePerMinute  int
    Priorities     bool // may set priority != "none"
    Search         bool // may use ?search=
    Webhooks       bool // may register outgoing webhooks
}

var Plans = map[Tier]Entitlements{
    TierFree:     {MaxActiveTasks: 100, RatePerMinute: 60, Priorities: false, Search: false, Webhooks: false},
    TierPro:      {MaxActiveTasks: 10_000, RatePerMinute: 300, Priorities: true, Search: true, Webhooks: false},
    TierBusiness: {MaxActiveTasks: -1, RatePerMinute: 1000, Priorities: true, Search: true, Webhooks: true},
}

Step 2 — the handler.

// cmd/api/billing.go — add below showPlanHandler
func (app *application) listOutgoingWebhooksHandler(w http.ResponseWriter, r *http.Request) {
    user := app.contextGetUser(r)

    ent := app.resolveEntitlements(r.Context(), user.ID)
    if !ent.Webhooks {
        app.upgradeRequiredResponse(w, r, "outgoing webhooks are available on the Business plan")
        return
    }

    app.writeJSON(w, http.StatusOK, envelope{"webhooks": []string{}}, nil)
}

Step 3 — the route, inside the authenticated group beside the others:

// cmd/api/routes.go — inside the r.Group that uses requireAuthenticatedUser
        r.Get("/webhooks", app.listOutgoingWebhooksHandler)

Verify. Rebuild, restart, and — because you changed the shape of the cached struct — docker compose exec cache redis-cli FLUSHALL. Then:

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  localhost:4000/v1/webhooks

402 on a free or Pro user. Give yourself a Business subscription with Exercise 2’s INSERT ('business' instead of 'pro'), DEL the key, and the same command returns 200 with {"webhooks":[]}.

Two things worth noticing.

First, you added a feature to the product and touched exactly three files: the rulebook, one handler, one route. No existing gate changed. That is what the resolver bought you.

Second, the cached JSON from before your change has no Webhooks key at all. json.Unmarshal leaves missing fields at their zero value, so a stale cache entry decodes to Webhooks: false — the safe direction, refusing a paid feature for at most sixty seconds rather than granting one. Adding a field that defaults to granting access would have been the dangerous direction, and the fix there is to change the cache key prefix so old entries can never be read.


12. FAQ

Why are the plans in a Go file instead of a database table, where I could change them without a deploy? Because a plan change is a behaviour change, and behaviour changes deserve a code review, a diff and a deploy you can roll back. Chapter 15 argued this at length. The database version sounds flexible right up to the evening somebody edits a row in production, nobody can say who or when, and every customer’s limits change with no record. If you genuinely need per-customer overrides — a comped account, an enterprise deal — the answer is a small overrides table read on top of Plans, not moving Plans into the database.

What happens to somebody who downgrades while holding more data than their new plan allows? Their existing data is untouched and new creates are refused until they are back under the limit. That is this book’s chosen policy, and the quota code implements it by counting before inserting and never touching existing rows. It is not the only defensible answer — some products archive the overflow, some make the account read-only — but it is the one that never destroys a customer’s work to enforce a billing rule. Whatever you choose, write it down where support can find it.

Why 100 tasks on the free plan and not 10? Because the free tier is marketing, not charity, and its job is to let somebody use the product for real long enough to want the paid features. At 10 tasks they hit the wall while still evaluating and leave; at 100 they hit the feature gates — priorities and search — which are the things worth paying for. The number is a product decision, and the code makes it a one-line change precisely so you can revise it when you have data instead of an opinion.

Is 402 a real status code? I have never seen one. It is real and it has been in the HTTP specification since the beginning, reserved for exactly this and left unused for decades because there was no common way to pay over the web. It is now used by a growing number of APIs for plan limits. If you are worried about odd clients mishandling it, that worry is covered by the body: every taskd error has the same {"error": ...} shape, and the code field is what a well-written client branches on regardless of the status.

What if Stripe is down when a customer’s request arrives? Nothing happens, and that is the design. No gate in this chapter talks to Stripe. They read your subscriptions table, which the webhook keeps up to date. A Stripe outage means no new upgrades land until it recovers; every existing customer keeps exactly the access their row already grants. That is the payoff for treating your table as a projection of Stripe’s truth rather than calling Stripe on the hot path.

How fast does an upgrade take effect, honestly — and is all this machinery really how companies do it, for “Pro users get search”? Two honest answers. On speed: on the next request after the webhook is processed, which in practice is a second or two after the customer’s card clears; if the invalidation is lost, within 60 seconds; if the webhook itself never arrives, never — which is why Chapter 16 spent a chapter on making the webhook reliable, and why Chapter 27 (Going live) has you register the production endpoint and watch its delivery log. On the machinery: yes, and usually with more parts, not fewer. What you have is small — one resolver, one cache key, one error helper, four call sites — and it feels heavy because each piece answers a different question: where do the rules live, how fast can they be read, what happens when the lookup fails, what does the client do with a refusal. Every subscription business answers all four. Most answer them by accident and find out which answers they chose during an incident.


13. Where we are

A monetized, tiered, self-enforcing SaaS. Money now changes what the software does, in both directions, without a human in the loop. Now we earn the right to say “production-ready”: see it (metrics, logging), verify it (tests), ship it (Docker, CI/CD).

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go          # UPDATED: GET /v1/billing/plan
│   ├── config.go
│   ├── db.go
│   ├── middleware.go      # UPDATED: rateLimitUser reads ent.RatePerMinute
│   ├── helpers.go
│   ├── errors.go          # UPDATED: upgradeRequiredResponse (402)
│   ├── context.go
│   ├── healthcheck.go
│   ├── tasks.go           # UPDATED: priority gate ×2, search gate, quota
│   ├── users.go
│   ├── tokens.go
│   ├── billing.go         # UPDATED: showPlanHandler
│   ├── entitlements.go    # REPLACED: the real resolver, not ch. 16's stub
│   └── webhooks.go
├── internal/
│   ├── cache/             # UPDATED: cache.go gains Delete
│   ├── data/              # filters.go plans.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output, incl. billing.sql.go
│   └── validator/
├── migrations/            # unchanged this chapter
├── sql/queries/           # unchanged this chapter
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: a user registers, logs in, and is a free-tier customer with 100 tasks, 60 requests a minute and no priorities or search. They pay with a test card; Stripe’s webhook writes the row and deletes their cached entitlements; their very next request is a Pro request, with 10,000 tasks, 300 requests a minute and both features live. Cancel, and the reverse happens. Every refusal along the way is a 402 carrying a machine-readable code and the path to upgrade. GET /v1/billing/plan reports exactly what the gates will enforce, because both read the same function.

What is still fake or missing:

  • You cannot see any of it. There is no number anywhere for how many 402s you serve, which is to say no measurement of your own upsell funnel. Chapter 18 (Prometheus) adds the metrics, and its cacheOps counter lands on two lines inside the listTasksHandler you printed here.
  • The quota can overshoot by a handful under concurrent creates. Accepted, documented, and explained in section 4.3. It is a decision, not a bug.
  • Nothing is tested. Chapter 20 (Testing what matters) writes the first tests, and the entitlement gates are good early candidates: they are decisions over a small struct, with no network in the way.
  • There is no operator tooling. When a customer emails “I paid and I’m still on free”, your only instruments are make db/psql, redis-cli and the Stripe dashboard. The four causes to check are in this chapter’s Common mistakes table; a real support tool is beyond this book.
  • /billing/plan renders MaxActiveTasks, not max_active_tasks. Cosmetic, and worth fixing before third parties integrate against it.

For your notes

Copy these into learnings/ch17.md, in your own words:

  1. One function owns the rules; everything else asks it. The alternative is if tier == pro scattered across twenty handlers, which drifts, and drift in billing enforcement produces either angry customers or free riders — and you only ever hear from one of them.
  2. The fail direction is chosen per gate, from what is behind the gate. Other people’s data fails closed. Fairness and speed fail open. Billing fails to free, because an hour of accidental free features costs less than an hour of locked-out paying customers.
  3. A TTL bounds staleness; an invalidation removes it. Use both: invalidation makes the common case instant, the TTL makes the lost-invalidation case self-heal.
  4. Match the strength of enforcement to what the limit protects. A soft business limit may overshoot by a handful under concurrency, and saying so in a comment is a decision. A payment ledger gets pg_advisory_xact_lock without hesitation. Knowing why you are allowed to be relaxed is not the same as not having thought about it.
  5. An over-limit response is a sales moment, not an error. 402 Payment Required, a stable code a program can branch on, and the upgrade path in the body. Your API’s error design is part of your funnel.

Chapter 18 — Prometheus: metrics that answer questions

Your API works. You have no idea how fast it is. Not “roughly” — you have no number at all, for any endpoint, over any period. If a customer emails “it’s been slow since Tuesday” you have nothing to check, and if it were slow you would find out from them rather than from your own machine. This chapter fixes that by making the server keep a running tally of its own behaviour — how many requests, how long each took, how many failed, how full the database pool is — and publish it at a URL that another program reads every fifteen seconds. At the end you will be able to type a question like “what is the 99th-percentile latency of GET /v1/tasks over the last five minutes” and get a number back.

What you’ll be able to do by the end

  • Explain the difference between a counter, a gauge and a histogram, and pick the right one.
  • Read a /metrics page and say what every line on it means.
  • Explain label cardinality with actual arithmetic, and name the label that would kill your monitoring.
  • Run Prometheus in Docker, watch it scrape your API, and answer three questions with PromQL.
  • Point at the exact line of the middleware that must run after the handler, and say why.

Time: ~50 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 17 (Entitlements and quotas) — the app builds, the database is up, and you can log in and list tasks. Prove it in one command:

go build ./... && echo READY-FOR-CH18

You should see READY-FOR-CH18. You also need Docker running, because Prometheus itself arrives as a container.


1. The problem, in plain words

A hospital does not decide whether a patient is fine by reading the ward’s diary. It watches a handful of numbers — pulse, temperature, blood pressure — sampled continuously, plotted over time. The diary is what you read after the numbers say something is wrong, to find out what happened.

Your server has a diary. Chapter 3 (Configuration and logging) gave it slog, and every request writes a line. That is genuinely valuable, and Chapter 19 (Logging that pays rent) makes it better. But try asking the diary a numeric question:

“What was the 99th-percentile latency of GET /v1/tasks last week?”

To answer from logs you must read every line of a week’s logs — millions of them — extract the duration, filter by path, sort, and count. On a laptop with the files to hand that is a slow afternoon. On a real system, where logs live in a search service that charges by the gigabyte, it is a bill and a meeting.

Now ask a different numeric question — “and how did that compare to the week before?” — and you do it all again. Logs are the wrong shape for this. They are events: one entry per thing that happened, infinitely detailed, and expensive to add up.

A metric is the opposite trade. Instead of storing every event, the server keeps a small set of running numbers in memory and lets someone read them periodically. “Requests served: 41,203.” “Requests currently in flight: 3.” “Requests that took under 25 ms: 39,881.” Those numbers are tiny — a few dozen bytes each — so you can keep them for years, and they are already added up, so the answer to “what’s the p99 this week” is instant.

The price is that a metric is blind to any question you did not think to encode in advance. If you never recorded which route a request was for, no amount of querying will tell you afterwards.

Remember this

Metrics tell you that something is wrong and how much. Logs tell you why. The alert fires on a metric; the investigation reads the logs. You need both, and they are not substitutes.

What breaks if you skip this chapter: you ship a service whose performance you can only learn about from complaints. You cannot tell a slow database from a slow network from a slow handler. You cannot size the connection pool from Chapter 6 (Connecting with pgx/v5), because you have no idea whether it is starved or half idle — that chapter explicitly promised “let the pgxpool metrics we export in Chapter 18 tell you the truth”, and this is where that promise comes due. And you cannot be woken up at 3 a.m. by a machine instead of a customer, which is the entire point of the exercise.


2. New words in this chapter

This is the largest vocabulary list in the book. Observability has its own language, and almost all of it arrives here at once. Skim it now; each term is defined again in context where it is used.

Word What it means here
metric A number your program reports continuously — cheap to store for years, instantly queryable.
time series One metric with one fixed set of labels, recorded repeatedly over time. The unit Prometheus stores.
label A key=value tag attached to a metric, e.g. route="/v1/tasks". Each unique combination is its own time series.
instrument The object in your code that holds a metric’s value — a counter, gauge or histogram.
counter An instrument that only ever goes up: requests served, errors, cache hits.
gauge An instrument that goes up and down: requests in flight, connections open.
histogram An instrument that counts how many observations fell into each bucket, so percentiles can be computed later.
bucket / le A histogram’s “less than or equal to” boundary. le="0.05" counts everything that took ≤ 50 ms.
summary An older instrument that computes percentiles inside your app. Skipped here; its results cannot be combined across servers.
quantile / percentile A cut point in a distribution. p99 = the value 99% of observations come in under.
cardinality How many unique label combinations exist. The number that decides whether Prometheus survives.
scrape Prometheus visiting your /metrics page on a schedule and reading the current values.
pull-based The monitoring system fetches from you. (Push-based is the opposite: your app sends.)
exposition format The plain-text layout of the /metrics page: one metric name, labels, a number.
registry The client library’s list of every instrument it knows about; a scrape reads the registry.
collector Anything that can produce metric values when asked. Instruments are collectors; you can also write your own.
exporter A separate small program that translates some other system’s numbers into the Prometheus format. taskd instruments itself instead.
RED metrics Rate, Errors, Duration — the three numbers that describe any endpoint’s health.
PromQL Prometheus’s query language. rate(), sum(), histogram_quantile() are its verbs.
up A metric Prometheus writes itself, per target: 1 if the last scrape worked, 0 if it failed.
route pattern /v1/tasks/{id} — the shape of a URL, as the router matched it. Safe as a label.
path /v1/tasks/48291 — the actual URL. Never a label.
OOM “Out of memory” — the operating system killing a process that asked for more RAM than exists.
Grafana A dashboard tool that draws graphs from Prometheus queries. Optional; the FAQ covers it.
alert rule A saved PromQL expression plus a threshold and a duration: “if this stays true for 5 minutes, page someone.”
directional channel A Go channel parameter marked send-only (chan<- T) or receive-only (<-chan T). New syntax; taught in Step 4.

3. The goal

A /metrics endpoint exposing: RED metrics per route (Rate, Errors, Duration — as a labelled histogram), an in-flight request gauge, cache hit/miss counters, pgx pool statistics via a custom collector, and Go runtime metrics for free. Plus a Prometheus container in Compose scraping it, and the cardinality discipline that keeps this from ever melting down.

In plainer terms: one new file, three new instruments, one middleware, one route, one container, and one rule you must never break.

New word

RED metrics — Rate (how many requests per second), Errors (how many of them failed), Duration (how long they took). Three numbers, per endpoint. Almost every “is the service healthy?” conversation is one of those three. The name comes from Tom Wilkie, who noticed that most teams reinvent the same three every time.


4. The thinking

4.1 Logs or metrics — the honest comparison

Logs Metrics
Shape One entry per event One number per thing measured
Detail Everything: user, path, error text Only what you labelled in advance
Cost per month Grows with traffic; gigabytes Flat; megabytes
“What’s the p99 this week?” Read every line Instant
“Why did request a3f9 fail?” Instant Impossible
Retention in practice Days to weeks Years

Neither replaces the other, and a system with only one of them is half blind. Prometheus for the numbers, slog for the story.

4.2 Pull, not push

There are two ways monitoring data can travel. A push-based system has your app send its numbers to a collector. A pull-based system has your app serve a text page of current values, and the monitoring system comes and reads it. Prometheus pulls.

That inversion is why instrumenting an app stays simple. Consider what a push design would force into your codebase: a background sender, a batching buffer, a retry queue for when the collector is down, a decision about what to do when that queue fills, and a new class of bug — “the app is fine but the metrics stopped arriving” — that you now have to monitor with something else.

With pull, none of that exists. /metrics is an ordinary HTTP handler that prints numbers. If Prometheus cannot reach it, that is Prometheus’s problem, and it is itself a signal: Prometheus records a metric called up for every target it scrapes, 1 for success and 0 for failure.

Pull also explains a word you will meet in every Prometheus discussion: an exporter. Postgres, Redis and your operating system do not serve a /metrics page, so somebody writes a small program that reads their statistics and serves one on their behalf — postgres_exporter, node_exporter. We need none of those for taskd, because we are instrumenting our own code directly and our own code can serve its own page. Step 5 does write something exporter-shaped, though, for the database pool.

Remember this

up == 0 is the first alert rule anybody writes. A monitoring system that notices its own failure to monitor is worth more than one with prettier graphs.

4.3 The four instrument types

An instrument is the object in your code that holds a metric’s value. There are four; you will use three.

Type Direction Use it for Example
Counter Only up (resets to 0 on restart) Things that accumulate requests served, errors, cache hits
Gauge Up and down Things with a current level requests in flight, open connections
Histogram Counts into buckets Distributions you want percentiles from request latency
Summary Computes percentiles in your app skip it

Why skip summaries? Because a summary calculates its percentiles inside one process, and percentiles cannot be averaged. If you run three copies of taskd and each says “my p99 is 200 ms”, there is no arithmetic that turns those three numbers into the real p99 across all three. A histogram exports raw bucket counts, and counts can be added, so the maths works across any number of instances. Prometheus’s own documentation says the same. Use histograms.

New word

counter — only ever increases. If you see a counter go down, the process restarted. gauge — a current level, like a fuel gauge, free to move in both directions.

4.4 Why a histogram and not an average

This is the idea people skip, so here it is with numbers. Suppose 100 requests arrive and you record the average response time. It comes out at 100 ms. Sounds fine.

Here are two completely different realities that both average to 100 ms:

  Reality A                          Reality B
  ─────────                          ─────────
  100 requests at ~100 ms            99 requests at 20 ms
                                       1 request at 8,020 ms

  average = 100 ms                   average = 100 ms
  worst customer waited 0.1 s        worst customer waited 8 s

In Reality B one customer in a hundred sat there for eight seconds and probably left. The average hid them completely. Averages are the wrong summary for latency because latency distributions have long tails, and the tail is where your unhappy users are.

What you want instead is a percentile. The p99 is the value that 99% of requests come in under. In Reality A the p99 is about 100 ms; in Reality B it is 8 seconds. One number, and it tells the two realities apart instantly.

To compute a percentile you need the shape of the distribution, and to store a distribution cheaply you use buckets: instead of keeping every measurement, keep a count of how many fell under each of a fixed set of boundaries.

  Each request's duration falls into every bucket it is <= to.
  These counts are CUMULATIVE — that is what "le" (less-or-equal) means.

  le=      .005  .01  .025  .05   .1   .25   .5    1    2.5    5    +Inf
          ┌────┬────┬─────┬─────┬────┬─────┬────┬────┬─────┬─────┬─────┐
  count   │ 12 │ 48 │ 91  │ 96  │ 97 │ 98  │ 98 │ 98 │ 100 │ 100 │ 100 │
          └────┴────┴─────┴─────┴────┴─────┴────┴────┴─────┴─────┴─────┘
                       ▲                                ▲
                       │                                │
            half of all requests are            the 99th request lands
            already counted here, so            somewhere between 1 s
            the median (p50) is in this         and 2.5 s — that is your
            bucket, around 25 ms                p99, and it is awful

Eleven small integers, and you can read the median and the p99 off them. Read the median by finding the first count that reaches 50; read the p99 by finding the first that reaches 99. Add the same eleven integers from another server and you get the combined distribution — that is the property summaries lack.

Now the bucket boundaries in the code will make sense:

Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},

Those are seconds: 5 ms, 10 ms, 25 ms, 50 ms, 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s. Six of the ten boundaries sit between 5 ms and 250 ms, because that is where a JSON API actually lives. The library’s default buckets stretch far higher and spend half their resolution on a range you are never in. The last few boundaries still catch the disaster case, which is all they need to do.

4.5 The decision that makes or breaks Prometheus: cardinality

Every unique combination of label values is a separate time series, held in memory by Prometheus, forever (or until retention expires). That sentence is the whole chapter’s danger.

Do the arithmetic for taskd’s request histogram, which has three labels:

  method  ×  route pattern  ×  status   =  time series
  ────────────────────────────────────────────────────
    ~7           ~25            ~8       =    1,400

  1,400 series × 12 buckets-ish each  ≈  17,000 samples per scrape.
  Prometheus finds this boring. Good.

Now change one thing. Instead of the route pattern /v1/tasks/{id}, label with the raw path r.URL.Path:

  SAFE — the pattern                    FATAL — the path
  ┌────────────────────────────┐        ┌────────────────────────────┐
  │ route="/v1/tasks/{id}"     │        │ path="/v1/tasks/48291"     │
  │                            │        │ path="/v1/tasks/48292"     │
  │ ONE series, no matter how  │        │ path="/v1/tasks/48293"     │
  │ many tasks ever exist      │        │ path="/v1/tasks/48294"     │
  │                            │        │ ...                        │
  │                            │        │ ONE NEW SERIES PER TASK,   │
  │                            │        │ kept for the whole         │
  │                            │        │ retention window           │
  └────────────────────────────┘        └────────────────────────────┘

The same trap with a user_id label and 50,000 users: 7 × 50,000 × 8 = 2.8 million series for one metric. Prometheus’s memory climbs until the kernel kills it. That is an OOM — “out of memory”, the operating system’s way of ending a process that asked for more RAM than the machine has. Your monitoring dies exactly when you need it, which is during the incident that created the traffic.

Remember this

Patterns, never paths. Never user IDs, emails, tokens, or anything else drawn from an unbounded set. Before writing any WithLabelValues(...) call, ask of every argument: is this drawn from a small, closed set I could write down on a napkin? If not, it is not a label.

This is also why “let me add per-user metrics so I can see each customer’s latency” is always the wrong answer. Per-user questions are what the database and the logs are for.

It is also, quietly, why Chapter 1 (Introduction) chose chi as the router. Its argument for chi mentioned “route patterns we’ll later need for Prometheus labels” — this is later. chi can tell you the pattern it matched, chi.RouteContext(r.Context()).RoutePattern(), and a router that cannot do that leaves you holding only the raw path, which is the one label you must not use.

4.6 The wrinkle: measuring happens on the way out

Middleware, from Chapter 4 (A server that dies well), is a function that wraps a handler: it gets the request first, calls next.ServeHTTP(...), and gets control back afterwards. Most middleware you have written so far does its work on the way in — checking a token, setting a header.

Metrics cannot. Two of the three things we want to record do not exist yet when the request arrives:

  • the route pattern, because the router has not matched the URL yet;
  • the status code, because the handler has not run yet.

So every line that records anything must sit after next.ServeHTTP. And for the status there is a second problem: Go’s http.ResponseWriter is write-only. There is no w.Status(). Once your handler writes 404, the number is gone down the wire and the interface will not tell you what it was.

The fix is a wrapper — an object that satisfies the same http.ResponseWriter interface, passes every call through to the real one, and remembers what went past. chi ships one: middleware.NewWrapResponseWriter. Chapter 4 promised this would arrive with the Prometheus middleware; here it is.

Think of it like

A plain ResponseWriter is a letterbox: you post things through and they are gone. The wrapper is a letterbox with a photocopier behind it — the letter still goes out, and you keep a copy of what was on it.


5. A picture of it

Two pictures. First, the shape of the whole system after this chapter — note the direction of the arrow between Prometheus and taskd.

   YOUR LAPTOP                              DOCKER
  ┌──────────────────────────┐             ┌───────────────────────────┐
  │  taskd  (go run)         │             │  prometheus container     │
  │  :4000                   │             │  :9090                    │
  │                          │             │                           │
  │  ┌────────────────────┐  │  every 15s  │  ┌─────────────────────┐  │
  │  │ /metrics           │◀─┼─────────────┼──│ scraper             │  │
  │  │  (a text page of   │  │   GET       │  │  writes `up` too    │  │
  │  │   current values)  │  │             │  └──────────┬──────────┘  │
  │  └─────────┬──────────┘  │             │             ▼             │
  │            │ reads       │             │  ┌─────────────────────┐  │
  │  ┌─────────▼──────────┐  │             │  │ time-series storage │  │
  │  │ registry           │  │             │  └──────────┬──────────┘  │
  │  │  counters, gauges, │  │             │             ▼             │
  │  │  histograms,       │  │             │  ┌─────────────────────┐  │
  │  │  poolStatsCollector│  │             │  │ PromQL + web UI     │  │
  │  └────────────────────┘  │             │  └─────────────────────┘  │
  └──────────────────────────┘             └───────────────────────────┘
  1. Your handlers update instruments in memory. That is all they do — no network, no I/O.
  2. Every 15 seconds Prometheus makes an ordinary HTTP GET to /metrics.
  3. The client library walks its registry and renders every current value as text.
  4. Prometheus stores the values with a timestamp, and stores up=1 because the scrape worked.
  5. You ask questions in PromQL at localhost:9090.

Second picture: one request through the metrics middleware, showing which lines run where.

  request in
      │
      ▼
  ┌──────────────────────────────────────────────────────────┐
  │ metricsMiddleware                                        │
  │                                                          │
  │  ON THE WAY IN                                           │
  │    httpRequestsInFlight.Inc()      gauge +1              │
  │    defer ...Dec()                  scheduled for exit    │
  │    ww := NewWrapResponseWriter(w)  the photocopier       │
  │    start := time.Now()                                   │
  │                                                          │
  │    next.ServeHTTP(ww, r) ─────▶ router matches the URL   │
  │                                 handler writes a status  │
  │    ◀──────────────────────────────────────────────────   │
  │                                                          │
  │  ON THE WAY OUT — nothing below can move above the call  │
  │    route  := RoutePattern()        now non-empty         │
  │    status := ww.Status()           now known             │
  │    Observe(time.Since(start))      the duration          │
  │    [deferred] ...Dec()             gauge -1              │
  └──────────────────────────────────────────────────────────┘
      │
      ▼
  response out

6. The steps

Four parts. Part A instruments the app, Part B teaches Go’s collector interface and exports the connection pool, Part C wires everything into the router, Part D runs Prometheus and asks it questions.

Part A — the instruments and the middleware

Step 1 — Install the client library

go get downloads a package and records it in go.mod so every future build uses the same version.

go get github.com/prometheus/client_golang

What you should see: a line beginning go: downloading github.com/prometheus/client_golang and then go: added github.com/prometheus/client_golang v1.20.x (plus several // indirect additions — client_model, common, procfs — which are its own dependencies). If you already had it, go get prints nothing and exits 0.

This one module gives us three packages we will import:

Package What it gives us
prometheus The core types: Gauge, HistogramVec, Desc, the registry.
promauto Constructors that create an instrument and register it in one call.
promhttp The ready-made HTTP handler that renders /metrics.

Step 2 — Declare the three instruments

A new file. This first half declares what we measure; the second half (Step 3) measures it.

// cmd/api/metrics.go — new file
package main

import (
    "net/http"
    "strconv"
    "time"

    "github.com/go-chi/chi/v5"
    chimw "github.com/go-chi/chi/v5/middleware"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "taskd_http_requests_in_flight",
        Help: "Current number of in-flight HTTP requests.",
    })

    httpRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "taskd_http_request_duration_seconds",
        Help:    "HTTP request latency by route pattern.",
        Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
    }, []string{"method", "route", "status"})

    cacheOps = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "taskd_cache_operations_total",
        Help: "Cache lookups by result.",
    }, []string{"result"}) // hit | miss
)

What this code says, line by line

  • chimw "github.com/go-chi/chi/v5/middleware" — an import alias. The package’s real name is middleware, which would collide with our own middleware.go concepts and read confusingly, so we give it the local name chimw. Any name you put before an import path becomes how you refer to that package in this file.
  • var ( ... ) at the top level, outside any function — these are package-level variables. Go initialises them once, before main() runs. That timing matters here: promauto’s constructors register the instrument with the default registry as a side effect of creating it, so by the time main starts, all three already exist and are discoverable by a scrape.
  • promauto.NewGauge vs prometheus.NewGauge — the plain version creates the instrument and leaves registration to you; the promauto version does both and panics if registration fails. For package-level variables that is what you want: a name collision is a programming error that should stop the program immediately, not at 3 a.m.
  • GaugeOpts{Name: ..., Help: ...} — a struct literal configuring the instrument. Name is what appears on the /metrics page. Help is a human sentence that also appears there, and is the only documentation anyone will ever read about this metric. Write it as if for a colleague at 2 a.m., because that is who reads it.
  • The naming conventiontaskd_http_request_duration_seconds: application prefix, then what it measures, then the unit, last. Prometheus convention is base units (seconds, bytes) and a _total suffix on counters. taskd_cache_operations_total follows it. Sticking to the convention means every graphing tool and every colleague can guess your units correctly.
  • NewHistogramVec and NewCounterVec — the Vec suffix means “a family of instruments, one per label combination”. NewGauge (no Vec) is a single number; NewHistogramVec(..., []string{"method", "route", "status"}) is a whole collection indexed by those three labels, created lazily the first time each combination is used.
  • Buckets: []float64{...} — the boundaries from §4.4, in seconds. .005 is Go’s way of writing 0.005; the leading zero is optional.
  • []string{"method", "route", "status"} — the label names. Values come later, at the call site. Three labels: this is the cardinality budget you are spending, and §4.5 did the arithmetic.
  • // hit | miss — a comment documenting the only two values result will ever take. Write this comment on every labelled metric. It is the napkin from the cardinality rule.
New word

registry — the client library’s internal list of everything it knows how to report. A scrape asks the registry for the current value of everything on it. promauto puts things on the default registry, which also already holds Go’s own runtime metrics.

Step 3 — Write the middleware that measures

Same file, straight after the var block.

// cmd/api/metrics.go — add below the var block

func (app *application) metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Gauge up on the way in, down on the way out — defer makes
        // the decrement panic-proof.
        httpRequestsInFlight.Inc()
        defer httpRequestsInFlight.Dec()

        // A plain ResponseWriter never tells you what status the
        // handler wrote. The wrapper records it as it passes through —
        // the one-way mirror this middleware needs.
        ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
        start := time.Now()

        next.ServeHTTP(ww, r)

        // Only AFTER the handler ran do we know the matched route
        // pattern and final status — which is why every line below
        // this point lives after next.ServeHTTP, not before.
        route := chi.RouteContext(r.Context()).RoutePattern()
        if route == "" {
            route = "unmatched"
        }
        httpRequestDuration.
            WithLabelValues(r.Method, route, strconv.Itoa(ww.Status())).
            Observe(time.Since(start).Seconds())
    })
}

func metricsHandler() http.Handler { return promhttp.Handler() }

What this code says, line by line

  • func (app *application) metricsMiddleware(next http.Handler) http.Handler — the standard middleware shape from Chapter 4: take the next handler in the chain, return a new handler that wraps it. It is a method on *application for consistency with the others, even though it does not use app — if you later want to read config or log from here, the receiver is already there.
  • defer httpRequestsInFlight.Dec()defer schedules a call for when the surrounding function returns, by any route. If the handler panics, Chapter 4’s recoverPanic catches it, the stack unwinds through here, and the deferred Dec() still runs. Without defer, one panic would leave the gauge permanently one too high, and the number would drift upward forever.
  • chimw.NewWrapResponseWriter(w, r.ProtoMajor) — builds the photocopier. r.ProtoMajor is the major version of the HTTP protocol this request arrived on: 1 for HTTP/1.1, 2 for HTTP/2. chi needs it because HTTP/2 responses support extra optional operations, and it picks a wrapper that preserves them rather than accidentally hiding them from the handler.
  • start := time.Now() — the clock, started as late as possible before handing off, so we time the handler and not our own setup.
  • next.ServeHTTP(ww, r) — the handoff. Note we pass ww, the wrapper, not w. Pass w here by mistake and everything still works except that ww.Status() is always 0, because nothing ever went through the copier.
  • chi.RouteContext(r.Context()).RoutePattern() — chi stashes routing information in the request’s context (Chapter 11 used the same mechanism to carry the authenticated user). Before the router has matched, the pattern is ""; after, it is the template string such as /v1/tasks/{id}. This is the safe label from §4.5.
  • if route == "" { route = "unmatched" } — for a request that matched nothing at all, the pattern is empty, and an empty label value is both useless and confusing on a graph. "unmatched" is one extra series, and it usefully quantifies the internet’s background noise of scanners probing for /wp-login.php.
  • WithLabelValues(r.Method, route, strconv.Itoa(ww.Status())) — picks (or creates) the one histogram belonging to this label combination. The three arguments must be in the same order as the label names in Step 2. strconv.Itoa converts an int to its decimal string — labels are always strings, and 200 the number is not "200" the label.
  • .Observe(time.Since(start).Seconds())time.Since(start) gives a time.Duration; .Seconds() converts it to a float64 count of seconds, which is the unit our buckets are in. Observe finds every bucket this value is ≤ and increments each one.
  • func metricsHandler() http.Handler { return promhttp.Handler() } — one line, and it is the entire /metrics endpoint. promhttp.Handler() walks the default registry and renders it in the exposition format. It is a plain function, not a method on app, because it needs nothing from the application.
Common mistake

You’ll see: every series on your graphs labelled route="unmatched" and status="0", and every duration essentially zero. No error, no warning, no crash. It means: you recorded the metrics before next.ServeHTTP instead of after. The route had not been matched ("""unmatched"), the handler had not written a status (chi’s wrapper returns 0 until something writes one), and no time had passed. Fix: move every line from route := down to below next.ServeHTTP(ww, r). This is the single most common mistake in this chapter and it produces plausible-looking wrong data, which is worse than a crash.

Part B — teaching the collector interface, and the pool

Step 4 — Two pieces of Go you have not met yet

The next listing is the hardest Go in the book. It needs two ideas first. Read this step before typing anything.

Idea 1: implementing someone else’s interface. An interface in Go is a list of method signatures. Any type that has those methods satisfies it — there is no implements keyword and no registration; the compiler checks it for you. You have been using interfaces since Chapter 2 (http.Handler is one). Here you will author an implementation of one for the first time.

The Prometheus library defines:

// This is the library's interface, shown for reference — do not type it.
type Collector interface {
    Describe(chan<- *Desc)
    Collect(chan<- Metric)
}

Anything with those two methods can be registered and scraped. Our job is to write a type that has them and reports six numbers about the connection pool.

Who calls these, and when? Not you. Describe is called once, when you register the collector, so the library can check the metric names do not clash with anything already registered. Collect is called on every single scrape — every 15 seconds, forever. That is why Collect must be fast and must not block: it runs inside an HTTP request.

Idea 2: directional channels. A channel in Go is a typed pipe that values can be sent into and received from. You met the idea in Chapter 4’s shutdown code. What is new is the arrow in the type:

Written Means
chan T A channel you may both send to and receive from.
chan<- T Send-only. You may put values in; you may not take any out.
<-chan T Receive-only. You may take values out; you may not put any in.

Read the arrow as pointing at the channel for send-only, and away from it for receive-only. The direction is part of the type, so the compiler enforces it.

So func (c *poolStatsCollector) Collect(ch chan<- prometheus.Metric) says: “the library hands me a pipe; I may push metrics into it; I cannot read from it.” That signature is a promise in both directions — the library is telling you it will read, and stopping you from interfering.

And the send operation itself is the arrow again:

ch <- someMetric     // push someMetric into the channel ch
Think of it like

Collect is a form you fill in and post. The library gives you the postbox (ch), you drop six completed forms in it, and you never see what happens next. The postbox only accepts posting — you cannot reach in and take mail back out.

Step 5 — Write the pool collector

Why bother? Because pgxpool does not expose Prometheus metrics. It exposes a single method, pool.Stat(), returning a snapshot struct of current numbers. Something has to read that snapshot at scrape time and translate. That something is a custom collector — and the interface is worth learning once.

// cmd/api/metrics.go (continued)
import "github.com/jackc/pgx/v5/pgxpool"

type poolStatsCollector struct {
    pool *pgxpool.Pool

    acquired, idle, total, maxConns *prometheus.Desc
    acquireCount, emptyAcquireCount *prometheus.Desc
}

func newPoolStatsCollector(pool *pgxpool.Pool) *poolStatsCollector {
    d := func(name, help string) *prometheus.Desc {
        return prometheus.NewDesc("taskd_pgxpool_"+name, help, nil, nil)
    }
    return &poolStatsCollector{
        pool:              pool,
        acquired:          d("acquired_conns", "Connections currently in use."),
        idle:              d("idle_conns", "Idle connections."),
        total:             d("total_conns", "Total connections in pool."),
        maxConns:          d("max_conns", "Configured MaxConns."),
        acquireCount:      d("acquire_count_total", "Cumulative acquires."),
        emptyAcquireCount: d("empty_acquire_count_total",
            "Acquires that had to wait for a free connection."),
    }
}

func (c *poolStatsCollector) Describe(ch chan<- *prometheus.Desc) {
    prometheus.DescribeByCollect(c, ch)
}

// Collect runs on EVERY scrape — Prometheus knocks every 15s, we take
// a fresh snapshot of the pool and hand back current numbers. This
// pull-based flow is why the app needs no metric-shipping machinery.
func (c *poolStatsCollector) Collect(ch chan<- prometheus.Metric) {
    s := c.pool.Stat()
    g := prometheus.GaugeValue
    cn := prometheus.CounterValue
    ch <- prometheus.MustNewConstMetric(c.acquired, g, float64(s.AcquiredConns()))
    ch <- prometheus.MustNewConstMetric(c.idle, g, float64(s.IdleConns()))
    ch <- prometheus.MustNewConstMetric(c.total, g, float64(s.TotalConns()))
    ch <- prometheus.MustNewConstMetric(c.maxConns, g, float64(s.MaxConns()))
    ch <- prometheus.MustNewConstMetric(c.acquireCount, cn, float64(s.AcquireCount()))
    ch <- prometheus.MustNewConstMetric(c.emptyAcquireCount, cn, float64(s.EmptyAcquireCount()))
}
Note

The original edition prints that import on its own line, which Go allows — a file may contain several import declarations. In the finished taskd the pgxpool import sits in the single import block at the top of metrics.go. Either compiles; put it wherever your editor’s goimports puts it and move on.

What this code says, line by line

  • type poolStatsCollector struct { ... } — the state our collector needs: a pointer to the pool it reports on, and six descriptors.
  • *prometheus.Desc — a descriptor: the immutable identity of a metric (its full name, its help text, its label names). Built once at construction and reused on every scrape, because building them 4 times a minute forever would be waste. Note the struct declares six fields across two lines using Go’s shorthand: acquired, idle, total, maxConns *prometheus.Desc declares four fields of the same type.
  • d := func(name, help string) *prometheus.Desc { ... } — a function assigned to a local variable. Go lets you define a function inline and call it like any other value. This one exists only to avoid writing the "taskd_pgxpool_" prefix six times, which is exactly the kind of repetition that produces one typo and one metric nobody can find.
  • prometheus.NewDesc(name, help, nil, nil) — the four arguments are: full metric name, help text, variable label names, constant labels. Both nil here means “this metric has no labels at all”. Cardinality: six series, permanently. The napkin is nearly empty.
  • Describe(ch chan<- *prometheus.Desc) — the first interface method. Its job is to announce which metrics this collector will produce.
  • prometheus.DescribeByCollect(c, ch) — a helper that implements Describe by running Collect once and extracting the descriptors from the metrics it produces. It exists precisely so that collectors like ours, which always emit the same fixed set, do not have to list them twice. One line instead of six, with no chance of the two lists drifting apart.
  • s := c.pool.Stat() — the snapshot. A struct of counters and gauges captured at this instant; cheap, and safe to call from any goroutine.
  • g := prometheus.GaugeValue / cn := prometheus.CounterValue — short local aliases for two constants, so the six long lines below stay readable. GaugeValue says “this number can go down”; CounterValue says “this number only rises”. Prometheus uses the distinction to reject nonsense queries, like rate() over a gauge.
  • prometheus.MustNewConstMetric(desc, type, value) — builds a one-off metric value: this descriptor, this kind, this number, right now. “Const” here means the value is fixed at the moment of creation, which is the whole idea — a fresh one is built on every scrape. Must means it panics rather than returning an error, which is right for a programming mistake (wrong number of label values) and impossible for correct code.
  • float64(...) — Prometheus stores every value as a float64. s.AcquiredConns() returns an int32, so the conversion is explicit, as Go always requires between numeric types.
  • ch <- ... — six sends into the channel the library gave us. That is the entire output of the function; there is no return value.

What the six numbers mean

Metric Type Reading it
taskd_pgxpool_acquired_conns gauge Connections lent out right now.
taskd_pgxpool_idle_conns gauge Open and unused. Lots, always → pool too big.
taskd_pgxpool_total_conns gauge Acquired + idle.
taskd_pgxpool_max_conns gauge The ceiling from config.toml (max_conns = 25).
taskd_pgxpool_acquire_count_total counter Every borrow, ever.
taskd_pgxpool_empty_acquire_count_total counter Borrows that had to wait for a free connection.

That last row is the one Chapter 6 promised. If empty_acquire_count_total climbs, requests are queueing for database connections — the pool is too small, and every one of those waits is latency your users feel. “Capacity planning by measurement, not vibes” was the claim; this is the measurement. Chapter 27 (Production checklist) puts it in the five-rule alert list by exactly this name.

Step 6 — Register the collector in main.go

promauto registered the three instruments for us. The custom collector is not created by promauto, and it cannot be created until the pool exists — so it is registered by hand, in main, right after openDB succeeds.

// cmd/api/main.go — add after the openDB block

    defer pool.Close()
    logger.Info("database connection pool established")

    // --- pool metrics (ch. 18): expose pgxpool stats to Prometheus ---
    prometheus.MustRegister(newPoolStatsCollector(pool))

Add the import:

// cmd/api/main.go — add to the import block
    "github.com/prometheus/client_golang/prometheus"

What this code says

  • prometheus.MustRegister(...) — adds a collector to the default registry. Must again: a failure here means two collectors claim the same metric name, which is a bug you want to find on the first run, not from a confusing graph next month.
  • Placement matters. Put this line above pool, err := openDB(cfg) and the compiler stops you with undefined: pool — the variable does not exist yet. Immediately after defer pool.Close() is the natural home: the pool exists, and openDB’s ping has already proved it is alive.
Common mistake

You’ll see: panic: duplicate metrics collector registration attempted It means: something registered a metric name that was already registered — most often calling MustRegister twice (in main and again in a test helper), or a promauto variable declared in two files. Fix: register each collector exactly once. If you need metrics inside tests, build a private registry with prometheus.NewRegistry() instead of touching the default one.

Part C — wiring

Step 7 — Mount the middleware and the route

Two changes in routes.go. First the middleware, which goes directly inside recoverPanic.

// cmd/api/routes.go — inside routes(), the middleware stack
    r.Use(app.recoverPanic)
    r.Use(app.metricsMiddleware)
    r.Use(app.logRequest)
    // ...
    r.Get("/metrics", metricsHandler().ServeHTTP)

Why that exact position. Registration order is wrapping order, outermost first. recoverPanic must stay outermost so that a panic anywhere below it — including inside the metrics code — becomes a logged 500 rather than a severed connection. metricsMiddleware goes directly inside it, so that everything except the panic handler is measured, and logRequest inside that.

  outermost                                                  innermost
  ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ ┌──────────────┐
  │ recoverPanic│▶│ metricsMiddleware│▶│ logRequest │▶│ authenticate │▶ handler
  └─────────────┘ └──────────────────┘ └────────────┘ └──────────────┘
        ▲                  ▲
        │                  └── times and counts everything inside it
        └── turns a panic into a 500 on the way back out
Note

The original edition gives the reason for that position as “so panics are counted as the 500s they become”. Follow the wrapping through and it is subtler than that. With recoverPanic outside metricsMiddleware, a panic in a handler unwinds through the metrics middleware — Go runs deferred calls on the way past and skips everything else — so httpRequestsInFlight.Dec() still runs (that is exactly what the defer is for) while the Observe(...) line after next.ServeHTTP does not. A panicked request is therefore absent from the duration histogram, and is accounted for by recoverPanic’s own error log instead. To have panics show up in the histogram as status="500", metricsMiddleware would have to sit outside recoverPanic, so that the 500 is written into the wrapper before the metrics code resumes. Keep the book’s order — it is what the codebase ships and what Chapter 27’s alerting assumes — but know which of the two behaviours you have, because a service whose 500s are invisible to its own graphs is a bad surprise to have during an incident.

Why /metrics is on the root router, not inside /v1. It is not part of your product’s API. It has no version, no envelope, no authentication, and it is for machines. Registering it at the root also means its route pattern is exactly /metrics, which is what you will filter on later.

metricsHandler().ServeHTTPmetricsHandler() returns an http.Handler, which is an interface with one method, ServeHTTP. chi’s r.Get wants an http.HandlerFunc — a plain function. Writing .ServeHTTP after the call takes that method and passes it as a function value. Go calls this a method value: the object is remembered, and the result behaves like an ordinary function.

Warning

Serving /metrics on the main port is fine behind a private network if the reverse proxy never routes it publicly. That page leaks your route patterns, your library versions, and the shape of your traffic — a free map for anyone deciding where to aim. The stricter pattern is a second listener on an internal-only port, a ten-line change. Chapter 27 (Production checklist) decides per deployment: move /metrics out of the proxy’s routing, or put it behind basic auth. Don’t skip deciding.

Step 8 — Count cache hits and misses

The cacheOps counter from Step 2 is declared but nothing touches it yet. It belongs at the two places in listTasksHandler where Chapter 13 (Caching with DragonflyDB) already sets the X-Cache header — those two points are, by definition, exactly where a lookup has resolved.

Note

The original edition describes these two lines in prose but never shows them in place. Here they are, in context, with enough of the surrounding handler to find them.

// cmd/api/tasks.go — inside listTasksHandler, the cache-HIT branch
    var key string
    if app.cache != nil {
        if k, err := app.cache.ListKey(r.Context(), user.ID, fingerprint); err == nil {
            key = k
            if b, ok := app.cache.Get(r.Context(), key); ok {
                cacheOps.WithLabelValues("hit").Inc()     // ch. 18
                w.Header().Set("Content-Type", "application/json")
                w.Header().Set("X-Cache", "HIT")
                w.Write(b)
                return
            }
        }
    }
// cmd/api/tasks.go — inside listTasksHandler, the tail, after the query ran
    js := body.([]byte)
    if app.cache != nil && key != "" {
        app.cache.Set(r.Context(), key, js, 60*time.Second)
    }

    cacheOps.WithLabelValues("miss").Inc()               // ch. 18
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Cache", "MISS")
    w.Write(js)
}

What this code says

  • cacheOps.WithLabelValues("hit").Inc() — pick the counter for result="hit" and add one. Two label values, ever, so two time series, ever.
  • The hit line goes inside the if ok block, not before it: a lookup that returned nothing is a miss, and a lookup we never made (because app.cache is nil in degraded mode) is neither.
  • The miss line goes at the tail, after the database work, so it counts exactly the requests that had to do that work. Note that this is reached whether the cache is present or absent — in degraded mode with no cache at all, everything is honestly a miss.

Now Chapter 13’s foreshadowing pays off: it warned that folding “key absent” and “cache on fire” into a single miss means an outage can hide as a slow decline in hit rate. With this counter you can see that decline, which is the difference between a policy and a blind spot.

Build:

go build ./... && echo METRICS-BUILDS

You should see METRICS-BUILDS.

Part D — scraping and querying

Step 9 — Look at the page

Start the app (make run/api) and, in another terminal, read the raw page. It is plain text; curl prints it as-is.

curl -s localhost:4000/metrics | grep '^taskd_'

grep '^taskd_' keeps only lines starting with taskd_, hiding the ~100 Go-runtime lines for now. On a freshly started server you should see the six pool numbers and the in-flight gauge:

taskd_http_requests_in_flight 1
taskd_pgxpool_acquire_count_total <n>
taskd_pgxpool_acquired_conns <n>
taskd_pgxpool_empty_acquire_count_total <n>
taskd_pgxpool_idle_conns <n>
taskd_pgxpool_max_conns 25
taskd_pgxpool_total_conns <n>

Seven lines, in that order (the page is sorted by metric name). The pool values marked <n> are whatever your server has done so far — they are small, and they are not something to match against this page. Two of the numbers are predictable, and both are worth a pause.

taskd_pgxpool_max_conns 25. That is max_conns = 25 from config.toml, arrived at by argument in Chapter 6 and now visible from outside the process for the first time.

taskd_http_requests_in_flight 1. Not zero — one. The request being measured is the very request that is asking. Inc() ran on the way in, the gauge was read while the handler rendered the page, and Dec() has not run yet. This is not a bug; it is the pull model showing you its own plumbing.

No taskd_http_request_duration_seconds at all, on the first request. A Vec creates its children lazily — no combination of labels has been used yet, because the only request so far is this one, and its Observe happens after the page is rendered. Run the same command a second time and the histogram appears:

# HELP taskd_http_request_duration_seconds HTTP request latency by route pattern.
# TYPE taskd_http_request_duration_seconds histogram
taskd_http_request_duration_seconds_bucket{
    method="GET",route="/metrics",status="200",le="0.005"} <n>
taskd_http_request_duration_seconds_bucket{
    method="GET",route="/metrics",status="200",le="0.01"} <n>
...
taskd_http_request_duration_seconds_bucket{
    method="GET",route="/metrics",status="200",le="+Inf"} 1
taskd_http_request_duration_seconds_sum{
    method="GET",route="/metrics",status="200"} <seconds>
taskd_http_request_duration_seconds_count{
    method="GET",route="/metrics",status="200"} 1

Each sample is really one line; they are wrapped above so they fit the page. The label sets are exact; the values are yours. _count is 1 because exactly one request has finished being measured. The +Inf bucket always equals _count. The lower buckets are 1 or 0 depending on how fast your machine rendered the page — which is the point of having them.

Reading the exposition format

  • # HELP <name> <text> and # TYPE <name> <kind> — two comment lines per metric family, generated from the Help string and instrument type you wrote in Step 2. This is why the Help text matters.
  • {method="GET",route="/metrics",status="200",le="0.005"} — the labels. Note le comes last, after the alphabetically sorted ones; that is the library’s formatting, not something you control.
  • One histogram becomes three metric names: _bucket (one line per boundary, cumulative), _sum (total seconds observed) and _count (how many observations). _count is why the error rate query later can use the histogram rather than a separate counter — a histogram already counts requests.
  • le="+Inf" — the catch-all bucket, always equal to _count. Anything slower than your last real boundary lands only here.

Now drop the grep and look at everything:

curl -s localhost:4000/metrics | grep -c ''

grep -c '' counts lines. You should see a number in the low hundreds. Most of them are free: go_goroutines, go_memstats_*, go_gc_duration_seconds and friends come from the Go collector that the client library registers into the default registry automatically. You wrote nothing for those and they answer real questions — a goroutine count that climbs forever is a leak.

Note

On Linux you also get process_* metrics (resident memory, open file descriptors, CPU seconds). On macOS you will not: those are read from /proc, which macOS does not have, so the collector quietly reports nothing. Not a misconfiguration on your part. Inside the Docker image from Chapter 25 (Docker), which is Linux, they appear.

You should also see two lines starting promhttp_metric_handler_requests_ — the handler instrumenting itself, counting scrapes by response code.

Step 10 — Run Prometheus and let it scrape you

Two files. First, a service in the Compose file you have been growing since Chapter 5.

# docker-compose.yml — add this service alongside db and cache
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

What this says

  • image: prom/prometheus:latest — the official image. latest is acceptable for a local tool; Chapter 25 argues why it is not acceptable for anything you deploy.
  • volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro — mount one file from your repo into the container at the path Prometheus reads on startup. :ro is read-only: the container can read your config and cannot rewrite it.
  • ports: - "9090:9090" — publish container port 9090 as port 9090 on your machine, so localhost:9090 reaches the web UI.

Second, the config file that tells it what to scrape.

# prometheus.yml — new file, in the repo root
scrape_configs:
  - job_name: taskd
    scrape_interval: 15s
    static_configs:
      - targets: ["host.docker.internal:4000"]   # api:4000 once taskd is in Compose (ch. 25)

What this says

  • scrape_configs — the list of things to scrape. One entry here.
  • job_name: taskd — a name for this group of targets. It becomes a job="taskd" label on every series Prometheus stores from it.
  • scrape_interval: 15s — how often to knock. Fifteen seconds is the conventional default: fine enough to see a two-minute incident, coarse enough to cost nothing.
  • targets: ["host.docker.internal:4000"] — where to knock. Prometheus assumes the path /metrics unless told otherwise. The hostname is the interesting part; see the callout.
  • Right now taskd runs on your laptop with go run while Prometheus runs in a container. Chapter 25 (Docker) puts taskd in Compose too, at which point this becomes api:4000 and Docker’s own network resolves it.
New word

host.docker.internal — a container has its own network. Inside it, localhost means the container itself, not your laptop, so localhost:4000 would find nothing. Docker provides the special name host.docker.internal meaning “the machine running Docker”. That is the address of your go run process as seen from inside the Prometheus container.

Common mistake

You’ll see: on Linux without Docker Desktop, the Prometheus targets page shows taskd as DOWN with an error like Get "http://host.docker.internal:4000/metrics": dial tcp: lookup host.docker.internal: no such host. It means: plain Docker Engine on Linux does not define that name; it is a Docker Desktop convenience. Fix: add this to the prometheus service in docker-compose.yml and recreate the container:

    extra_hosts: ["host.docker.internal:host-gateway"]

host-gateway is a Docker keyword resolving to the host’s address on the bridge network.

Start it:

docker compose up -d prometheus

-d means detached — it runs in the background and gives you your prompt back. Then open localhost:9090 in a browser and go to Status → Target health. You should see one target, taskd, with state UP and a “last scrape” that keeps resetting to a few seconds ago.

Generate some real traffic first, so there is something to query. Use the token flow you already have:

TOKEN=$(curl -s -d '{"email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

for i in $(seq 1 50); do
  curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks
  curl -s -o /dev/null localhost:4000/v1/healthcheck
  curl -s -o /dev/null localhost:4000/v1/tasks/999999999
done

Fifty of each: cached list reads, healthchecks, and deliberate 404s so there is a non-200 status to find. Wait about thirty seconds — two scrapes — before querying, or the rate functions have nothing to work with.

Step 11 — The three queries that answer most incidents

Type these into the expression box at localhost:9090.

# p99 latency per route, 5m window
histogram_quantile(0.99, sum by (le, route)
  (rate(taskd_http_request_duration_seconds_bucket[5m])))

# error rate
sum(rate(taskd_http_request_duration_seconds_count{status=~"5.."}[5m]))
  / sum(rate(taskd_http_request_duration_seconds_count[5m]))

# cache hit ratio
rate(taskd_cache_operations_total{result="hit"}[5m])
  / sum(rate(taskd_cache_operations_total[5m]))

Reading PromQL, from the inside out

  • taskd_http_request_duration_seconds_bucket on its own selects every series with that name.
  • {status=~"5.."} filters by label. = is exact match; =~ is regular-expression match. In a regular expression . means “any one character”, so 5.. matches 500, 502, 503 — any three-character status starting with 5.
  • rate(X[5m]) — “over the last 5 minutes of samples, how fast was this counter rising, per second?” Counters only ever go up, so the raw value is meaningless on its own; rate turns it into something you can plot. It also handles restarts correctly (a counter dropping to zero is understood as a reset, not a negative rate).
  • sum by (le, route) (...) — add series together, keeping only the le and route labels and discarding the rest (method, status, instance). This is how you collapse “the same route across three servers” into one line.
  • histogram_quantile(0.99, ...) — takes bucket counts and interpolates the value at the 99th percentile. It requires the le label to still be present, which is why sum by must list it.
  • A ratio is division: 5xx-per-second over all-per-second gives the error fraction. 0.01 means 1%.

If you have not caused any 500s, the error-rate query returns nothing rather than zero — there are no status="5.." series to sum, and summing an empty set gives an empty result. That surprises people. It also matters for alerting: an alert on “error ratio > 1%” never fires while there are no errors, which is what you want, but it also never fires if the metric disappears entirely, which is why up == 0 is a separate rule.

Note

The cache-hit query as printed above is the original’s, kept unchanged. Be aware of a wrinkle: in PromQL, dividing one vector by another matches series with identical label sets, and the left side here still carries result="hit" while the sum() on the right has stripped all labels — so it can return empty. The symmetric form works: sum(rate(taskd_cache_operations_total{result="hit"}[5m])) / sum(rate(taskd_cache_operations_total[5m])). Reach for it if the printed one gives you “Empty query result”.

Point a Grafana at Prometheus and these three become a dashboard in ten minutes; alert rules (error rate > 1% for 5m, up == 0, p99 > 1s) are the same expressions with thresholds. Grafana is optional and the FAQ covers whether you want it.


7. Checkpoint: prove it works

Checkpoint

The one-line version: curl -s localhost:4000/metrics | grep -c '^taskd_' should print a number that grows as you use new routes and then stops growing. If it never stops, you have a cardinality bug; if it is 0, nothing is registered.

Five checks, each covering a different layer. Run them in order.

# 1. it compiles
go build ./... && echo METRICS-BUILDS

You should see METRICS-BUILDS.

# 2. the endpoint answers, in the right format
curl -s -D- -o /dev/null localhost:4000/metrics | head -1
curl -s -o /dev/null -w '%{content_type}\n' localhost:4000/metrics

The first command prints the status line, HTTP/1.1 200 OK. The second prints the content type, which begins text/plain; version=0.0.4 (your client library may add charset and escaping parameters after that). The version=0.0.4 is the Prometheus exposition format version, not your application’s.

# 3. the pool collector is registered and reporting
curl -s localhost:4000/metrics | grep -c '^taskd_pgxpool_'

You should see 6 — one value line per pool metric. Counting all lines mentioning pgxpool instead gives 18, because each metric also gets a # HELP and a # TYPE line:

curl -s localhost:4000/metrics | grep -c 'pgxpool'
# 4. real routes appear as PATTERNS, never paths
curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks/1
curl -s localhost:4000/metrics | grep 'route="/v1/tasks/{id}"' | head -1

You should see a taskd_http_request_duration_seconds_bucket{...route="/v1/tasks/{id}"...} line. You should never see a line containing /v1/tasks/1. If you do, the middleware is labelling with r.URL.Path.

# 5. Prometheus can reach you
curl -s 'localhost:9090/api/v1/query?query=up' | grep -o '"value":\[[^]]*\]'

The up metric is Prometheus’s own verdict on the scrape. You should see a fragment ending ,"1"]. A "0" means the target is unreachable.

If you got something else

You got Cause Fix
404 page not found from /metrics The route was registered inside r.Route("/v1", ...), or not at all r.Get("/metrics", metricsHandler().ServeHTTP) on the root router
Only go_* lines, no taskd_* metrics.go compiled but nothing in it ran — check the file is package main and in cmd/api/ Rebuild; go build ./... will not silently skip a file in the package
No taskd_pgxpool_* lines prometheus.MustRegister(newPoolStatsCollector(pool)) is missing from main Step 6
up is 0 Prometheus cannot reach the app: wrong hostname, app not running, or the Linux host.docker.internal issue Check Status → Target health in the UI for the exact error text
Empty result for the p99 query Not enough scrapes yet, or no traffic in the window Generate traffic, wait 30 s, retry

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: cmd/api/metrics.go:12:5: undefined: promauto It means: the import block is missing the promauto package, or you typed the module path without the sub-package. Fix: the path is github.com/prometheus/client_golang/prometheus/promauto. The module is client_golang; the packages live underneath it.

Common mistake

You’ll see: panic: inconsistent label cardinality: "taskd_http_request_duration_seconds" has 3 variable labels named [...] but 2 values [...] were provided It means: WithLabelValues was given a different number of arguments than the metric has label names. Our histogram declares method, route, status. Fix: pass three values, in that order. This panics at the call, not at startup, so a rarely taken branch can hide it — which is a reason to keep label lists short.

Common mistake

You’ll see: graphs where every route is unmatched and every status is 0. It means: the recording lines run before next.ServeHTTP. Fix: move them after. Reread the second diagram in §5.

Common mistake

You’ll see: Prometheus memory climbing steadily, then the container disappearing; docker compose ps shows it exited, and docker inspect reports OOMKilled: true. It means: cardinality explosion. Something is labelling with an unbounded value. Fix: find it with curl -s localhost:4000/metrics | grep -c '^taskd_' — if that number grows with your traffic instead of staying flat, you have found the bug. Then audit every WithLabelValues call.

Symptom What it means Fix
histogram_quantile returns NaN No observations in the window, so there is nothing to interpolate Generate traffic; widen the window to [30m]
The p99 graph is a flat line at exactly your top bucket Every request is slower than 5 s, so they all land in +Inf and interpolation has nothing to work with Your buckets are wrong for this endpoint — or your service is genuinely broken
rate() on a gauge gives a nonsense line, and recent Prometheus versions show an info note about expecting a counter rate() is defined for counters only; on a gauge it measures the wrong thing and older versions do not warn at all Graph the gauge’s value directly, or use delta() if you want its change over a window
Cache hit ratio query returns “Empty query result” The label-matching wrinkle in the Step 11 note Use the symmetric sum(...) / sum(...) form
/metrics responds but Prometheus still says DOWN Prometheus is reaching a different address than your curl does Read the exact error on Status → Target health; it names the host it tried

9. Pitfalls

Cardinality, again, because it is the one that pages you. Audit every WithLabelValues call: is each argument drawn from a small, closed set? route is safe only because it is the pattern. The day someone “helpfully” adds a user_tier label, fine — three values. The day they add user_id, start the incident channel. A useful review habit: any pull request touching a label list gets the napkin question in the description, answered with a number.

Histograms and sum by (le, ...). Quantile maths needs the le bucket label preserved through aggregation. Drop it in a sum by and histogram_quantile gets a set of numbers with no idea which boundary each belongs to — and Grafana draws confident nonsense, a line that looks like a latency and is not one. Copy the query shapes from Step 11 rather than reconstructing them from memory.

Counting before routing. Mount the metrics middleware outside the chi sub-routers, but expect RoutePattern() to be empty for genuine 404s — hence the "unmatched" fallback, which also usefully quantifies your scanner traffic.

Note

Measured against the real router: a request to /wp-login.php (no match anywhere) does give an empty pattern and lands in unmatched. A request to /v1/nope — inside a mounted sub-router that has no such child — reports the pattern /v1/*. Both are bounded values, so both are safe labels; you get two flavours of 404 on the graph rather than one. Knowing which is which saves a puzzled ten minutes later.

Scraping yourself into the stats. Prometheus hitting /metrics every 15 s shows up in your own request metrics as roughly 4 requests per minute on route="/metrics", forever, at whatever rendering cost the page has. Either exclude the route in the middleware or know that the baseline exists — but decide, so that nobody “investigates” 4 rpm of mystery traffic. (Chapter 19 makes the same decision for the log, giving /metrics and /v1/healthcheck a quieter level so uptime monitors do not dominate the file.)

The cost is real but small. Each instrumented request does a map lookup for the label combination, a handful of integer increments and one time.Now() pair. That is measured in hundreds of nanoseconds against a request measured in milliseconds. The cost that can bite is memory, and it is bounded by cardinality — which is the first pitfall again, wearing a different hat.


10. Check yourself — quiz

  1. You need to record: (a) total emails sent, (b) how many background jobs are running right now, © how long password hashing takes. Which instrument for each, and why?
  2. Why does this book skip Prometheus summaries?
  3. Your service’s average response time is 100 ms and you are told that is fine. Give a concrete scenario where it is not fine, and name the number that would have revealed it.
  4. Define label cardinality in one sentence, then name one label that would destroy this setup and estimate the series count it would create.
  5. route is a label and its values are URLs. Why is that safe here, when §4.5 says paths are fatal?
  6. Delete the line defer httpRequestsInFlight.Dec() and instead call httpRequestsInFlight.Dec() as the very last statement in the middleware. What still works, and what breaks?
  7. taskd_pgxpool_empty_acquire_count_total has gone from 0 to 4,000 in an hour. What is happening, which earlier chapter predicted it, and what do you change?
  8. Prometheus shows up == 0 for taskd while curl localhost:4000/v1/healthcheck from your laptop returns 200. Name two possible causes.
Answers
  1. (a) counter — emails sent only accumulate; you want a rate from it. (b) gauge — a current level that rises and falls. © histogram — you want percentiles of a duration, and a histogram’s bucket counts can be aggregated across instances. The general rule: “only up” → counter, “current level” → gauge, “how long / how big, and I want percentiles” → histogram.

  2. Because a summary computes its quantiles inside a single process, and quantiles cannot be averaged or added. Three servers each reporting “my p99 is 200 ms” cannot be combined into the real p99. A histogram exports bucket counts, and counts add up correctly across any number of instances.

  3. 99 requests at 20 ms and one at 8 seconds also averages to 100 ms. One customer in a hundred waited eight seconds and probably left, and the average hid them entirely. p99 — the value 99% of requests come in under — is the number that separates the two cases, and it is why we store a distribution rather than a mean.

  4. Cardinality is the number of unique label-value combinations, each of which is a separate time series held in Prometheus’s memory. A user_id label would be fatal: with 50,000 users, 7 methods and 8 statuses that is 7 × 50,000 × 8 = 2.8 million series for one metric, and Prometheus is killed by the kernel for running out of memory. (r.URL.Path is the same bug wearing a disguise: one new series per task id, forever.)

  5. Because it is the route pattern, not the path. chi.RouteContext(r.Context()).RoutePattern() returns the template the router matched — /v1/tasks/{id} — which is drawn from the fixed list of routes in routes.go. That list has a couple of dozen entries and only changes when you edit the file. The path /v1/tasks/48291 is drawn from your data, which is unbounded.

  6. In the normal case both work identically, and the gauge is correct. The break is the abnormal exit: if the handler panics, the stack unwinds through the middleware to recoverPanic and your final statement is never reached, so the gauge is left one too high — permanently. Enough panics and taskd_http_requests_in_flight drifts upward and stops meaning anything. defer runs on every exit path, which is exactly why it is used here.

  7. Requests are queueing for a database connection: the pool has none free, so each Acquire waits. Chapter 6 (Connecting with pgx/v5) predicted precisely this and deferred the measurement to this chapter. The fix is to raise max_conns in config.toml — while remembering that chapter’s other constraint, that total connections across all app instances must stay under Postgres’s max_connections with headroom for psql, migrations and backups. If raising it is not possible, the queries themselves are holding connections too long and that is the real bug.

  8. Any two of: (a) Prometheus is inside a container and host.docker.internal does not resolve on your Linux host — the extra_hosts: host-gateway fix; (b) the target address or port in prometheus.yml is wrong; © the app listens only on 127.0.0.1 and is unreachable from the container’s network; (d) a firewall between the container and the host. In every case the exact error text is on Prometheus’s Status → Target health page, and reading it beats guessing.


11. Practice

Exercise 1 — Add a business metric (easy)

Technical metrics tell you the service is healthy. Business metrics tell you it is useful. Add a counter taskd_tasks_created_total, increment it on every successful task creation, and see it appear on /metrics.

Answer

Declare it with the others:

// cmd/api/metrics.go — add inside the existing var block
    tasksCreated = promauto.NewCounter(prometheus.CounterOpts{
        Name: "taskd_tasks_created_total",
        Help: "Tasks successfully created.",
    })

NewCounter, not NewCounterVec: no labels, one series. Resist the urge to label it by user.

Increment it after the insert succeeds, not before — a counter that includes failures is a counter nobody trusts:

// cmd/api/tasks.go — inside createTaskHandler, after the CreateTask error check
    task, err := app.q.CreateTask(r.Context(), db.CreateTaskParams{
        UserID:   user.ID,
        Title:    input.Title,
        Notes:    input.Notes,
        Priority: input.Priority,
        DueAt:    input.DueAt,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    tasksCreated.Inc()   // only after we know the row exists

Verify:

go build ./... && make run/api      # in one terminal
# in another:
curl -s -H "Authorization: Bearer $TOKEN" -d '{"title":"metric bait"}' \
  localhost:4000/v1/tasks -o /dev/null
curl -s localhost:4000/metrics | grep tasks_created

You should see a # HELP line, a # TYPE ... counter line, and taskd_tasks_created_total followed by the number of tasks you have created since the server started. Restart the server and it goes back to 0 — that is normal and correct for a counter; rate() and increase() are written to cope with resets.

Exercise 2 — Write the query yourself (medium)

Write the PromQL for “requests per second, broken down by route, over the last five minutes”, run it at localhost:9090, and explain why the histogram’s _count series is the right input.

Answer
sum by (route) (rate(taskd_http_request_duration_seconds_count[5m]))

Why _count: a histogram already counts its observations, and every observation is one request, so _count is the request counter — no separate metric needed. rate(...[5m]) converts the ever-rising count into requests per second averaged over five minutes. sum by (route) adds up the per-method, per-status series so you get one line per route rather than one per combination.

Run it and check the shape of the answer: one row per route, each with a small number. If you ran the fifty-request loop from Step 10, /v1/healthcheck, /v1/tasks and /v1/tasks/{id} should all be visible. Once your own manual curls have aged out of the five-minute window, /metrics should settle at roughly 0.067 — that is one request every fifteen seconds, which is Prometheus scraping you, and it is the “scraping yourself into the stats” pitfall showing up as a real number.

Two variations worth trying, to see what changes:

# the same thing, but keeping status so you can see errors separately
sum by (route, status) (rate(taskd_http_request_duration_seconds_count[5m]))

# total requests per second for the whole service, all routes
sum(rate(taskd_http_request_duration_seconds_count[5m]))

Exercise 3 — Break it on purpose, then measure the damage (harder)

Read about cardinality once and you will nod. Watch it happen once and you will never do it. Do this on a scratch branch you are going to throw away.

Answer
git checkout -b cardinality-lesson

First, count the series you have now. In the Prometheus expression box:

count({__name__=~"taskd_.*"})

__name__ is the built-in label holding the metric name, so this counts every series whose name starts with taskd_. Note the number.

Now add the forbidden label. In cmd/api/metrics.go, add a fourth label name and pass the raw path as its value — the exact mistake §4.5 warns about:

// cmd/api/metrics.go — DO NOT SHIP THIS. Scratch branch only.
    }, []string{"method", "route", "status", "path"})
// cmd/api/metrics.go — the matching call site, inside metricsMiddleware
    httpRequestDuration.
        WithLabelValues(r.Method, route, strconv.Itoa(ww.Status()), r.URL.Path).
        Observe(time.Since(start).Seconds())

Rebuild, restart, and fetch a run of individual tasks so that each request has a different path:

for i in $(seq 1 50); do
  curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks/$i
done

Wait for a scrape, then re-run the count query. Each distinct path adds a full histogram — ten buckets plus +Inf plus _sum and _count, so thirteen series each. Fifty made-up ids is already several hundred series where you had a couple of dozen, and every one of them is kept for the whole retention window even though the task may never be requested again. Extrapolate to a real task table on paper; you do not need to run that, and you should not.

A user_id label is the identical bug in different clothing: it is unbounded because it comes from your data rather than from routes.go.

Then:

git checkout . && git checkout main && git branch -D cardinality-lesson

Write the number in learnings/ch18.md. The number is the lesson.


12. FAQ

What is the actual difference between logs and metrics? They both record what happened. The difference is what they are cheap at. A log entry is one row per event with everything in it — perfect for “why did request a3f9 fail”, useless for “how many failed this week” because you have to read them all. A metric is one number per thing measured, already added up — instant for “how many”, incapable of “which one”. You reach for the metric to find out that there is a problem and how big, then the log to find out why. Any system that has only one of them makes one of those two questions expensive.

Why does Prometheus pull instead of my app pushing? Pushing sounds simpler. Pushing sounds simpler and is not. A pushing app needs a buffer, a retry policy, a decision about what to drop when the buffer fills, and configuration telling it where to send. Every one of those is a new failure mode inside your process, and the worst of them — “metrics silently stopped arriving” — needs a second monitoring system to detect. With pull, your app serves a page; if it cannot be reached, Prometheus records up=0 and that is already the alert. Push has real uses (short-lived batch jobs that exit before anyone could scrape them; Prometheus has a Pushgateway for exactly that), but a long-running server is not one.

Is this a lot of code for something so simple? metrics.go is about 100 lines and roughly half of it is the pool collector, which exists to fulfil a promise made in Chapter 6 and to teach you an interface you will meet again. The instruments and the middleware are maybe 40 lines. What you get is every route’s rate, error rate and latency distribution, forever, plus Go runtime metrics you did not write at all. It is one of the highest ratios of value-to-typing in the book.

How much does instrumenting cost at runtime? Per request: one map lookup to find the right label combination, a few atomic increments, and two clock reads. Hundreds of nanoseconds against a request measured in milliseconds — not measurable against your database call. The cost that matters is Prometheus’s memory, which is governed entirely by cardinality. Instrument freely; label carefully.

Do I need Grafana? No. Everything in this chapter is done in Prometheus’s own UI at localhost:9090, which graphs single expressions perfectly well. Grafana is a separate dashboard tool that talks to Prometheus and gives you saved dashboards, several panels on one screen, and better sharing. The original edition assumes you already run one; if you do not and want to try it, add four lines to docker-compose.yml — but understand this is an optional extra, not part of the taskd stack:

# docker-compose.yml — OPTIONAL, not required by any later chapter
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"

Then open localhost:3000 (login admin/admin), add a Prometheus data source pointing at http://prometheus:9090, and paste the three queries from Step 11 into three panels. The same goes for Dozzle, mentioned in passing as a log viewer: a small web UI over docker logs, entirely optional, and not something this book installs.

Should /metrics be public? No, and the honest answer is that we have not fixed it yet. Right now it is on the same port as your API, which is fine while that port is only reachable from your laptop or a private network. The moment a reverse proxy is in front of it, that page hands anyone your route patterns (your whole API surface), your library versions and your traffic volumes. Chapter 27 (Production checklist) makes the call: either the proxy refuses to route /metrics, or it sits behind basic auth. The stricter option — a second http.Server on an internal-only port serving this handler alone — is about ten lines and is the right answer if the service ever handles anything sensitive.


13. Where we are

The service now reports on itself. Every request is counted, timed and bucketed by route pattern and status; the connection pool’s health is visible; cache hits and misses are countable; and a Prometheus container is storing all of it every fifteen seconds. Chapter 6’s promise about pool sizing is kept, Chapter 4’s promise about the ResponseWriter wrapper is kept, and Chapter 13’s foreshadowed cache-hit-ratio metric exists.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go            # UPDATED: MustRegister(newPoolStatsCollector(pool))
│   ├── server.go
│   ├── routes.go          # UPDATED: metricsMiddleware + GET /metrics
│   ├── config.go
│   ├── db.go
│   ├── middleware.go
│   ├── helpers.go
│   ├── errors.go
│   ├── context.go
│   ├── healthcheck.go
│   ├── metrics.go         # NEW: instruments, middleware, pool collector
│   ├── tasks.go           # UPDATED: the two cacheOps lines
│   ├── users.go
│   ├── tokens.go
│   ├── billing.go
│   ├── webhooks.go
│   └── entitlements.go
├── internal/
│   ├── cache/             # cache.go ratelimit.go
│   ├── data/              # filters.go plans.go tasks.go users.go tokens.go
│   ├── db/                # sqlc output
│   └── validator/
├── migrations/            # 000001 … 000005_billing
├── sql/queries/           # tasks.sql users.sql tokens.sql billing.sql
├── prometheus.yml         # NEW: the scrape config
├── docker-compose.yml     # UPDATED: prometheus service
├── Makefile   sqlc.yaml   config.toml
└── go.mod   go.sum

What works end to end: the full product from Chapter 17, plus a /metrics page a Prometheus container scrapes every 15 seconds, plus three PromQL queries that answer “how fast”, “how broken” and “how well is the cache working”.

What is still fake or missing:

  • The logs cannot be joined to the metrics. A p99 spike tells you which route; nothing yet tells you which request. Chapter 19 (Logging that pays rent) adds request IDs so the two sides meet.
  • /metrics is publicly routable in principle. The decision is deferred to Chapter 27 (Production checklist), and it is a decision, not an oversight.
  • The scrape target is host.docker.internal. Chapter 25 (Docker) puts taskd into Compose and changes it to api:4000.
  • No alert rules exist. The three queries are the expressions; Chapter 27 adds thresholds and somewhere to send them.
  • No dashboard. Optional, and the FAQ tells you how if you want one.

For your notes

Copy these into learnings/ch18.md, in your own words:

  1. Metrics tell you that and how much; logs tell you why. The alert fires on a metric; the investigation reads the logs. Neither replaces the other.
  2. Patterns, never paths. Never user IDs, emails or tokens as label values. Every unique label combination is a separate time series held in memory — method × route × status is about 1,400 series and boring; adding user_id is millions and an out-of-memory kill.
  3. Averages hide your worst-served users; percentiles do not. 100 ms average is compatible with 1% of requests taking 8 seconds. That is why we store bucket counts, and why histograms beat summaries: counts can be added across servers, quantiles cannot.
  4. Middleware that measures runs its code on the way back out. The route pattern and the status code do not exist until the handler has run, and a plain ResponseWriter will not tell you the status at all — hence the wrapper.
  5. Pull beats push for a long-running server. Your app serves a text page and nothing more; there is no sender, no buffer, no retry queue, and a failed scrape is already an alert (up == 0).

Chapter 19 — Logging that pays rent

Chapter 4 (A server that dies well) gave taskd a log line per request: method, path, remote address, duration. It was enough to prove the server was alive. It is not enough to answer the only question logging exists to answer, which is “a customer says it broke — what happened?” This chapter turns that line into something a tired human can actually work with: every request gets a short random request ID that goes into the response, into the request’s context, and onto every log line the request produces. Plus status and byte counts, a rule for what must never appear in a log, and a clear statement of which questions belong to logs and which belong to the metrics you built in Chapter 18 (Prometheus: metrics that answer questions).

What you’ll be able to do by the end

  • Take an ID out of a user’s email and find every log line their failing request produced.
  • Explain out loud which facts belong in a metric and which belong in a log field.
  • Read a JSON log line and name every field in it, including why duration is a huge integer.
  • Filter a live log stream with jq — server errors only, one request only.
  • Say what must never be written to a log, and why “one debug line” is how it starts.

Time: ~35 minutes reading, ~20 minutes typing.

You need before starting: a working Chapter 18 (Prometheus: metrics that answer questions) — that is, metricsMiddleware registered in routes.go and a /metrics endpoint. Prove it in two commands. In one terminal, with Postgres up:

docker compose up -d db
go run ./cmd/api

In another:

curl -s localhost:4000/metrics | grep taskd_http_requests_in_flight

You should get three lines: a # HELP line, a # TYPE ... gauge line, and the metric name followed by a number. If curl prints Connection refused, the server is not running; if the grep finds nothing, Chapter 18’s metrics.go is not wired into routes.go.


1. The problem, in plain words

It is 02:00. Your phone goes off because an alert fired — the metric you built last chapter says the 500-rate crossed its threshold. You open the logs. There are forty thousand lines from the last ten minutes, because your service is doing its job for everybody else at the same time.

New word

paged / on-call — being woken by an automatic alert because a system you are responsible for is misbehaving. “A 3 a.m. page” is the industry’s shorthand for the worst way to find out.

Somewhere in those forty thousand lines are the six lines belonging to the one request that failed. They are not next to each other. Between the line where the handler started and the line where the database gave up, two hundred other requests wrote their own lines. Sorting by time does not help — you need lines that belong together, not lines that happened at the same time.

Now the second version of the same night. A customer emails: “it failed, and the app showed me this code”. The code is twelve characters long. You paste it into one command and get back every line that request produced, in order, and nothing else. The whole investigation is one filter away.

Think of it like

This is a complaint reference number. Nobody at an airline searches for “the passenger who was unhappy on Tuesday”. They ask for the booking reference, type it in, and the entire history of that one journey appears. The reference is worthless to the customer and priceless to the staff.

The difference between those two nights is one random string, generated once per request and attached to everything that request touches. That is what correlation means, and it is most of this chapter.

The other half is discipline about what a log line contains. A log is a permanent, copied, shipped-around record. Anything you put in it goes wherever the logs go — the developer’s laptop, the log search tool, the backup bucket, the third-party service. Which makes “I’ll log the request body while I debug this” one of the most expensive four-second decisions in the trade.

What breaks if you skip this chapter: nothing, today. The service runs. Then one real customer has one real problem, and you discover that your logs can tell you the weather but not the news.


2. New words in this chapter

Word What it means here
structured logging Logging as labelled key=value fields instead of prose, so tools can filter and count them.
log level How important a line is — debug, info, warn, error — so production can keep the loud ones and drop the chatty ones.
attribute (field) One labelled value on a log line, e.g. status=404. slog calls them attributes.
correlation Tagging every line belonging to one request with a shared ID so they can be found together.
request ID That shared ID. Twelve hex characters here — short enough to read over the phone.
X-Request-ID The HTTP header carrying the request ID in and back out. Not an official standard, but the near-universal convention.
hex (hexadecimal) Base-16 text using 09 and af. One byte becomes exactly two hex characters.
CSPRNG A random-number source safe for security use, because its output cannot be predicted from previous output. crypto/rand is Go’s.
LogAttrs The slog call that takes pre-typed attributes instead of loose key/value pairs; cheaper on hot paths.
hot path Code that runs on every single request, where a small cost multiplies by your traffic.
response writer wrapper An object that stands in front of the real http.ResponseWriter and records what passed through it — the status code and byte count. Chapter 18 introduced it.
PII Personally Identifiable Information — names, emails, addresses. Logging it accumulates legal risk.
redaction Deliberately leaving a value out of a log, or replacing it with a placeholder.
sampling Keeping only a fraction of a very high-volume log line (e.g. one in a hundred) to control cost.
log aggregation Collecting logs from wherever they were written into one searchable place.
jq A command-line tool that filters and reshapes JSON. Our proof that JSON logs were worth it.
Dozzle A small web page that shows Docker container logs live in a browser.
Loki A log database from the Grafana project; you ship JSON lines into it and query them.
distributed tracing Following one request across several services, each recording its own timing.
span One timed step inside a trace — “the database call took 8 ms” — nested inside the parent request.
OpenTelemetry (OTel) The industry-standard libraries and format for tracing. Unnecessary while there is one binary.
retention How long you keep logs before deleting them. A cost decision and a legal one.

3. The goal

Turn the Chapter 4 request logger into production-grade structured logging: request IDs that tie a request’s log lines together (and travel to the client for support tickets), status and byte counts on every line, noise control, and a clear statement of the log / metric / trace division of labour.


4. The thinking

The test of a log line

The test is whether future-you, at 02:00, can go from a customer’s complaint to a cause. That requires three properties, and a line missing any one of them is decoration.

Property What it means Without it
Correlation All lines of one request are findable together — the request ID. You have events but no stories.
Context Status, duration, and the user where lawful and needed. You know a request happened, not whether it went well.
Parseability JSON in production, so any tool can filter it. You are writing regular expressions at 02:00.

Parseability is the one people argue about, so be concrete. Compare the same event written two ways:

prose:      2026-04-02 11:04:19 GET /v1/tasks 500 in 812ms from 10.0.0.4

structured: {"time":"2026-04-02T11:04:19.882+02:00","level":"INFO",
             "msg":"request","method":"GET","path":"/v1/tasks",
             "status":500,"duration":812000000,"remote":"10.0.0.4"}

Both contain the same facts. Only the second can answer “show me every request slower than one second, grouped by path” without a human writing a parser first. Dozzle, jq, Loki, or anything else can filter the second one. Printf prose cannot be queried; it can only be read.

New word

structured logging — writing each log line as labelled fields rather than a sentence. Go’s log/slog, which Chapter 3 (Configuration and logging, the Nadh way) wired up, does this: a text handler with key=value pairs for human eyes in development, a JSON handler for machine eyes in production, chosen by the same config value.

What not to log is the more senior skill

Three categories, all of them learned the expensive way by somebody else:

  1. Bodies. Chapter 10 (Users and passwords) described the password-logging incident that every company has: a debug line printing the request body, a registration endpoint, and now plaintext passwords sit in the log archive of a system with different access rules than the database. Bodies also carry customer content you have no business copying.
  2. Tokens and secrets. The bearer token from Chapter 11 (Stateful tokens) is a password with a shorter life. A logged token is a working credential lying in a text file.
  3. Per-request DEBUG chatter in production. It costs money to store and it buries the lines that matter. Loud logs are the same failure as no logs, arrived at from the other direction.

The boundary with metrics

Last chapter built counters and histograms. This chapter builds log lines. Knowing which is which saves you from two common, opposite mistakes.

Remember this

If you’re grepping logs to compute a rate, that number wanted to be a metric. If you’re adding a metric label to identify one user, that fact wanted to be a log field.

Question Belongs to Why
“How many 500s per minute?” metric Pre-aggregated, cheap to keep for years, instant to query.
“Which user hit that 500, and what did the database say?” log Full detail for one event; useless as a number.
“Is p99 latency creeping up?” metric A distribution over millions of events.
“Why was this request slow?” log One event, and a request ID to gather its lines.
“Where in a chain of six services did it stall?” trace Not us: taskd is one binary.

The trap in the third column is cardinality, which Chapter 18 spent a page on: a metric label holding a user ID or a request ID creates one stored time series per value, and Prometheus runs out of memory. Logs have the opposite economics — they are per-event by nature, so identifying detail is exactly what belongs there.

Where the request ID comes from

Two sources, and we accept both:

Source When Trade-off
Inbound X-Request-ID header A trusted proxy in front of us minted one (Caddy can). One ID for the whole hop chain — the proxy’s access log and ours agree. Only safe when the proxy is the only route to the app.
Minted by us Everything else, including local development. Always available, never forgeable by a client, costs six random bytes.

Then that one ID goes to three places: the response header, so the user can quote it; the request context, so code deep inside the request can attach it to its own output; and every log line the request writes.

A header is not something a human sees on their own, so the first of those three has a condition attached: whatever calls this API has to surface it. curl -i prints it for you. A browser front end has to read X-Request-ID off the failed response and put it on its error screen, which is one line of that front end’s code and the reason this whole feature is worth the trouble.

When a user emails “it failed”, you ask for the ID from their error response and jump straight to the exact request. This tiny feature has the best effort-to-gratitude ratio in operations.

New word

Caddy — a web server commonly placed in front of an application to terminate HTTPS and forward requests inward. Chapter 27 (Production checklist, and where to go next) puts one in front of taskd. A server in that position is called a reverse proxy.

Tracing, and why not yet

The request ID is also the gateway drug to distributed tracing. OpenTelemetry is this idea industrialised across services: each service records timed spans, all carrying the same trace ID, and a UI draws them as a waterfall so you can see which service ate the 800 ms.

With one binary, the request ID covers you. OTel waits until a second service exists.

Note

This is a genuine trade-off, not a dodge. OTel gives you span timing inside one process too. It also adds a dependency, a collector to run, and a sampling policy to reason about — for a service where “which of my services was slow” has exactly one possible answer.


5. A picture of it

Where the ID comes from, and where it goes

            request arrives
                  │
                  │  does it already carry X-Request-ID?
                  ▼
        ┌────────────────────────┐
        │       logRequest       │
        │  yes ──▶ reuse it      │  (a trusted proxy minted it)
        │  no  ──▶ mint one      │  (6 random bytes -> 12 hex chars)
        └───────────┬────────────┘
                    │  one id, three destinations
      ┌─────────────┼──────────────┐
      ▼             ▼              ▼
 ┌──────────┐  ┌──────────┐  ┌───────────────┐
 │ response │  │ request  │  │ the log line  │
 │  header  │  │ context  │  │ "id": "9f2c…" │
 └──────────┘  └──────────┘  └───────────────┘
  the user      deep code      your filter
  can quote it  can read it    finds it
  1. The middleware looks for an inbound X-Request-ID header.
  2. If there isn’t one, it makes six random bytes and turns them into twelve hex characters.
  3. It writes the ID back as a response header — this is the part the customer sees.
  4. It puts the ID into the request’s context, the per-request bag of values that travels with the request through every middleware and handler.
  5. When the request finishes, it writes one log line carrying the same ID.

The log line is written on the way out

  in  ▶ recoverPanic ▶ metrics ▶ logRequest ▶ authenticate ▶ handler
  out ◀ recoverPanic ◀ metrics ◀ logRequest ◀ authenticate ◀────┘
                                     │
                                     └─ the one log line is written HERE,
                                        after the handler returned, when
                                        status, size and duration exist

A middleware is a function that wraps a handler, so it can run code before the request reaches the handler and after the response comes back. Everything before next.ServeHTTP happens on the way in; everything after it happens on the way out. The status code does not exist on the way in — nobody has decided it yet.

One request, many lines, one ID

This is what the payoff looks like: a failing request, filtered out of a busy log by its ID. The error line comes first because it is written deep inside the request; the request line is always last, because it is written on the way out.

  filter: everything whose id is a450dfbc1f27
  ──────────────────────────────────────────────────────────────
  {"level":"ERROR","msg":"<what went wrong>","method":"GET",
   "path":"/v1/tasks","id":"a450dfbc1f27"}
  {"level":"INFO","msg":"request","id":"a450dfbc1f27","method":"GET",
   "path":"/v1/tasks","status":500,"bytes":80,"duration":9214583}
                       └──────── the join that makes this work ───┘

Metrics, logs, traces

   WHAT happened,          WHY it happened,        WHERE it happened,
   how much, how often     in full detail          across services
   ┌───────────────┐       ┌───────────────┐       ┌───────────────┐
   │    METRICS    │       │     LOGS      │       │    TRACES     │
   │   (ch. 18)    │       │   (ch. 19)    │       │   (not yet)   │
   ├───────────────┤       ├───────────────┤       ├───────────────┤
   │ numbers, pre- │       │ one line per  │       │ one timeline  │
   │ aggregated,   │       │ event, full   │       │ per request,  │
   │ cheap to keep │       │ detail, more  │       │ spanning many │
   │ for years     │       │ expensive     │       │ processes     │
   └───────────────┘       └───────────────┘       └───────────────┘
    the alert fires         the investigation       one binary: the
    from here               reads these             request id is it

6. The steps

There are seven, and the first five are one idea split into digestible pieces: mint an ID, carry it, log it, and make the error path carry it too.

Step 1 — Give the context a key to store the ID under

Chapter 11 (Stateful tokens) created cmd/api/context.go with one key in it, userContextKey, plus the helpers that put a user into the request context and take it out again. We need a second key for the request ID.

// cmd/api/context.go — add this const under userContextKey

// requestIDKey joins userContextKey here in ch. 19: logRequest mints (or
// accepts) a request id and stashes it in the context so every log line
// of one request shares an id.
const requestIDKey = contextKey("request_id")
Note

The original edition mentions this line in a parenthesis and never prints it, so a reader typing from the page gets undefined: requestIDKey and no way to know what the declaration should look like. This is its first appearance in print.

What this code says, line by line

  • contextKey is a type declared in context.go as type contextKey string. It is a string underneath, but it is its own type, which is the whole point.
  • contextKey("request_id") converts the plain string into that type.
  • Values are stored in a context under a key of any type. If we used the plain string "request_id", any library that also used the plain string "request_id" would collide with us — silently, in production. A private named type in our own package cannot collide with anyone, because nobody else can name it.
New word

context — a per-request bag of values and cancellation signals that flows through every middleware and handler. r.Context() gets it; context.WithValue(parent, key, val) returns a new context with one more value in it. Contexts are never modified in place.

Step 2 — Add the imports logRequest is about to need

middleware.go is about to use five things it has never used. Add them to the import block at the top of the file, keeping the standard-library group and the third-party group separate as the rest of the codebase does.

// cmd/api/middleware.go — add to the existing import block

import (
    // ... the imports already there (errors, fmt, net, net/http, strings,
    // sync, time, crypto/sha256 ...)
    "context"       // context.WithValue
    "crypto/rand"   // unpredictable random bytes
    "encoding/hex"  // bytes -> "9f2c1a4b7e30"
    "log/slog"      // slog.LevelInfo, slog.String, slog.Int ...

    chimw "github.com/go-chi/chi/v5/middleware" // the response writer wrapper
)

What this code says, line by line

  • chimw "github.com/..." is an aliased import: inside this file the package is called chimw rather than its real name middleware, which would otherwise be confusing next to our own file called middleware.go. Chapter 18’s metrics.go uses the same alias. Imports are per-file, so each file that wants the package imports it again.
  • crypto/rand, not math/rand. Both have a Read function and your editor will happily autocomplete the wrong one. math/rand produces numbers that look random and are predictable from previous output; crypto/rand is a CSPRNG, backed by the operating system’s randomness.
Tip

If you use gopls (the editor integration) or run goimports, the imports are added for you when you save. Type the code first, then look at what the tool added, then check it is the package you meant. That last step is not optional with rand.

Step 3 — Replace logRequest with its final form

This is the chapter. The Chapter 4 version logged method, path, remote and duration. This version mints or accepts the request ID, echoes it, puts it in the context, wraps the response writer so it can report the status and the byte count, and writes one line on the way out.

// cmd/api/middleware.go — replaces the whole logRequest function
func (app *application) logRequest(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Accept an upstream request ID (Caddy can mint one) or create
        // a short random one. 6 random bytes -> 12 hex chars: unique
        // enough to grep for, short enough to read over the phone.
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            b := make([]byte, 6)
            rand.Read(b) // crypto/rand
            reqID = hex.EncodeToString(b)
        }
        // Echo it to the client: this header is what a user quotes in
        // a support ticket, and it jumps you to their exact request.
        w.Header().Set("X-Request-ID", reqID)

        ctx := context.WithValue(r.Context(), requestIDKey, reqID)
        ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
        start := time.Now()

        next.ServeHTTP(ww, r.WithContext(ctx))

        app.logger.LogAttrs(ctx, slog.LevelInfo, "request",
            slog.String("id", reqID),
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.Int("status", ww.Status()),
            slog.Int("bytes", ww.BytesWritten()),
            slog.Duration("duration", time.Since(start)),
            slog.String("remote", r.RemoteAddr),
        )
    })
}

What this code says, line by line

  • r.Header.Get("X-Request-ID") reads a request header. Missing header means an empty string, not an error — Go’s header map returns the zero value.
  • make([]byte, 6) creates a slice of six bytes, all zero. A byte is a number from 0 to 255.
  • rand.Read(b) overwrites those six bytes with cryptographically random ones. Since Go 1.24 this call never returns an error (the standard library crashes the program rather than handing back weak randomness), which is why ignoring the return value here is safe rather than sloppy.
  • hex.EncodeToString(b) turns each byte into two characters from 0-9a-f. Six bytes in, twelve characters out. Six bytes is 48 bits, about 281 trillion possible values. Duplicates arrive faster than that number suggests — the odds that some pair in a pile of IDs matches reach one in two at around twenty million requests, not at 281 trillion — but twelve characters is short enough to read over the phone, and a service at taskd’s size will not get there. Six bytes is a size choice, not a law: eight bytes and sixteen characters is the same code with a bigger 6.
  • w.Header().Set(...) adds the header to the response. This must happen before the handler writes anything, and it does, because it is above next.ServeHTTP.
  • context.WithValue(r.Context(), requestIDKey, reqID) produces a new context carrying the ID.
  • chimw.NewWrapResponseWriter(w, r.ProtoMajor) wraps the response writer. A plain http.ResponseWriter is write-only: once a handler has sent a status code, there is no method to ask it what that status was. The wrapper is a one-way mirror that remembers. r.ProtoMajor is 1 for HTTP/1.1 and 2 for HTTP/2, so the wrapper can match the abilities of the connection underneath.
  • next.ServeHTTP(ww, r.WithContext(ctx)) runs the rest of the chain — with the wrapper in place of the writer and a copy of the request carrying the new context. Passing w here instead of ww is the classic mistake; the code still compiles and every logged status is 0.
  • app.logger.LogAttrs(ctx, slog.LevelInfo, "request", ...) writes the line. "request" is the message; everything after it is an attribute.
  • ww.Status() and ww.BytesWritten() are the two facts only the wrapper knows: the status code the handler chose, and how many bytes of body reached the client.
  • time.Since(start) is the duration of the whole request, measured across the entire chain inside this middleware.
New word

LogAttrs — the fussier of slog’s two APIs. logger.Info("request", "status", 200) takes loose alternating key/value pairs and works out the types at runtime; LogAttrs takes pre-typed slog.Attr values (slog.Int("status", 200)) and does no such work. The second form is a little more typing and noticeably less work per call. This function runs on every request, which is exactly where that matters.

Why this exists

Why is the whole line written after the handler instead of one line before and one after? Because status, byte count and duration do not exist before, and because two lines per request doubles your log bill to tell you the same story. One line, on the way out, with everything on it.

What you should see

Nothing new yet — the code compiles and behaves as before, plus a response header. Two commands later, in Step 7, it is visible. If it does not compile, jump to Section 8; the four likely errors are all listed there with their exact text.

Step 4 — Add the accessor so deep code can reach the ID

The ID is in the context. Anything holding the context can now read it, but not by reaching into ctx.Value by hand at every call site. One helper, next to the user helpers in context.go:

// cmd/api/context.go — add below contextGetUser

// requestID lets deep code (logError, background work) enrich its output
// with the id of the request it belongs to. Missing id = empty string:
// this one is not worth a panic.
func (app *application) requestID(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)
    return id
}

What this code says, line by line

  • ctx.Value(requestIDKey) returns any — Go’s “value of unknown type”. You cannot use it until you say what you expect it to be.
  • .(string) is a type assertion: “treat this as a string”. In the two-result form, id, ok := x.(string), ok reports whether the assertion held instead of panicking when it did not. This is the same comma-ok form Chapter 11 used on the user value.
  • id, _ := discards the ok. If the value is missing or is not a string, id is the zero value, the empty string, and that is the behaviour we want. Compare contextGetUser, which panics: a handler with no user in context means the route was wired without the auth middleware, a programmer error worth screaming about. A missing request ID means a log line is slightly less useful. Different severities, different reactions.

Step 5 — Join the error line to the request line

Right now, when something fails, two lines are written about it: the ERROR line from logError and the request line from logRequest. They do not know about each other. Adding the ID to logError is what makes the join possible — the join is the whole point.

Chapter 8 (CRUD done properly) wrote logError as two lines. Here is the final version:

// cmd/api/errors.go — replaces the whole logError function

// logError: server-side detail, never shown to clients.
func (app *application) logError(r *http.Request, err error) {
    // ch. 19: the request id joins the error line to the request line,
    // so one grep collects everything that happened to one request.
    app.logger.Error(err.Error(),
        "method", r.Method,
        "path", r.URL.Path,
        "id", app.requestID(r.Context()),
    )
}
Important

The attribute key must be "id" — the same key logRequest uses. A filter matching .id finds both lines only if both lines spell it the same way. request_id on one line and id on the other means the join silently fails and you never find out until 02:00.

What this code says, line by line

  • err.Error() turns the error into its message string. slog’s Error method wants a string message; handing it the error value itself does not compile.
  • The loose "key", value form is used here rather than LogAttrs, matching how the rest of errors.go was written. Error paths are not hot paths, so the cheaper API buys nothing.
  • app.requestID(r.Context()) is the accessor from Step 4. logError receives the request, and the request carries the context logRequest enriched — so the ID is there for free.

Both places in errors.go that report a server-side fault — serverErrorResponse, and errorResponse’s last-resort branch for when writing the response itself fails — funnel through logError. So this one change puts the ID on every 500 raised by a handler or by the middleware inside logRequest.

Note

One 500 escapes it, and it is worth knowing about. recoverPanic is registered outside logRequest in routes.go, so the request it holds is the original one — the copy carrying the ID never reaches it. A panic therefore produces an ERROR line with "id":"", and no request line at all, because the panic unwinds past logRequest before its LogAttrs call is reached. The client still gets the X-Request-ID header. Moving recoverPanic inside logRequest would fix the join and cost you the guarantee that panics thrown by logRequest are caught, which is why the original leaves the order alone.

Step 6 — The noise policy: three lines of judgment

Policy first, then the code.

  1. Log the healthcheck at Debug, not Info. An uptime monitor and a container orchestrator both poll /v1/healthcheck every few seconds, forever. At Info those lines will outnumber your real traffic and you will pay to store the fact that your server was fine.
  2. Keep production at info via config. The log level is the dial that decides which lines survive: TASKD_LOG__LEVEL=info in the environment, which Chapter 3 (Configuration and logging, the Nadh way) already wired to log.level. Development stays at debug, the default in config.toml, so the quiet lines are still there when you want them.
  3. Resist “log at start and end of request”. One line per request, on the way out, with everything on it.

The original edition states rule 1 in prose and never prints the code. Here it is — three lines above the LogAttrs call, choosing the level from the path:

// cmd/api/middleware.go — inside logRequest, between next.ServeHTTP and the log call

        next.ServeHTTP(ww, r.WithContext(ctx))

        // Noise policy: an uptime monitor hits /v1/healthcheck every few
        // seconds and Prometheus scrapes /metrics every 15s. Both are
        // real requests; neither is news. Debug keeps them in dev and
        // drops them in production, where the level is info.
        level := slog.LevelInfo
        switch r.URL.Path {
        case "/v1/healthcheck", "/metrics":
            level = slog.LevelDebug
        }

        app.logger.LogAttrs(ctx, level, "request",
            // ... attributes unchanged
        )

Because a three-line insertion into the middle of a function is exactly the change beginners get wrong, here is the whole function afterwards:

// cmd/api/middleware.go — logRequest, complete, after Step 6
func (app *application) logRequest(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-ID")
        if reqID == "" {
            b := make([]byte, 6)
            rand.Read(b) // crypto/rand
            reqID = hex.EncodeToString(b)
        }
        w.Header().Set("X-Request-ID", reqID)

        ctx := context.WithValue(r.Context(), requestIDKey, reqID)
        ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
        start := time.Now()

        next.ServeHTTP(ww, r.WithContext(ctx))

        // Uptime monitors and the Prometheus scrape are real requests
        // that are never news: Debug keeps them out of production logs.
        level := slog.LevelInfo
        switch r.URL.Path {
        case "/v1/healthcheck", "/metrics":
            level = slog.LevelDebug
        }

        app.logger.LogAttrs(ctx, level, "request",
            slog.String("id", reqID),
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.Int("status", ww.Status()),
            slog.Int("bytes", ww.BytesWritten()),
            slog.Duration("duration", time.Since(start)),
            slog.String("remote", r.RemoteAddr),
        )
    })
}

What this code says, line by line

  • level := slog.LevelInfo declares a variable holding a level value; slog.Level is a number underneath, where higher means more severe.
  • switch r.URL.Path { case ... } compares the raw path against two literals. Not the route pattern — these two endpoints have no path parameters, so the raw path is exact and cheap.
  • The level is then passed to LogAttrs in place of the hardcoded slog.LevelInfo.
  • /metrics is in the list for the same reason as the healthcheck, and it also settles the loose end from Chapter 18’s pitfall about “scraping yourself into the stats”: the scrape still counts in the metrics, but it stops filling your logs.
Note

The lines the handler itself logs are unaffected. If the healthcheck ever calls serverErrorResponse, that ERROR line is still written at error level and still carries the request ID. Only the routine one-line-per-request summary is quietened.

Step 7 — Confirm the contract in production mode

The original’s instruction, verbatim:

TASKD_APP__ENV=production TASKD_LOG__LEVEL=info go run ./cmd/api
curl -D- localhost:4000/v1/healthcheck   # note X-Request-ID in response
# {"time":"...","level":"INFO","msg":"request","id":"a1b2c3...", "status":200,...}

What this command says, word by word

  • TASKD_APP__ENV=production sets an environment variable for this one command only. Chapter 3’s config loader maps TASKD_APP__ENV to the app.env key (double underscore stands in for the dot, because shells forbid dots in variable names), and newLogger switches from the text handler to the JSON handler when env is production.
  • TASKD_LOG__LEVEL=info maps to log.level, raising the bar so Debug lines are dropped.
  • curl -D- dumps the response headers to standard output — -D takes a filename and - means “standard output”. That is how you see X-Request-ID at all.
Note

That commented sample line was written before Step 6’s noise policy existed. With the policy in place, the healthcheck’s request line is a Debug line, so at info you get the header and no log line. The shape of the line is exactly as shown, and the next section makes it appear.

Then pipe it through jq 'select(.status >= 500)' once, to feel why JSON logs won. Section 7 does that with a command you can copy.


7. Checkpoint: prove it works

Four things to prove: the header comes back, the ID is in the log line, the noise policy works, and jq can filter the stream.

7.1 — Development mode: one line per request, in text

Start the server the ordinary way (Postgres up, go run ./cmd/api). In another terminal:

curl -is localhost:4000/v1/healthcheck | grep -i x-request-id

You should see one header line, X-Request-Id: followed by twelve characters drawn from 0-9 and a-f — a different twelve every time you run it. (curl -i includes headers in the output; -s silences the progress meter; grep -i matches case-insensitively, because HTTP header names are not case-sensitive and different tools capitalise them differently.)

In the server’s terminal, the matching log line looks like this — the values after id= and duration= will be different, and so will the port on the end of remote:

time=2026-04-02T11:04:19.882+02:00 level=DEBUG msg=request id=cd9dd6e66930
method=GET path=/v1/healthcheck status=200 bytes=83 duration=28.541µs
remote=127.0.0.1:56156

(That is one line; it is wrapped here to fit the page.) It is DEBUG because of Step 6, and you can see it because config.toml sets log.level = "debug" for development.

Now hit a path that is not on the quiet list:

curl -is localhost:4000/v1/nope | head -1

HTTP/1.1 404 Not Found, and in the server terminal one more line — this one at level=INFO, with path=/v1/nope and status=404.

7.2 — Production mode: JSON, and the healthcheck goes quiet

Stop the server with Ctrl-C and start it the other way:

TASKD_APP__ENV=production TASKD_LOG__LEVEL=info go run ./cmd/api

The startup lines are now JSON objects rather than key=value text. Then, from the other terminal, run the healthcheck and the 404:

curl -sD- localhost:4000/v1/healthcheck -o /dev/null | grep -i x-request-id
curl -s localhost:4000/v1/nope > /dev/null

Expected: the first command still prints the X-Request-Id header, and the server logs nothing for it. The second produces one JSON line of this shape (again, your time, id, duration and remote will differ):

{"time":"2026-04-02T11:04:19.884262+02:00","level":"INFO","msg":"request",
 "id":"a450dfbc1f27","method":"GET","path":"/v1/nope","status":404,
 "bytes":54,"duration":27292,"remote":"127.0.0.1:56160"}
Checkpoint

Header present on both requests, log line on the 404 only. If the healthcheck still logs a line in production mode, Step 6 is not in your logRequest — or you started the server without TASKD_LOG__LEVEL=info.

duration is a large plain integer because the JSON handler encodes a Go duration as nanoseconds. 27292 nanoseconds is 27 microseconds. The text handler prints the friendlier 27.292µs. Same value, two audiences.

That is a change from Chapter 4 (A server that dies well), which logged "duration", time.Since(start).String() — a string, so JSON got "duration":"27.292µs". A string is nicer to look at and impossible to compare, which is why Step 3 switched to slog.Duration. jq 'select(.duration > 250000000)' needs a number; it cannot do arithmetic on "27.292µs".

7.3 — Filter the stream with jq

jq reads JSON, one object per line, and prints the ones you ask for. Install it with brew install jq (macOS) or sudo apt install jq (Debian/Ubuntu) — Before you begin installed it already, so jq --version may answer first. Restart the server in production mode with its output piped through jq:

TASKD_APP__ENV=production TASKD_LOG__LEVEL=info go run ./cmd/api \
  | jq 'select(.status >= 500)'

Now generate ordinary traffic — the 404 above, a healthcheck, anything that works — and watch nothing appear. That is the point: the only lines that reach your eyes are the ones you asked for. select(...) is a filter that emits the object when the condition is true and nothing when it is false; .status reads the field of that name.

Two other filters worth keeping:

# every line belonging to one request
jq 'select(.id == "a450dfbc1f27")'

# requests slower than 100 ms (durations are nanoseconds)
jq 'select(.duration > 100000000)'

If it went wrong

What you got Cause Fix
jq: parse error: Invalid literal at line 1, column 19 The server is in development mode, so the lines are text, not JSON. jq was handed prose and gave up on the first line. (The column number is wherever your timestamp stops looking like JSON.) Start it with TASKD_APP__ENV=production.
No X-Request-Id header at all The w.Header().Set line is below next.ServeHTTP, so it ran after the response was already sent. Move it back above next.ServeHTTP.
"status":0,"bytes":0 on every line next.ServeHTTP(w, ...) is passing the original writer, so the wrapper never saw the response. Pass ww, not w.
Nothing appears at all, even for the 404 TASKD_LOG__LEVEL was set to warn or error. Use info.

8. Common mistakes (and the quick fix)

You’ll see It means Fix
cmd/api/middleware.go:64:41: undefined: requestIDKey (the line and column point into your file, so the numbers will differ) Step 1 was skipped: the constant does not exist. Add const requestIDKey = contextKey("request_id") to context.go.
requestIDKey redeclared in this block / other declaration of requestIDKey It is declared twice, usually once in context.go and once in middleware.go. Keep the one in context.go, delete the other.
undefined: rand / undefined: hex Step 2 was skipped: the imports are missing. Add "crypto/rand" and "encoding/hex".
"crypto/rand" imported and not used The imports are there but the minting block is not — Go refuses to compile an unused import. Type the if reqID == "" block, or remove the imports until you do.
declared and not used: ww The wrapper is created but the log line does not use ww.Status() / ww.BytesWritten(). Add both attributes back.
cannot use slog.LevelInfo ... as context.Context value in argument to app.logger.LogAttrs LogAttrs was called without its first argument. The signature is LogAttrs(ctx, level, msg, attrs...).
Every ID is identical across restarts, or IDs look sequential math/rand was imported instead of crypto/rand — the editor autocompleted the wrong one. Change the import. Both packages have a Read; only one is unguessable.
Two request lines per HTTP call A logging line was left above next.ServeHTTP as well as below. Delete the one on the way in.
The error line and the request line have different ID field names logError uses a key other than "id". Use "id" in both, exactly.
Common mistake

You’ll see: the request ID in the log, but the customer’s error page has a different one. It means: something in front of the app is minting its own ID and not passing it on, or two requests were made (the browser retried). Fix: configure the proxy to set X-Request-ID and forward it; our middleware reuses an inbound one, which is exactly what that branch is for.


9. Pitfalls

Logging PII by drift

Today it is email “just for this one debug line”; a year later it is a compliance finding.

Warning

Decide the allowed field set and treat additions as reviews, not reflexes. We log user ID where needed, never email.

   what the request contains          what the log line may carry
   ────────────────────────────       ──────────────────────────────
   method, path            ─────────▶  method, path         yes
   status, size, duration  ─────────▶  status, bytes, ms    yes
   the user's database id  ─────────▶  user_id              yes, id only
   Authorization: Bearer … ── stop ──  never
   {"notes":"card 4242 …"} ── stop ──  never
   the user's email        ── stop ──  never

The reason an ID is allowed and an email is not: the ID is meaningless outside your database, and you can trade it for an email in one query when you actually need to. The email is meaningful to anybody who reads the log file, forever, including whoever ends up with a copy of it. Chapter 21 (Background work and transactional email) will add an activation token to the system — that one is a working credential, so it is in the same category as the bearer token.

Note that the left-hand column above is not “delete these values”. The request still carries them; we choose not to copy them into the log. That deliberate leaving-out is called redaction, and doing it at the point where the line is written is far safer than doing it later, in the log pipeline, where a misconfiguration means the secret was stored anyway.

New word

PII — Personally Identifiable Information: names, emails, addresses, phone numbers. Data protection law (GDPR in Europe, and its equivalents elsewhere) treats a log file full of it as data you are storing, with all the obligations that follow — including deleting it on request.

slog levels aren’t free

Building expensive attributes for a Debug line that is filtered out still costs the building.

This surprises people, so be clear about the mechanism: Go evaluates every argument to a function before the function runs. In

// illustrative — not a line in taskd; any file, any handler

app.logger.Debug("cache state", "keys", expensiveKeyDump())

expensiveKeyDump() runs on every request even when the logger is at info and throws the line away. The filtering happens inside Debug, which is too late.

For hot paths, guard with the level check first — or better, do not put Debug logging in hot paths:

// only build the attributes if something will actually consume them
if app.logger.Enabled(r.Context(), slog.LevelDebug) {
    app.logger.Debug("cache state", "keys", expensiveKeyDump())
}

Enabled asks the handler whether a line at that level would be kept. It is a comparison of two numbers, and when the answer is no, the if skips the body — so nothing inside it, including expensiveKeyDump(), ever runs.

Multi-line output

Panics and some libraries print bare multi-line text that shreds JSON-line parsing. A tool reading one JSON object per line hits a stack trace and gives up on the whole block — usually the exact block you needed.

Our recoverPanic from Chapter 4 routes panics through slog already: it catches the panic, converts it to an error, and hands it to serverErrorResponse, which logs one structured line. Keep it that way when adding libraries. A dependency that writes to standard output with fmt or the old log package is a dependency that damages your logs, and the fix is usually a one-line option in that library pointing it at slog.

Trusting an inbound request ID from anybody

Our middleware reuses an inbound X-Request-ID header. In front of a trusted proxy this is what you want. Exposed directly to the internet, a client can send the same ID on every request and make your logs unsortable, or send a very long one and make your log lines enormous.

Note

The original chapter is explicit that the inbound branch is for “a trusted proxy” — this is the operational consequence. Chapter 27 (Production checklist, and where to go next) is where the proxy becomes the only route to port 4000, which is the assumption this branch depends on. Until then, on your laptop, nothing else is sending the header.


10. Check yourself — quiz

  1. Why is the request log line written after next.ServeHTTP rather than before it?
  2. You move w.Header().Set("X-Request-ID", reqID) to the line after next.ServeHTTP. The code compiles. What breaks, and why?
  3. slog.Int("bytes", ww.BytesWritten()) — bytes of what, exactly?
  4. A colleague changes crypto/rand to math/rand because “it’s faster and the tests pass”. Give two concrete reasons to change it back.
  5. Metric or log field: (a) the number of 402 responses per hour; (b) which user got the 402; © the p99 latency of POST /v1/tasks; (d) the SQL error text behind one 500.
  6. Production runs the JSON handler and development the text handler. Why not use JSON in both and have one format to learn?
  7. What does app.logger.Enabled(ctx, slog.LevelDebug) protect you from, given that Debug lines are dropped at info anyway?
  8. This snippet compiles and runs. What appears in the log, and why?
// cmd/api/middleware.go — a broken variant of the inside of logRequest

ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(w, r.WithContext(ctx))
app.logger.LogAttrs(ctx, slog.LevelInfo, "request",
    slog.Int("status", ww.Status()))
Answers
  1. Because status, byte count and duration do not exist until the handler has finished. Logging on the way in would mean either a second line afterwards (double the volume for one story) or a line missing the three fields you most want at 02:00.

  2. The header never reaches the client. Headers must be set before the response body starts; once the handler has called WriteHeader or written a byte, later header changes are silently ignored. Silently is the dangerous word — nothing errors, the support workflow stops working.

  3. Bytes of response body written to the client, counted by the wrapper as they pass through. Headers are not included, and neither is anything the handler built but did not write.

  4. First: math/rand’s output is predictable — observing a few IDs lets someone compute the rest, and an attacker who can guess request IDs can make convincing false support reports or probe your log search. Second: crypto/rand is the CSPRNG the rest of this codebase already uses for values that must be unguessable (Chapter 11’s tokens), and having two different randomness habits in one package is how the wrong one ends up on a token.

  5. (a) metric — a rate over time. (b) log field — identifying one user in a metric label is the cardinality mistake from Chapter 18. © metric — a distribution, which is what histograms are for. (d) log field — one event, full detail, of no use as a number.

  6. Because the two readers are different. In development a human is watching a terminal, and level=INFO msg=request status=404 is readable at a glance while a JSON object is not. In production the reader is a machine — jq, Dozzle, Loki — and it needs the structure. One config value (app.env) switches handlers, so no code cares which is in use.

  7. From building the arguments. Go evaluates every argument before calling the function, so an expensive value passed to Debug is computed even when the line is discarded inside the call. Enabled is a cheap number comparison that lets you skip the construction entirely.

  8. "status":0. The wrapper was created but never used: next.ServeHTTP was handed the original w, so the response bypassed the wrapper and ww recorded nothing. Zero is chi’s “no status has been sent through me” value. This is a bug that no compiler catches — only reading the log does.


11. Practice

Exercise 1 — Follow one failing request from complaint to cause

Make a request fail for real, then find both of its log lines by their shared ID — the workflow this whole chapter exists for.

Solution

Run the server in production mode, saving the output to a file as well as the screen:

TASKD_APP__ENV=production TASKD_LOG__LEVEL=info go run ./cmd/api | tee taskd.log

In a second terminal, break the database and make an authenticated-looking request:

docker compose stop db
curl -is -H "Authorization: Bearer aaaaaaaaaaaaaaaaaaaaaaaaaa" \
  localhost:4000/v1/tasks | grep -iE 'HTTP/|x-request-id'

The bearer value is 26 characters because Chapter 11’s authenticate middleware rejects anything else before it reaches the database. You should get HTTP/1.1 500 Internal Server Error and an X-Request-Id header. Copy that ID, then:

jq --arg id "PASTE_THE_ID_HERE" 'select(.id == $id)' taskd.log

Two objects come back: an ERROR line whose msg is the connection failure the pgx driver reported, and the INFO request line with "status":500. The error line comes first — it is written deep inside the request; the request line is always written last.

(--arg id VALUE defines a jq variable so you do not have to fight shell quoting.)

Put the database back:

docker compose start db

Note what the healthcheck does not give you here: with the database down it returns 503 and logs no error line, because it handles the ping failure itself. The join only exists on paths that go through logError.

Exercise 2 — A server-errors-only view, and a slow-request view

Build two jq filters you would actually keep: one that shows only server errors, and one that shows only requests slower than 250 ms. Verify each one fires.

Solution
# server errors only
jq 'select(.status >= 500)' taskd.log

# slow requests only — durations are nanoseconds, so 250ms = 250000000
jq 'select(.duration > 250000000)' taskd.log

To prove the first one fires, reuse Exercise 1’s stopped database — the 500 appears and nothing else does. To prove the second fires without waiting for a genuinely slow request, lower the threshold to something your machine will exceed (> 100000, i.e. 0.1 ms) and confirm lines start appearing; then put the real threshold back.

A compact live view of only the fields you care about:

TASKD_APP__ENV=production TASKD_LOG__LEVEL=info go run ./cmd/api \
  | jq -r 'select(.msg=="request") | "\(.status) \(.duration/1000000)ms \(.path) \(.id)"'

-r prints raw strings without quotes, and \(...) inserts a value into a string. This is the whole reason for choosing JSON in production: the format is a data structure, so a one-line program can reshape it however the moment requires.

Exercise 3 — Add user_id to the request line (and not the email)

Authenticated requests should log which user made them. Add a user_id attribute to the request line, then write down in learnings/ch19.md why the ID is allowed and the email is not.

This one has a trap in it, which is why it is the last exercise: logRequest runs outside authenticate, and the user is not known until authenticate has run.

Solution

The trap first. authenticate does r = app.contextSetUser(r, &user), which builds a new request with a new context and passes it downstream. Context values flow inward, never back outward — so by the time logRequest resumes after next.ServeHTTP, its own r is the old one and calling app.contextGetUser(r) panics with missing user value in request context.

The fix is to put a pointer into the context on the way in. Both ends then share one struct, and the inner middleware can fill it in.

// cmd/api/context.go — add above AnonymousUser

// logFieldsKey holds a POINTER to a struct that logRequest creates and
// downstream middleware fills in. A plain context value cannot travel
// back up the chain; a pointer to a struct can, because both ends share
// the same struct.
const logFieldsKey = contextKey("log_fields")

type logFields struct{ userID int64 }

func (app *application) contextLogFields(ctx context.Context) *logFields {
    lf, _ := ctx.Value(logFieldsKey).(*logFields)
    return lf
}
// cmd/api/middleware.go — inside logRequest, two edits

        ctx := context.WithValue(r.Context(), requestIDKey, reqID)
        lf := &logFields{}
        ctx = context.WithValue(ctx, logFieldsKey, lf)

        // ... unchanged ...

        app.logger.LogAttrs(ctx, level, "request",
            // ... the seven existing attributes ...
            slog.Int64("user_id", lf.userID),
        )
// cmd/api/middleware.go — inside authenticate, immediately before the
// final contextSetUser(r, &user), not the AnonymousUser one above it

        if lf := app.contextLogFields(r.Context()); lf != nil {
            lf.userID = user.ID
        }

        r = app.contextSetUser(r, &user)
        next.ServeHTTP(w, r)

Verify: register and log in as in Chapter 11, then

curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks > /dev/null

The request line now carries user_id with your user’s id. Anonymous and unauthenticated requests log user_id=0, because that is the zero value of the field nobody filled in — which reads correctly as “no user”.

The learnings/ch19.md note, in your own words: the ID is a number that means nothing outside our database and can be exchanged for an email in one query when a human genuinely needs one. The email is personal data that would then exist in every place logs are copied to, under retention rules nobody set deliberately, subject to deletion requests we could not honour. The log gets the key, not the person.


12. FAQ

Why JSON logs? They are unreadable. They are unreadable to you, in a terminal, which is why development does not use them — app.env picks the text handler there. Production logs are read by tools far more often than by humans, and when a human does read them it is through jq, Dozzle or Loki, all of which render JSON nicely. The moment you need “every 500 in the last hour on this path”, the format that is a data structure wins and the format that is a sentence loses.

Where do logs actually go in production? Ours go to standard output, and that is deliberate — a program that writes its own log files has to solve rotation, disk-full and permissions, all of which the surrounding system already solves. Chapter 25 (Docker: a 15 MB production image) puts taskd in a container, at which point Docker captures standard output and docker compose logs -f api reads it. From there the usual options are a browser UI over the container logs (Dozzle) or shipping the lines into a log database (Loki), which is where log aggregation stops being a word and starts being a search box.

Should I log request bodies? No. Not “not by default” — no. Bodies contain passwords on the registration route, customer content everywhere else, and card-adjacent data eventually. If you need to see a specific body to debug a specific problem, reproduce it locally with the request ID telling you exactly which request to reproduce. That is the alternative the ID buys you.

What is the difference between a log, a metric and a trace? A metric answers what and how much: numbers, pre-aggregated, cheap to keep for years, and the thing an alert fires from. A log answers why: one line per event with the full detail, and the thing the investigation reads. A trace answers where, across services: one timeline per request made of spans, each service adding its own. With a single binary, the request ID gives you the useful part of the third one for free.

How long should I keep logs? Long enough to investigate a complaint that arrives late, short enough that the bill and the privacy exposure stay small. Two weeks to thirty days of full request logs is an ordinary choice for a small service; anything you want for a year should have become a metric. Setting a retention period is a decision someone must make on purpose, because the default — keep everything forever — is the expensive one and the legally awkward one.

Do I need OpenTelemetry? Not with one binary. Adopt it when you have a second service and the question “which one was slow?” becomes real, or when a managed platform gives you tracing for the cost of an import. The work in this chapter is not wasted then: propagating an ID from an inbound header, through the context, onto every line, is precisely the shape OTel formalises — it standardises the header (traceparent), the identifiers and the wire format.


13. Where we are

taskd now produces logs a human can work with under pressure: one line per request, machine parseable in production, quiet about the endpoints that poll it, and every line belonging to one request carrying the same ID — including the error line, which is the join that turns a complaint into a cause.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go
│   ├── server.go
│   ├── routes.go
│   ├── config.go
│   ├── db.go
│   ├── middleware.go      # UPDATED: logRequest final form + noise policy
│   ├── helpers.go
│   ├── errors.go          # UPDATED: logError carries the request id
│   ├── context.go         # UPDATED: requestIDKey + requestID accessor
│   ├── healthcheck.go
│   ├── metrics.go
│   ├── tasks.go
│   ├── users.go
│   ├── tokens.go
│   ├── billing.go
│   ├── webhooks.go
│   └── entitlements.go
├── internal/
│   ├── cache/             # cache.go ratelimit.go
│   ├── data/              # filters.go tasks.go users.go tokens.go plans.go
│   ├── db/                # sqlc output
│   └── validator/
├── migrations/
├── sql/queries/
├── Makefile   sqlc.yaml   config.toml
├── docker-compose.yml     prometheus.yml
└── go.mod   go.sum

What works end to end: every request gets an ID, echoes it to the client, and logs one line carrying status, byte count and duration; every server-side error logs a second line with the same ID; production emits JSON at info and stays quiet about the healthcheck and the metrics scrape.

What is still fake or missing:

  • Nothing collects the logs. They exist on one terminal. Chapter 25 (Docker: a 15 MB production image) makes the container’s standard output the collection point.
  • There are no tests. Chapter 20 (Testing what matters) is next, and it will exercise this middleware stack for real through httptest.
  • Nothing in the app writes a log line outside a request yet. Chapter 21 (Background work and transactional email) adds background work, which is the first code that has to carry a request ID it did not mint.
  • The inbound-ID branch is untested in anger, because nothing is in front of the app. Chapter 27 (Production checklist, and where to go next) puts a proxy there.

For your notes

Copy these into learnings/ch19.md, in your own words:

  1. One ID, three destinations: the response header, the context, every log line. The header is what the customer quotes; the context is how deep code reaches it; the log line is where you search. Miss any one of the three and the workflow does not close.
  2. Metrics tell you that and how much; logs tell you why. If you are grepping logs to compute a rate, that wanted to be a metric. If you are adding a metric label to identify one user, that wanted to be a log field.
  3. The log line is written on the way out, once. Status, bytes and duration do not exist before the handler runs, and two lines per request cost twice as much to tell one story.
  4. We log the user’s ID, never their email, and never bodies or tokens. An ID is meaningless outside our database; everything else in that list is either personal data or a working credential, permanently copied wherever logs go.
  5. Arguments are built before the function that discards them runs. A Debug line at info level still pays for whatever you passed it — guard hot paths with logger.Enabled, or keep Debug out of them.

Chapter 20 — Testing what matters

Everything in taskd works. You know this because you have typed a curl command after every chapter and read the answer with your own eyes. That method has one fatal property: it only tells you about the thing you changed most recently. Nobody re-runs nineteen chapters of curl commands before a deploy. This chapter replaces that ritual with a program that does it for you in a second — and, more importantly, teaches you how to decide which checks are worth owning, because a test suite is not free and a badly chosen one costs more than it earns.

What you’ll be able to do by the end

  • Write and run an automated test in Go, and read the pass/fail report it prints.
  • Explain, out loud and convincingly, why this book tests handlers against a real PostgreSQL instead of a fake one.
  • Spin your actual router up on a real port inside a test, and drive it exactly as curl would.
  • Prove that two users cannot see each other’s data — as a machine-checked assertion, not a memory of a terminal session.
  • Run the race detector and read its report when it finds a concurrency bug.
  • Say why “100% coverage” is a target that makes test suites worse.

Time: ~45 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 19 (Logging that pays rent) — which means the whole stack from Chapter 5 onward. Two commands prove it:

docker compose up -d db
go build ./...

The first prints a line about starting or already-running the db container; the second prints nothing at all. In Go, a silent build is a successful build.


1. The problem, in plain words

Think about the last time you changed something in this codebase. In Chapter 12 you added AND user_id = $2 to six queries. In Chapter 17 you put a quota check in front of task creation. In Chapter 19 you rewrote the request logger. Each time, you ran a curl command, saw a sensible answer, and moved on.

Now answer this honestly: after Chapter 19’s changes, did you re-check that a second user still cannot read the first user’s tasks?

You did not. Nobody does. And that is exactly how the most expensive class of bug in software gets shipped — not a bug in the code you wrote today, but a bug your code today introduced into the code you wrote six weeks ago.

New word

regression — a thing that used to work and has quietly stopped working. Named because the software has regressed: gone backwards. Almost all serious production incidents are regressions, not brand-new features failing.

An automated test is a small program that exercises your real program and complains if the answer is wrong. Go has one built into the toolchain: you write a function in a file whose name ends _test.go, you type go test ./..., and every such function runs. There is nothing to install and no framework to choose.

That is the mechanics. The interesting question is the strategy, because you can write tests forever and you have a finite life. The wrong goal is “test everything”. The right goal, and the sentence this whole chapter hangs on, is confidence per minute of test runtime: for each hour you spend writing tests, how much real risk did you retire?

Think of it like

A restaurant kitchen does not taste every ingredient before every service. It tastes the sauce — because the sauce is where twenty ingredients meet, and where a mistake is both likely and invisible until a customer is eating it. Tests go where things meet.

What breaks if you skip this chapter: nothing today. In three months, a refactor drops a user_id filter, nobody notices, and one of your customers reads another one’s private notes. That is not a hypothetical failure mode for a multi-tenant SaaS; it is the failure mode.


2. New words in this chapter

Word What it means here
automated test A program that runs your program and fails loudly if the answer is wrong.
unit test A test of one small piece of logic in isolation, with no database and no network.
integration test A test that exercises several real parts together — here, handlers against a real Postgres.
end-to-end test A test that goes in the front door, through everything, and out again.
seam A place where two parts of the system meet, and therefore where they can disagree.
regression A feature that used to work and silently stopped.
fixture The known starting state a test sets up before it asserts anything.
table-driven test Go’s house style: a list of input/expected pairs, looped over, one sub-test each.
sub-test A named test inside a test, created with t.Run; it passes or fails on its own.
testing.T The object Go hands your test; you call methods on it to report failure, skip, or clean up.
t.Helper() Marks a function as scaffolding so failures point at the caller’s line, not the helper’s.
t.Cleanup(f) Registers f to run when this test finishes, whether it passed, failed or panicked.
t.Skip Declares this test not applicable right now; it is reported as SKIP, not PASS and not FAIL.
TestMain An optional function that runs once per package, around all its tests.
httptest Go’s standard helper for running your real router on a real port inside a test.
mock / stub / fake A stand-in object that pretends to be a dependency instead of being one.
mock drift When the fake stops behaving like the real thing, and the tests keep passing anyway.
test isolation Making each test independent of every other one.
flaky test A test that passes or fails depending on timing, order or luck. Worse than no test.
coverage The percentage of your lines a test suite executes. A measurement, not a goal.
data race Two goroutines touching the same memory at the same time, at least one of them writing.
race detector (-race) A Go build mode that watches memory during a run and reports data races.
TRUNCATE ... RESTART IDENTITY CASCADE Empty these tables, reset their id counters to 1, and follow foreign keys to dependants.
io.Discard A writer that accepts everything and keeps nothing — /dev/null as a Go value.
-count=1 Turns off Go’s test result cache, forcing every test to actually run.
-vet=off Turns off the small automatic code review go test normally runs first.

3. The goal

Here is the original book’s statement of what we are building, in its own words:

Not “coverage” — confidence per minute of test runtime. Unit tests for the pure logic (validator, filters, token generation), handler tests through real HTTP machinery with httptest, and integration tests against a real Postgres, because our riskiest code is SQL and SQL doesn’t run on mocks. Wired into make audit, ready for CI.

Two of those phrases need unpacking before we start.

“SQL doesn’t run on mocks.” If you replace your database with a hand-written fake, then every query you wrote is never executed by anything during testing. The CASE-based sort from Chapter 9 (Listing at scale), the AND user_id = $2 from Chapter 12 (Ownership), the optimistic lock’s AND version = $8 from Chapter 8 (CRUD done properly) — none of it runs. You have tested the wrapper around the risk and left the risk alone.

“Ready for CI.” CI is continuous integration: a server that runs your tests automatically every time you push code, and refuses to merge if they fail. We build that in Chapter 26 (CI/CD: the robot that says no). This chapter’s job is to make the tests exist and be runnable with one command.

Note

The original goal says the tests are “wired into make audit”. The audit target — which chains formatting checks, go vet, staticcheck, govulncheck and the tests into one gatekeeper command — actually arrives in Chapter 26, and Appendix B has the final version. This chapter adds make test and make test/int; Chapter 26 wraps them.


4. The thinking

Where’s the risk?

Where’s the risk? Not in writeJSON. It’s in the seams: queries (does the CASE-sort actually sort? does tenancy scoping actually scope?), auth middleware, webhook idempotency, quota edges. A testing strategy is just an honest risk list with assertions attached.

That last sentence is the whole method. Before writing a single test, write a list of the things that, if broken, would cost you money, customers or sleep. Then attach one assertion to each. The list for taskd, in order:

Risk If it breaks Where it lives
Tenancy scoping One customer reads another’s data WHERE ... AND user_id = $2
Quota / entitlement edges You give away the paid product, or bill for nothing resolveEntitlements, CountActiveTasks
Webhook idempotency A duplicated Stripe event double-applies a payment stripe_events ledger
Auth middleware Anonymous requests reach authenticated routes authenticate, requireAuthenticatedUser
The CASE sort Results come back in the wrong order ListTasks
Concurrency (409, limiter) Silent lost updates; a crash under load version column, limiter map
writeJSON …basically nothing. It has been running on every request for twelve chapters. helpers.go
New word

seam — a place where two parts meet: Go and SQL, handler and middleware, your code and Stripe’s. Bugs concentrate at seams because each side was written assuming something about the other, and only one of those assumptions gets tested by the person who wrote it.

The mock question

This is the part of the chapter people argue about, and the part worth reading twice.

The mock question. Classic layered answer: interface out the DB, mock it in handler tests. Cost: our handlers call sqlc-generated methods; mocking them means an interface with every query and a fake per test — hundreds of lines proving handlers call functions we told them to call, while the SQL itself — the risky part — goes untested. The Nadh-flavored alternative: test against real Postgres. It’s already one docker compose away, tests run in transactions-or-truncate isolation, and what passes is what ships. Slower per test (~ms, not µs), radically higher confidence per test. We mock only true externals with real cost or nondeterminism — Stripe (their CLI fixtures + our idempotency ledger make the webhook handler testable with canned payloads), and the clock if we ever need it.

New word

mock (also stub, fake — the distinctions are academic here) — a stand-in object that pretends to be a dependency. You hand it to the code under test in place of the real thing, and it returns whatever answers you programmed into it.

The standard advice you will read everywhere is: define a Go interface describing what your handlers need from the database, have the real database satisfy it in production, and hand the handlers a fake in tests. It is not stupid advice. It is badly priced here.

Count the cost against this codebase. Our handlers call app.q.GetTask, app.q.ListTasks, app.q.CreateTask, app.q.UpdateTask, app.q.DeleteTask, app.q.CountActiveTasks, app.q.GetSubscription, and a dozen more — all generated by sqlc from sql/queries/*.sql. To mock them you write an interface with every one of those methods, plus a fake implementation per test scenario. That is hundreds of lines whose entire content is: the handler called the function we told it to call. Meanwhile the SQL — the actual risk — is never executed.

Mock the database Use a real Postgres
Speed per test microseconds milliseconds
Needs a container running no yes
Tests your SQL no yes
Tests your tenancy filter no yes
Tests your CHECK constraints and foreign keys no yes
Lines of setup code hundreds about thirty, written once
Can silently stop resembling production yes (see mock drift) no
Good for third-party APIs, clocks, email your own data layer
Remember this

Real-DB tests can’t drift from the DB. A mock encodes what you believed about the database on the day you wrote it, forever. The real database encodes what is actually true today.

Be honest about the price, though: milliseconds instead of microseconds, and a Postgres container must be running or the tests cannot run at all. We solve the second problem by making the suite skip cleanly when there is no database, and the first by not caring — a suite that takes two seconds instead of two hundred milliseconds is still a suite you run on every save.

Note

“Nadh-flavored” refers to Kailash Nadh, one of the two influences named in How to read this book: boring technology, few dependencies, plain SQL you can read. His instinct here is that a real dependency you already run beats an abstraction you invented to avoid it.

The one thing we do mock

Stripe. Not because mocking is nicer, but because the alternative is unavailable: we cannot make Stripe’s servers send us a customer.subscription.updated event on demand inside a test, and network calls to another company are slow and nondeterministic. Instead we feed our own webhook handler a canned event payload — the same shape Stripe’s CLI produces — and check what it does to our tables. The idempotency ledger from Chapter 16 (Stripe II) makes this easy, because “what did this event do to the database” is exactly the question the handler already answers.

Test DB hygiene

Test DB hygiene: a dedicated database (taskd_test), migrations applied once per run, TRUNCATE ... RESTART IDENTITY CASCADE between tests. Parallel tests share a DB only if they share no rows — with per-test users (tenancy again, moonlighting as test isolation), they naturally don’t.

Three separate decisions there, each worth a sentence.

A dedicated database. Not a dedicated server — the same Postgres container, a second database inside it, called taskd_test. Tests destroy data. Your development database has your hand-made test accounts and the tasks you created while reading Chapter 8. Point the tests at that and you will lose it.

Migrations applied once per run. The test database needs the same tables as the real one, so the test command runs migrate ... up against it first. golang-migrate records which migrations have run, so this is safe to repeat: it applies only what is new.

TRUNCATE between tests. Every test starts by emptying the tables. That is what makes tests independent: no test can be affected by what a previous test left behind.

The last clause is the elegant bit. Each test registers its own users with its own email addresses, and because Chapter 12 scoped every query by user_id, one test’s user cannot see another test’s rows even if they are in the table at the same time. The multi-tenancy feature we built for customers turns out to be the test isolation mechanism too, for free.


5. A picture of it

The risk map

Here is the codebase sorted by how much it would hurt if it were wrong. The right-hand column is where we spend our test budget.

   LOW RISK — leave it alone           HIGH RISK — buy assertions here
 ┌───────────────────────────┐       ┌──────────────────────────────────┐
 │ writeJSON                 │       │ tenancy scoping                  │
 │ readIDParam               │       │   WHERE id = $1 AND user_id = $2 │
 │ envelope                  │       ├──────────────────────────────────┤
 │ (run on every request     │       │ webhook idempotency              │
 │  since ch. 8 — proven     │       │   stripe_events ledger           │
 │  by 12 chapters of use)   │       ├──────────────────────────────────┤
 └───────────────────────────┘       │ quota edges  n >= MaxActiveTasks │
                                     ├──────────────────────────────────┤
 ┌───────────────────────────┐       │ auth middleware                  │
 │ CalculateMetadata         │       ├──────────────────────────────────┤
 │ GenerateToken             │       │ the CASE sort in ListTasks       │
 │ validator.Check           │◀ unit ├──────────────────────────────────┤
 │ readJSON's error triage   │ tests │ optimistic lock → 409            │
 └───────────────────────────┘       └──────────────────────────────────┘
                                      ▲ handler tests, real Postgres

This book’s test pyramid

You will see “the test pyramid” drawn in every testing article. Ours has an unusual middle layer: the big band is not mocked unit tests, it is handler tests against a real database.

              ┌──────────────────────────────────┐
              │ mocked externals: Stripe only    │  canned payloads
              └──────────────────────────────────┘
        ┌──────────────────────────────────────────────┐
        │ handlers + REAL Postgres, via httptest       │  ~milliseconds
        │ (routing, middleware order, auth, SQL, JSON) │  each
        └──────────────────────────────────────────────┘
  ┌────────────────────────────────────────────────────────────┐
  │ pure logic: filters, tokens, password, validator, readJSON │  ~microseconds
  └────────────────────────────────────────────────────────────┘

Reading it bottom-up: the base is cheap and fast and catches arithmetic mistakes; the middle is where nearly all the value is, because that is where Go, SQL, HTTP and authentication meet; the top is small on purpose, because every mock you own is a mock you must maintain.


6. The steps

Step 1 — Meet go test (a five-minute detour)

Before writing a test, understand the machinery, because it is unusually small.

Go’s testing rules, complete:

  1. Test code lives in files ending _test.go. The compiler excludes them from your real binary, so test code never ships to production.
  2. A test is a function named TestSomething that takes one argument, t *testing.T. The name after Test must not begin with a lowercase letter.
  3. You report a problem by calling a method on t. t.Errorf(...) records a failure and keeps going; t.Fatal(...) records it and stops this test immediately.
  4. go test ./... compiles and runs every test in every package below the current directory.

That is the entire framework. There is no describe, no expect, no assertion library, and adding one is a choice this book declines to make.

Run it now, before you have written anything:

go test ./...

What you should see — one line per package, and since you have no tests yet, every line says so:

?   	github.com/yourname/taskd/cmd/api	[no test files]
?   	github.com/yourname/taskd/internal/cache	[no test files]
?   	github.com/yourname/taskd/internal/data	[no test files]
?   	github.com/yourname/taskd/internal/db	[no test files]
?   	github.com/yourname/taskd/internal/validator	[no test files]

The ? means “nothing to do here”. Later, packages with passing tests print ok and a duration.

Note

That is one line per package that exists in your repo right now, sorted by import path. Later chapters add packages — internal/mailer arrives in Chapter 21 (Background work and transactional email) — so your list grows. The shape is what matters, not the exact rows.

Step 2 — Write the first unit test, table-driven

We start with CalculateMetadata from Chapter 9 (Listing at scale) — the function that turns “there are 101 matching tasks, you asked for page 1 of 20” into the metadata block of a list response. It is pure arithmetic: same input, same output, no database, no clock, no network. That makes it a unit test in the strict sense.

It is also the function most likely to be subtly wrong, because it contains the classic off-by-one: 101 records at 20 per page is six pages, not five, and the last page has one task on it.

// internal/data/filters_test.go — new file
package data

import "testing"

func TestCalculateMetadata(t *testing.T) {
	tests := []struct {
		name           string
		total          int64
		page, pageSize int
		wantLast       int
	}{
		{"exact fit", 100, 1, 20, 5},
		{"partial last page", 101, 1, 20, 6},
		{"empty", 0, 1, 20, 0},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := CalculateMetadata(tt.total, tt.page, tt.pageSize)
			if got.LastPage != tt.wantLast {
				t.Errorf("LastPage = %d, want %d", got.LastPage, tt.wantLast)
			}
		})
	}
}

What this code says, line by line

  • package data — the test file joins the same package as the code it tests, so it can call CalculateMetadata without an import and can reach unexported things too. (Go also allows package data_test for testing only the public surface; we do not use it here.)
  • tests := []struct{ ... }{ ... } — this declares a struct type and creates a slice of it in one expression. Read it as: “a list of rows, where each row has a name, a total, a page, a pageSize and an expected answer”. The type has no name because it is never used anywhere else.
  • name string / total int64 / page, pageSize int / wantLast int — the columns of the table. The want prefix is a Go convention for “the expected value”, paired with got.
  • {"exact fit", 100, 1, 20, 5} — a row written positionally: the values line up with the fields above, in order. So this row means name: "exact fit", total: 100, page: 1, pageSize: 20, wantLast: 5. Counting the fields is not optional; miscount and the compiler tells you, which is the only reason this terse style is safe.
  • for _, tt := range tests — loop over the rows. _ discards the index; tt is the current row.
  • t.Run(tt.name, func(t *testing.T) { ... }) — run the body as a named sub-test. Each row gets its own pass/fail line and its own name in the output, so a failure tells you which case broke without you counting rows.
  • t.Errorf("LastPage = %d, want %d", ...) — record a failure and continue. A good failure message contains both numbers; “assertion failed” tells you nothing at 2 a.m.
Why this exists

Why a table instead of three separate test functions? Because adding a fourth case becomes one line instead of eight, and because the cases sit next to each other where you can see the gaps in your thinking. This shape is called a table-driven test and it is the dominant Go house style — you will see it in Go’s own standard library.

What you should see — run only this package:

go test ./internal/data/
ok  	github.com/yourname/taskd/internal/data	0.004s

Add -v (verbose) to watch the sub-tests individually:

=== RUN   TestCalculateMetadata
=== RUN   TestCalculateMetadata/exact_fit
=== RUN   TestCalculateMetadata/partial_last_page
=== RUN   TestCalculateMetadata/empty
--- PASS: TestCalculateMetadata (0.00s)
    --- PASS: TestCalculateMetadata/exact_fit (0.00s)
    --- PASS: TestCalculateMetadata/partial_last_page (0.00s)
    --- PASS: TestCalculateMetadata/empty (0.00s)
PASS
ok  	github.com/yourname/taskd/internal/data	0.004s

Note that spaces in a sub-test name become underscores.

Now break it on purpose — change {"partial last page", 101, 1, 20, 6} to expect 7 — and run again. You get:

--- FAIL: TestCalculateMetadata (0.00s)
    --- FAIL: TestCalculateMetadata/partial_last_page (0.00s)
        filters_test.go:21: LastPage = 6, want 7
FAIL
FAIL	github.com/yourname/taskd/internal/data	0.003s
FAIL

File, line, expected, actual. Line 21 is where the t.Errorf call sits in the file exactly as printed above, path comment included; if you left the comment out, yours says 20. Put the 6 back.

Tip

Watch every test fail once, deliberately, before you trust it. A test that has never failed is not a test; it is a decoration. This is the single most useful habit in this chapter.

The same shape, for the rest of the pure logic. The original chapter lists the other functions that earn a table test, and they are all the same pattern with different columns:

  • Token generation (Chapter 11, Stateful tokens): the plaintext is 26 characters, the stored hash equals sha256(plaintext), and 1,000 generations produce 1,000 distinct tokens.
  • Password.Set / Password.Matches (Chapter 10, Users and passwords): the right password matches, a wrong one does not, and Matches returns false, nil rather than an error for a wrong password. Run these at bcrypt cost 4 — see Pitfalls, below, for why.
  • validator.Check / PermittedValue (Chapter 8): the first error per field wins, and a value outside the safelist is rejected.
  • readJSON’s error triage (Chapter 8): feed it each kind of malformed body, assert each message. That switch statement is pure logic wearing an HTTP costume — no network is involved, only a body reader. You write this one yourself in Practice, below.

Step 3 — Build the test application fixture

Now the interesting layer. To test a handler we need an application — the struct from Chapter 2 (The skeleton) that carries the config, logger, database pool and query object — built for testing rather than for production.

New word

fixture — the known starting state a test begins from. Ours is: a connected pool pointing at taskd_test, empty tables, a silent logger, and an application wired to all of it.

// cmd/api/testutils_test.go — new file
func newTestApplication(t *testing.T) *application {
	t.Helper()

	dsn := os.Getenv("TASKD_TEST_DSN")
	if dsn == "" {
		t.Skip("TASKD_TEST_DSN not set; skipping integration tests")
	}

	cfg := config{env: "test"}
	cfg.db.dsn = dsn
	cfg.db.maxConns = 5

	pool, err := openDB(cfg)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)

	// clean slate per test
	_, err = pool.Exec(context.Background(),
		`TRUNCATE tasks, tokens, subscriptions, stripe_events, users
		 RESTART IDENTITY CASCADE`)
	if err != nil {
		t.Fatal(err)
	}

	return &application{
		config: cfg,
		logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
		db:     pool,
		q:      db.New(pool),
	}
}

Its imports, which the original leaves implicit:

// cmd/api/testutils_test.go — the top of the file
package main

import (
	"context"
	"io"
	"log/slog"
	"os"
	"testing"

	"github.com/yourname/taskd/internal/db"
)

What this code says, line by line

  • package main — the test file joins cmd/api’s own package. It must: application, config and openDB are all unexported, and only code inside the package can see them.
  • t.Helper() — tells Go “I am scaffolding”. Without it, a failure inside this function is reported at this file’s line number in every test that uses it. With it, the failure is reported at the line of the test that called it, which is the line you actually want.
  • os.Getenv("TASKD_TEST_DSN") — reads an environment variable. This is the switch that decides whether database tests run at all.
New word

DSN — Data Source Name: the single string that tells a driver where the database is and how to log in, e.g. postgres://taskd:pa55word@localhost:5432/taskd_test?sslmode=disable. Note the name at the end: taskd_test, not taskd.

  • t.Skip(...) — abandons the test with a “not applicable” verdict. Not a pass, not a failure: Go prints SKIP. This is what lets go test ./... succeed on a laptop with no database running, while CI — which always has one, because Chapter 26 gives it a Postgres service — runs everything. The suite degrades gracefully instead of failing where it cannot apply.
  • cfg := config{env: "test"} — build a config by hand instead of loading config.toml. Every field we do not set stays at its zero value, and one of those zeros matters: limiter.enabled is false, so rate limiting is off during tests. A test that fails because it exceeded a rate limit is a test that will waste an afternoon of your life. The second deliberate omission is in the struct we return below: it never sets cache, so app.cache is nil and every cache path takes the degraded branch Chapter 13 (Caching with DragonflyDB) built for exactly this.
  • cfg.db.dsn = dsnconfig nests anonymous structs as namespaces, so the field is addressed with two dots. Same struct, same layout as production.
  • cfg.db.maxConns = 5 — a small pool. Tests do not need twenty-five connections, and a test database is often shared.
  • openDB(cfg) — the real function from Chapter 6 (pgx), including its five-second timeout and its Ping. If the database is unreachable, we find out here with a clear error rather than twenty lines later.
  • t.Cleanup(pool.Close) — register pool.Close to run when this test finishes, however it finishes. This is defer that survives being written inside a helper: a plain defer pool.Close() here would close the pool before the test that needs it had even started.
  • pool.Exec(context.Background(), ...) — run a statement that returns no rows. context.Background() is the empty context — no deadline, no cancellation — which is right for test setup because there is no request to tie a lifetime to.
  • The TRUNCATE statement — decoded in full below.
  • slog.New(slog.NewTextHandler(io.Discard, nil)) — a real logger whose output goes nowhere. io.Discard is a writer that accepts bytes and drops them. Handlers still log, so nothing nil-panics, and your test output stays readable.
  • db.New(pool) — the sqlc-generated query object from Chapter 7 (sqlc). Note the collision that is not a collision: db is both a package name and the name of the application struct’s pool field. Go tells them apart by context — immediately left of a colon inside a struct literal it is a field name, anywhere else it is the package — so db: pool and q: db.New(pool) can sit two lines apart and mean different things.
New word

TRUNCATE tasks, tokens, subscriptions, stripe_events, users RESTART IDENTITY CASCADE — empty all five tables. RESTART IDENTITY resets the auto-generated id counters so the next row in every table is id 1 again. CASCADE tells Postgres to follow foreign keys and truncate any table that depends on these, which is what makes the statement legal at all when tasks.user_id references users.id. It is one statement, it takes microseconds, and unlike DELETE it does not scan.

Warning

TRUNCATE has no WHERE, no confirmation and no undo. The only thing standing between this function and your development data is the database name at the end of TASKD_TEST_DSN. Read it twice, every time you set it. It ends in taskd_test.

Tip

When a handler test misbehaves and you want to see what the server was thinking, change io.Discard to os.Stdout for one run. The logs appear inline with the test output. Change it back afterwards, or every future test run drowns.

Create the test database now — once, ever:

docker compose exec db createdb -U taskd taskd_test

docker compose exec db runs a command inside the already-running db container; createdb is one of Postgres’s own command-line tools; -U taskd is the user to connect as. On success it prints nothing. If you run it twice it complains — createdb: error: database creation failed: ERROR: database "taskd_test" already exists — and exits non-zero, which is harmless here: the database you wanted is there either way.

Step 4 — The three helpers the original does not print

The original chapter says the assertion helpers are “~25 lines you write once; resist importing an assertion framework for them” — and then does not print them. Here they are, from the working repository. This is the first time this book has shown them.

The argument against an assertion library is only won by seeing how little you give up, so read these with that question in mind.

// cmd/api/testutils_test.go — add below newTestApplication
// doJSON sends one request through the real router running on a real
// port, with the bearer token attached when there is one.
func doJSON(t *testing.T, srv *httptest.Server, method, path, token, body string) *http.Response {
	t.Helper()

	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req, err := http.NewRequest(method, srv.URL+path, rdr)
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	res, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { res.Body.Close() })
	return res
}

func assertStatus(t *testing.T, res *http.Response, want int) {
	t.Helper()
	if res.StatusCode != want {
		t.Errorf("status = %d, want %d", res.StatusCode, want)
	}
}

func assertBodyContains(t *testing.T, res *http.Response, want string) {
	t.Helper()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(body), want) {
		t.Errorf("body %s does not contain %q", body, want)
	}
}

Add encoding/json, fmt, net/http, net/http/httptest and strings to the file’s imports.

What this code says, line by line

  • var rdr io.Reader; if body != "" { rdr = strings.NewReader(body) } — a GET has no body, and http.NewRequest wants nil rather than an empty reader in that case. strings.NewReader turns a string into something readable as a stream.
  • srv.URL + pathhttptest picks a free port at random, so the base URL is not known until runtime. srv.URL is a string like http://127.0.0.1: followed by whatever port it got.
  • req.Header.Set("Authorization", "Bearer "+token) — exactly what curl -H has been doing since Chapter 11. The space after Bearer is part of the format.
  • srv.Client() — a *http.Client preconfigured for this test server. Using it rather than http.DefaultClient matters more later, when servers use TLS.
  • t.Cleanup(func() { res.Body.Close() }) — every response body is a stream that must be closed or its connection leaks. Registering the close here means no test ever has to remember.
  • t.Fatal in doJSON, t.Errorf in the assertions — deliberate. If the request could not even be sent, continuing is pointless: stop. If an assertion fails, keep going and collect the other failures too, so one run tells you everything that is wrong.

And the helper that gets a usable token, which the original describes as “2 POSTs”:

// cmd/api/testutils_test.go — add below the assertions
// registerAndLogin creates an account and returns a usable bearer token.
func registerAndLogin(t *testing.T, srv *httptest.Server, email string) string {
	t.Helper()

	res := doJSON(t, srv, "POST", "/v1/users", "",
		fmt.Sprintf(`{"name":"Test User","email":%q,"password":"pa55word123"}`, email))
	assertStatus(t, res, http.StatusCreated)

	res = doJSON(t, srv, "POST", "/v1/tokens/authentication", "",
		fmt.Sprintf(`{"email":%q,"password":"pa55word123"}`, email))
	assertStatus(t, res, http.StatusCreated)

	var out struct {
		AuthenticationToken struct {
			Token string `json:"token"`
		} `json:"authentication_token"`
	}
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		t.Fatal(err)
	}
	return out.AuthenticationToken.Token
}
  • fmt.Sprintf(..., %q, email)%q writes the value as a quoted Go string, so the email arrives inside the JSON already wrapped in " and correctly escaped. Using %s here would produce invalid JSON.
  • The nested anonymous struct mirrors the response shape from Chapter 11: {"authentication_token":{"token":"...","expiry":"..."}}. We declare only the fields we want; encoding/json ignores the rest.
Note

This helper’s lifespan is limited, and that is worth knowing now. Chapter 21 (Background work and transactional email) makes new users start unactivated and puts /v1/tasks behind requireActivatedUser. From that chapter on, two POSTs are not enough — the fixture has to flip activated directly with SQL, which means it also has to take the *application. Chapter 21 makes that change. A feature that changes an invariant changes the fixtures; that is normal maintenance, not a mistake in either chapter.

Step 5 — One end-to-end slice through the real router

Now the payoff: a single test that would catch a whole class of regressions — routing, middleware order, authentication, tenancy scoping and the JSON contract, all in one.

// cmd/api/tasks_test.go — new file
func TestTaskLifecycle(t *testing.T) {
	app := newTestApplication(t)
	// httptest.NewServer runs OUR real router on a real port — requests
	// travel the full stack: routing, middleware order, auth, JSON.
	// What this test passes is what curl would see.
	srv := httptest.NewServer(app.routes())
	defer srv.Close()

	token := registerAndLogin(t, srv, "a@example.com") // small helper: 2 POSTs

	// create
	res := doJSON(t, srv, "POST", "/v1/tasks", token,
		`{"title":"integration test"}`)
	assertStatus(t, res, http.StatusCreated)

	// isolation: a second user sees nothing
	token2 := registerAndLogin(t, srv, "b@example.com")
	res = doJSON(t, srv, "GET", "/v1/tasks", token2, "")
	assertBodyContains(t, res, `"tasks":[]`)

	// and cannot fetch user A's task
	res = doJSON(t, srv, "GET", "/v1/tasks/1", token2, "")
	assertStatus(t, res, http.StatusNotFound)
}

With the file header:

// cmd/api/tasks_test.go — the top of the file
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)
Important

The original chapter prints this assertion as assertBodyContains(t, res, `"tasks": []`), with a space after the colon. It can never match. Our listTasksHandler builds the body with json.Marshal, not MarshalIndent, and json.Marshal never pretty-prints — the bytes on the wire are {"metadata":{},"tasks":[]}. The space belongs to how JSON is displayed, not how it is sent. Corrected above, and the lesson is bigger than the typo: substring assertions on JSON are brittle for exactly this reason. When the shape matters, decode the body into a struct and assert on fields instead.

What this code says, line by line

  • httptest.NewServer(app.routes()) — start a real HTTP server, on a real TCP port on localhost, serving your real router. Not a simulation of one: an actual server that an actual browser could hit while the test runs.
  • app.routes() — the same function main() calls. Every middleware, in the same order, with the same route patterns.
  • defer srv.Close() — shut the server down when the test function returns.
  • "/v1/tasks/1" — hard-coded id 1, which is safe here only because RESTART IDENTITY reset the counter, so the first task created in this test is id 1. (Do not do this in a test where you did not create the row yourself; see Pitfalls.)
  • The final assertion is a 404, not a 403, because Chapter 12 decided that other people’s ids should be indistinguishable from ids that never existed.
Why this exists

Why bother with a real port when Go also lets you call a handler directly with httptest.NewRecorder()? Because a direct call skips the router (so a wrong URL pattern is invisible), skips the middleware chain (so a wrong order is invisible), skips authentication (so you must fake the user in the context by hand — and then you are testing your fake), and skips real HTTP parsing. httptest.NewServer(app.routes()) tests the thing you deploy.

Here is what actually happens when that test runs:

  newTestApplication(t)
        │
        ├─ TASKD_TEST_DSN set? ──no──▶ t.Skip   →  reported as SKIP
        │  yes
        ├─ openDB(cfg)  ─────────────▶ pgx pool (5 conns, 5s boot timeout)
        ├─ TRUNCATE ... RESTART IDENTITY CASCADE      (clean slate)
        └─ &application{config, logger→io.Discard, db, q}
                     │
        httptest.NewServer(app.routes())   ──▶ http://127.0.0.1:<random port>
                     │
   request ──▶ recoverPanic ──▶ metricsMiddleware ──▶ logRequest
           ──▶ authenticate ──▶ requireAuthenticatedUser ──▶ rateLimitUser
           ──▶ createTaskHandler ──▶ sqlc ──▶ REAL Postgres (taskd_test)
                     │
             assertStatus / assertBodyContains
                     │
        t.Cleanup: response bodies closed, pool.Close()

Walking it: (1) the fixture decides whether it can run at all; (2) it opens a real pool and wipes the tables; (3) it assembles the same struct main() assembles; (4) httptest puts the real router behind a real port; (5) each request passes through the real middleware chain in the real order and reaches real SQL; (6) assertions read the real response; (7) cleanups run whatever happened.

Siblings to add in the same style. The original names three, and they are the three highest value tests you can write next:

  • Optimistic-lock 409 — two clients read the same task, both write, the second is rejected. See Practice, exercise 1, which also explains why this one is subtler than it looks.
  • Quota 402 — a free-plan user hits MaxActiveTasks and gets 402 Payment Required. Arrange it either by overriding the plan map or by inserting a subscriptions row directly.
  • Webhook idempotency — POST the same canned Stripe event twice and assert the second one changes nothing. Construct it unsigned and call the apply function directly, or inject a test webhook secret and sign it with stripe-go’s test helpers. Signature verification is Stripe’s code, not your risk.

Step 6 — Make it one command, and honest about its dependency

Two Makefile targets: one that runs everything runnable without a database, one that supplies a database and runs the lot.

# Makefile — add these two targets
.PHONY: test
test:
	go test -race -vet=off ./...

.PHONY: test/int
test/int: export TASKD_TEST_DSN = postgres://taskd:pa55word@localhost:5432/taskd_test?sslmode=disable
test/int:
	migrate -path ./migrations -database $$TASKD_TEST_DSN up
	go test -race ./...

What this code says, line by line

  • .PHONY: test — tells make that test is a command, not a file it should look for on disk. Without it, a file called test in your directory would make make test decide there is nothing to do.
  • test/int: export TASKD_TEST_DSN = ... — a target-specific variable that is exported into the environment of that target’s commands. It is why newTestApplication finds the DSN when you run make test/int and does not when you run make test.
  • $$TASKD_TEST_DSN — two dollar signs. make eats the first one and passes $TASKD_TEST_DSN to the shell, which expands the environment variable. A single $ would make make try to expand its own variable named T followed by the letters ASKD_TEST_DSN, which is not what anyone wants and fails silently.
  • -race — build and run with the race detector. Non-negotiable here; see below.
  • -vet=off — skips the small automatic subset of go vet that go test runs over your test files before it runs the tests. The full go vet ./... lives in the audit target in Chapter 26, so running a slice of it here as well is waste.
Common mistake

You’ll see: Makefile:52: *** missing separator. Stop. — with whatever line number your new target landed on. It means: a recipe line under a target begins with spaces instead of a real tab character. make has required a literal tab there since 1976 and has never bent on it. Fix: delete the leading whitespace on that line and press Tab once. In VS Code, “Convert Indentation to Tabs” on the Makefile, and add "files.associations": {"Makefile": "makefile"} so the editor stops helpfully inserting spaces.

Note

Appendix B’s final Makefile writes the same target as go test -race -count=1 ./.... The -count=1 flag disables Go’s test result cache: by default Go remembers that a package’s tests passed and, if nothing in it changed, prints (cached) instead of running them again. That is excellent for pure unit tests and a liar for tests that touch a database, because the database can change without any Go file changing. Use -count=1 from the moment you have database tests.

Create the test database once (if you have not already):

docker compose exec db createdb -U taskd taskd_test

What -race is for. Every HTTP request in a Go server runs in its own goroutine — its own independently scheduled thread of execution. That means any data your handlers and middleware share is being touched by many goroutines at once. When two of them touch the same memory at the same time and at least one is writing, that is a data race, and its consequences range from a wrong number to an instant crash.

    goroutine A (request 1)      goroutine B (request 2)
            │                                │
            │  clients["1.2.3.4"]++          │  clients["1.2.3.4"]++
            └───────────────┐   ┌────────────┘
                            ▼   ▼
                      ┌──────────────────────┐
                      │  map[string]*client  │   ← same memory, same instant
                      └──────────────────────┘
                        Go maps are NOT safe for concurrent writes:
                        best case a wrong count, worst case a crash.

This codebase has three places built precisely for that hazard: the limiter map, its mutex and the cleanup goroutine that prunes it, all from Chapter 14 (Rate limiting); the singleflight group from Chapter 13 (Caching with DragonflyDB); and — from Chapter 21 onward — app.janitor(). -race is how you check that the locks around them are right.

Compiled with -race, Go instruments every memory access and reports races it observes. Here is a real report from a two-goroutine map increment with no mutex, trimmed of the runtime’s own stack frames and of the second goroutine’s creation site so it fits on the page:

==================
WARNING: DATA RACE
Read at 0x00c0000a0840 by goroutine 8:
  demo.TestLimiterMap.func1()
      /path/to/race_test.go:15 +0x80

Previous write at 0x00c0000a0840 by goroutine 9:
  demo.TestLimiterMap.func1()
      /path/to/race_test.go:15 +0xb0

Goroutine 8 (running) created at:
  demo.TestLimiterMap()
      /path/to/race_test.go:13 +0x58
==================
--- FAIL: TestLimiterMap (0.00s)
    testing.go:1617: race detected during execution of test
FAIL

Read it as: this line read that memory, that line wrote it, here is where each goroutine came from. The fix is always the same shape — put the shared thing behind a lock, or stop sharing it.

Warning

The race detector only reports races it actually observes during the run. A race that needs an unlucky interleaving may not appear this time. A clean -race run is strong evidence, not proof. It also makes programs several times slower and hungrier for memory, which is why it belongs in tests and CI, not in your production build.


7. Checkpoint: prove it works

1. The suite runs with no database at all. Stop the database if you like; this must still pass:

go test ./...

Expect ok for internal/data, ok for cmd/api (its one test skipped), and [no test files] for the rest:

ok  	github.com/yourname/taskd/cmd/api	0.011s
?   	github.com/yourname/taskd/internal/cache	[no test files]
ok  	github.com/yourname/taskd/internal/data	0.004s
?   	github.com/yourname/taskd/internal/db	[no test files]
?   	github.com/yourname/taskd/internal/validator	[no test files]

2. Prove the skip is a skip, not a pass. This is the important one:

go test -v -run TestTaskLifecycle ./cmd/api/
=== RUN   TestTaskLifecycle
    tasks_test.go:11: TASKD_TEST_DSN not set; skipping integration tests
--- SKIP: TestTaskLifecycle (0.00s)
PASS
ok  	github.com/yourname/taskd/cmd/api	0.010s

--- SKIP is the line to look for. -run TestTaskLifecycle filters to tests whose name matches that pattern. The line number points at your call to newTestApplication, not at the t.Skip inside it — that is t.Helper() doing its job, and durations will differ from the ones printed here.

3. The real thing, against a real database:

docker compose up -d db
docker compose exec db createdb -U taskd taskd_test   # first time only
make test/int

The migrate line prints one line per migration it applies (nothing, on later runs), then the test output appears. cmd/api should now report ok with a duration in the hundreds of milliseconds to a couple of seconds, because it really is talking to Postgres and really is hashing passwords with bcrypt.

If it goes wrong

You got Cause Fix
--- SKIP from make test/int The export line lost its target prefix, or a stray blank line separated it from the target Both lines must start with test/int:, adjacent, no blank line between
Makefile:NN: *** missing separator. Stop. Recipe lines indented with spaces Retype the indentation as a single tab
A t.Fatal at testutils_test.go naming a connection failure Postgres is not running, or taskd_test does not exist docker compose up -d db, then the createdb line above
--- FAIL: TestTaskLifecycle with status = 401 The token was not attached — check the Bearer prefix and its trailing space
--- FAIL with a body that is not "tasks":[] The list is not empty, so the previous test’s rows survived Confirm the TRUNCATE is in newTestApplication and that every test calls it

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: the whole suite passes in half a second, including TestTaskLifecycle. It means: almost certainly nothing ran. Without TASKD_TEST_DSN, t.Skip fires and Go reports the package as ok. A green suite that tested nothing is the most dangerous output in this chapter. Fix: run with -v and look for --- SKIP. Then set the DSN with make test/int.

Common mistake

You’ll see: your development data has vanished. It means: TASKD_TEST_DSN pointed at taskd, not taskd_test, and TRUNCATE did exactly what it was told. Fix: there is no fix; re-run your migrations and re-register your test users. Then check the database name at the end of the DSN, every single time. This is why the test database has a different name rather than a different server: one wrong character is easier to spot in .../taskd_test?sslmode=disable than in a port number.

Common mistake

You’ll see: cmd/api/tasks_test.go:11:9: undefined: application, under a header line reading # github.com/yourname/taskd/cmd/api_test [github.com/yourname/taskd/cmd/api.test], and then FAIL github.com/yourname/taskd/cmd/api [build failed]. Nothing runs. It means: your test file declares package main_test instead of package main — the _test on the end of the package name in that header is the tell. The suffix creates an external test package that can only see exported identifiers, and application is unexported. Fix: change the first line to package main. Same rule in internal/data: use package data.

Common mistake

You’ll see: go test ./... reports ok but your new test never appears with -v. It means: the file is not named *_test.go, or the function is not named TestSomething, or its signature is not exactly func TestSomething(t *testing.T). Go does not warn about any of these; it does not see a test at all. Fix: check all three. test_tasks.go is not tasks_test.go.

Common mistake

You’ll see: --- FAIL: TestTaskLifecycle on a run that passed a minute ago, with no code change. It means: leftover rows, or a test that depends on another test having run first. Fix: make sure every DB-touching test calls newTestApplication(t) — the TRUNCATE lives there and nowhere else. Then check you are not asserting on an id you did not create in this test.

Common mistake

You’ll see: a suite that takes twenty seconds and spends all of it doing nothing visible. It means: bcrypt at cost 12. Each password hash costs roughly a quarter of a second by design, and every registerAndLogin does one hash to register and one to log in. Fix: the package-level cost variable in Pitfalls, below.


9. Pitfalls

bcrypt at cost 12 in tests. Each hash takes about 250 ms; a 40-test suite spends 20 seconds hashing. That slowness is the entire point of bcrypt in production and pure waste in tests, where nobody is attacking you. The original’s fix is deliberately unglamorous: give Password.Set a package-level cost variable and lower it in TestMain.

// internal/data/users.go — the two-line change
// bcryptCost is a variable, not a constant, for exactly one reason:
// tests drop it to bcrypt.MinCost. Production never touches it.
var bcryptCost = 12

func (p *Password) Set(plaintext string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcryptCost)
	// ... unchanged
}
// internal/data/users_test.go — TestMain runs once, around every test in the package
func TestMain(m *testing.M) {
	bcryptCost = bcrypt.MinCost // 4 — fast, and worthless against attackers
	os.Exit(m.Run())
}

TestMain is Go’s one hook for per-package setup: if a package defines it, Go calls it instead of running the tests directly, and m.Run() is where the tests happen. os.Exit(m.Run()) passes the suite’s exit code straight out, which is how go test learns whether the package passed.

Remember this

This is the canonical example of what “design for testability” should mean: one variable, not one framework. Production behaviour is unchanged — the default is still 12.

Note

The assembled repository in the appendices keeps 12 written inline in Set. If yours does too, nothing is broken; the change above is the original’s prescription and is worth making the first time your suite feels slow.

Test interdependence via shared rows. The moment test B passes only after test A has run, you own a haunted suite: failures that appear and vanish depending on order, and hours lost to debugging a test rather than a program. The defences are TRUNCATE-per-test and per-test users, and one rule with no exceptions — never assert on an id you did not create in this test. RESTART IDENTITY makes ids deterministic, which is what allows TestTaskLifecycle to say /v1/tasks/1 at all.

New word

flaky test — one that passes or fails depending on order, timing or luck. Flaky tests are worse than no tests, because a team that has learned to re-run the suite until it goes green has learned to ignore failures.

Chasing the coverage number. Coverage is the percentage of your statements that a test run executed; go test -cover ./... prints it as a line ending coverage: N% of statements. It is a useful map of where you have not looked. It is a terrible target, because gaming it takes no effort at all: call every function, assert nothing, report 100%. The three failure classes worth buying first are tenancy isolation, money-adjacent logic (quota, entitlements, idempotency) and concurrency (the 409, the limiter). Everything else is compound interest.

Mock drift. If you do mock the data layer somewhere, the mock encodes yesterday’s behaviour forever. You change a query, the mock does not, the tests stay green, and production breaks. Real-DB tests cannot drift from the DB. That is the whole argument in one line.

One more, from building this chapter’s tests. go test ./... runs different packages in parallel. Today only cmd/api touches the database, so the shared taskd_test is safe. The day a second package opens that database, two test binaries will be truncating the same tables at the same time, and you will meet the strangest failures of your career. When that day comes, give the new package its own database name rather than trying to coordinate.


10. Check yourself — quiz

  1. You have thirty minutes and no tests. Which three things do you test first, and why those?
  2. Your colleague says “mock the database, real databases in tests are slow”. Give the two strongest counter-arguments from this chapter, and concede the one point they are right about.
  3. What does httptest.NewServer(app.routes()) exercise that calling app.createTaskHandler(w, r) directly does not?
  4. What exactly does t.Helper() change? What would you notice if you deleted it from doJSON?
  5. Prediction: you delete the TRUNCATE statement from newTestApplication and run TestTaskLifecycle twice in a row. Which assertion fails on the second run, and why that one?
  6. Why can the assertion assertBodyContains(t, res, “tasks”: []) never pass, and what is the general lesson?
  7. -race found nothing. Name two different reasons that might be true, only one of which is good news.
  8. Your test suite reports 100% coverage. Name two serious bugs that could still be in the code.
Answers
  1. Tenancy isolation, money-adjacent logic, concurrency — in that order. Tenancy because a failure there is a data breach; money because a failure there is a refund, a chargeback or a free customer; concurrency because those bugs are invisible in manual testing and only appear under load. Everything else can wait, because everything else fails visibly.

  2. Counter-arguments: (a) mocking sqlc’s generated methods means hundreds of lines that only prove handlers call the functions you told them to call, while the SQL — the risky part — never runs; (b) a mock encodes yesterday’s database behaviour and drifts silently, whereas a real database cannot drift from itself. The concession: they are genuinely slower, milliseconds against microseconds, and they need a running container, which is why our fixture skips cleanly when there isn’t one.

  3. Routing (whether the URL pattern and method actually reach that handler), the middleware chain and its order, real authentication via the Authorization header, real HTTP request parsing and response serialisation, and real status codes and headers. A direct handler call needs you to hand-build the request and fake the authenticated user in the context — at which point you are testing your fake.

  4. It marks the function as scaffolding, so when a failure is reported inside it, Go prints the file and line of the caller rather than of the helper. Delete it from doJSON and every failure in every test points at the same line inside doJSON, telling you nothing about which request failed.

  5. The first one: registerAndLogin’s assertStatus(t, res, http.StatusCreated). On the second run a@example.com already exists, Postgres rejects the duplicate on its unique index, and the handler translates that into 422 Unprocessable Entity. Everything after it then fails too — the token comes back empty, so every later request is a 401. That cascade is the real lesson: a leftover-state failure rarely looks like leftover state. It looks like authentication is broken.

  6. writeJSON and listTasksHandler both build bodies with json.Marshal, which emits no whitespace: the actual bytes are "tasks":[]. The space exists only in pretty-printed displays of JSON. General lesson: substring matching on JSON tests the formatter as much as the logic. When the shape matters, decode into a struct and assert on fields.

  7. Good news: there is no race in the code paths that ran. Not-good news: the racy code path never ran — no test exercised the limiter or the cache concurrently — or the unlucky interleaving did not happen this time. The detector reports races it observes; it does not prove their absence.

  8. Any number, but the cheap two: (a) a test that calls every function and asserts nothing — 100% coverage, zero verification; (b) a wrong-but-executed line, such as > where >= was meant in the quota check, or a missing AND user_id = $2, both of which are fully covered and fully broken. Coverage measures execution, never correctness.


11. Practice

Exercise 1 — The optimistic-lock 409 test (and a surprise)

The original chapter says to write the 409 test as “two GETs, two PATCHes”. Try it against the real handler. Then write a version that actually reproduces the conflict deterministically.

Solution

First, the surprise: the two-PATCH version does not produce a 409, and finding that out is the exercise. Look again at updateTaskHandler in cmd/api/tasks.go. Every PATCH begins with its own GetTask, so the second PATCH reads the fresh version (2), sends version = 2, matches a row, and returns 200. The lost-update window in this design exists only between one request’s GetTask and its own UpdateTask — it cannot be opened by two sequential requests.

That is not a bug in the code; it is a mismatch between the original’s sentence and the handler’s shape. The lock is real, and this is how to prove it deterministically — at the query layer, where the conflict lives:

// cmd/api/tasks_conflict_test.go — new file
package main

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/jackc/pgx/v5"

	"github.com/yourname/taskd/internal/db"
)

func TestOptimisticLockRejectsStaleWrite(t *testing.T) {
	app := newTestApplication(t)
	srv := httptest.NewServer(app.routes())
	defer srv.Close()

	token := registerAndLogin(t, srv, "lock@example.com")
	res := doJSON(t, srv, "POST", "/v1/tasks", token, `{"title":"contended"}`)
	assertStatus(t, res, http.StatusCreated)

	ctx := context.Background()
	user, err := app.q.GetUserByEmail(ctx, "lock@example.com")
	if err != nil {
		t.Fatal(err)
	}

	// Both "clients" read version 1 — RESTART IDENTITY makes the id 1.
	before, err := app.q.GetTask(ctx, db.GetTaskParams{ID: 1, UserID: user.ID})
	if err != nil {
		t.Fatal(err)
	}

	// Client one writes: succeeds, and version becomes 2.
	if _, err := app.q.UpdateTask(ctx, db.UpdateTaskParams{
		ID: before.ID, UserID: user.ID, Title: "written by client one",
		Notes: before.Notes, Status: before.Status, Priority: before.Priority,
		DueAt: before.DueAt, Version: before.Version,
	}); err != nil {
		t.Fatalf("first write should succeed: %v", err)
	}

	// Client two writes using the version it read BEFORE client one wrote.
	// WHERE ... AND version = $8 matches zero rows -> pgx.ErrNoRows,
	// which updateTaskHandler translates into 409 Conflict.
	_, err = app.q.UpdateTask(ctx, db.UpdateTaskParams{
		ID: before.ID, UserID: user.ID, Title: "written by client two",
		Notes: before.Notes, Status: before.Status, Priority: before.Priority,
		DueAt: before.DueAt, Version: before.Version,
	})
	if !errors.Is(err, pgx.ErrNoRows) {
		t.Errorf("stale write: err = %v, want pgx.ErrNoRows", err)
	}
}

Verify: make test/int. It should pass. Then delete AND version = $8 from UpdateTask in sql/queries/tasks.sql, run sqlc generate, and run the test again — it fails, because the stale write now succeeds. Restore the line and regenerate.

Note it in learnings/ch20.md: the failure the original describes needs two requests interleaved inside their handlers. Testing that over HTTP means firing both concurrently and asserting “exactly one got a 409” — which is a legitimate test, but a timing-dependent one, and timing-dependent tests are how flaky suites begin.

Exercise 2 — The readJSON triage table

readJSON in cmd/api/helpers.go translates Go’s hostile decoder errors into messages a client can act on. It is pure logic wearing an HTTP costume: no network, no database. Write the table test that feeds it one malformed body per branch and asserts the exact message.

Solution
// cmd/api/helpers_test.go — new file
package main

import (
	"net/http/httptest"
	"strings"
	"testing"
)

func TestReadJSONTriage(t *testing.T) {
	app := &application{} // readJSON touches no dependencies

	tests := []struct {
		name string
		body string
		want string // "" means: expect no error
	}{
		{"truncated mid-key", `{"title":`, "body contains badly-formed JSON"},
		{"missing closing brace", `{"title": "x"`, "body contains badly-formed JSON"},
		{"missing value", `{"title": }`, "body contains badly-formed JSON (at character 11)"},
		{"wrong type", `{"title": 42}`, `body contains incorrect JSON type for field "title"`},
		{"empty body", ``, "body must not be empty"},
		{"unknown key", `{"titel":"x"}`, `body contains unknown key "titel"`},
		{"two values", `{"title":"a"}{"title":"b"}`, "body must only contain a single JSON value"},
		{"valid", `{"title":"ok"}`, ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var input struct {
				Title string `json:"title"`
			}
			w := httptest.NewRecorder()
			r := httptest.NewRequest("POST", "/v1/tasks", strings.NewReader(tt.body))

			err := app.readJSON(w, r, &input)

			switch {
			case tt.want == "" && err != nil:
				t.Errorf("err = %v, want nil", err)
			case tt.want == "":
				// valid body, no error: nothing to assert
			case err == nil:
				t.Errorf("err = nil, want %q", tt.want)
			case err.Error() != tt.want:
				t.Errorf("err = %q, want %q", err.Error(), tt.want)
			}
		})
	}
}

Decode of the two new pieces: httptest.NewRecorder() is a fake http.ResponseWriter that remembers what was written to it — readJSON only needs one because http.MaxBytesReader takes one. httptest.NewRequest(method, target, body) builds a request without a network, and panics rather than returning an error, which is exactly what you want in a test.

Verify: go test -v -run TestReadJSONTriage ./cmd/api/. All eight sub-tests should pass, with no database — this one runs even when TASKD_TEST_DSN is unset, which is a nice demonstration of why the pure-logic layer is worth having.

If a message does not match, read the difference carefully before changing your code: these strings are your API’s public error contract, and a test that pins them is the point.

Exercise 3 — A tenancy test that fails when the filter is missing

TestTaskLifecycle proves user B cannot read user A’s task. It says nothing about PATCH and DELETE. Close that gap, then prove your new test works by breaking the code it guards.

Solution
// cmd/api/tenancy_test.go — new file
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestCrossTenantWritesAre404(t *testing.T) {
	app := newTestApplication(t)
	srv := httptest.NewServer(app.routes())
	defer srv.Close()

	tokenA := registerAndLogin(t, srv, "owner@example.com")
	res := doJSON(t, srv, "POST", "/v1/tasks", tokenA, `{"title":"private"}`)
	assertStatus(t, res, http.StatusCreated)

	tokenB := registerAndLogin(t, srv, "stranger@example.com")

	// B tries to edit A's task. 404, not 403: the id must be
	// indistinguishable from one that never existed (ch. 12).
	res = doJSON(t, srv, "PATCH", "/v1/tasks/1", tokenB, `{"title":"stolen"}`)
	assertStatus(t, res, http.StatusNotFound)

	// B tries to delete it.
	res = doJSON(t, srv, "DELETE", "/v1/tasks/1", tokenB, "")
	assertStatus(t, res, http.StatusNotFound)

	// And A's task is untouched.
	res = doJSON(t, srv, "GET", "/v1/tasks/1", tokenA, "")
	assertStatus(t, res, http.StatusOK)
	assertBodyContains(t, res, `"title":"private"`)
}

Verify by breaking it. In cmd/api/tasks.go, inside deleteTaskHandler, temporarily change

rows, err := app.q.DeleteTask(r.Context(), db.DeleteTaskParams{
	ID: id, UserID: user.ID,
})

to UserID: 1 — user A, because RESTART IDENTITY made the first registered user id 1. Run make test/int. The DELETE assertion now fails with status = 200, want 404, and the final GET fails too, because the task is gone. Put user.ID back and re-run to confirm green.

That whole sequence is the point of the exercise: a test you have watched fail for the right reason is a test you can trust. A test that has only ever passed might be asserting nothing at all.


12. FAQ

How much testing is enough? There is no number, and any number someone gives you is about their codebase, not yours. The usable heuristic: for each thing on your risk list, can you point at an assertion? If yes, you are done for now. If you cannot say what risk a test retires, it is probably not worth its maintenance cost. Write tests for the bugs you would be embarrassed by, not for the lines you happen to have written.

Why not mock the database? Every tutorial says to. Most of those tutorials are written for codebases where the data layer is an ORM the author trusts and the SQL is invisible. Ours is the opposite: we deliberately hand-wrote the SQL, because Chapter 7 (sqlc) argued that visible SQL is an asset. Having chosen to own the SQL, we do not then arrange for it never to run. Mock what is expensive, slow or nondeterministic — other companies’ APIs, clocks, email. Do not mock the thing you are most likely to get wrong.

Do I need testify, ginkgo, or another testing framework? No, and this chapter is the evidence: everything above is 25 lines of helpers plus the standard library. Assertion libraries buy you shorter failure lines and cost you a dependency, a second vocabulary in the codebase, and — with the BDD-style ones — a control flow that is genuinely hard to debug. If you join a team that uses one, use it. Do not add one on day one.

Why -race? My code isn’t concurrent. It is. Go’s HTTP server runs every request in its own goroutine, so from the moment you served your first request in Chapter 2 you have been writing concurrent code. Anything shared between requests — the limiter map, the singleflight group, any package-level variable you add in a hurry — is shared across goroutines. -race costs you a flag and some CPU, and it finds the class of bug that is otherwise found by customers.

My tests are slow. What do I do, in order? (1) Check bcrypt: drop the cost to bcrypt.MinCost in TestMain — this is usually 90% of it. (2) Check you are not sleeping anywhere; time.Sleep in a test is almost always a design problem in disguise. (3) Only then consider running independent tests concurrently with t.Parallel(), and be aware that tests sharing one database and one TRUNCATE cannot be parallel within a package without more work than it saves at this size.

Is this really how companies do it? Both ways exist, genuinely. Large organisations with many teams and a shared database often mock, because standing up a real database per team is organisationally hard, and they pay for it in mock drift. Smaller teams shipping a single service — which is what you are building — increasingly test against a real database in a container, because containers made it cheap. The honest summary: the mock-everything orthodoxy predates Docker, and much of the advice you will read has not been re-derived since.


13. Where we are

Observable and verified. All that’s left of “production-ready” is the production: a container, a pipeline, a deploy. The last part.

Concretely: the system now checks itself. Chapter 18 (Prometheus) made it observable, Chapter 19 (Logging that pays rent) made it explicable, and this chapter made it provable — the tenancy guarantee that Chapter 12 argued for is now a machine-checked assertion that runs in under a second and will fail the day someone breaks it.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go          server.go       routes.go
│   ├── config.go        db.go           middleware.go
│   ├── helpers.go       errors.go       context.go
│   ├── healthcheck.go   tasks.go        users.go
│   ├── tokens.go        billing.go      webhooks.go
│   ├── metrics.go       entitlements.go
│   ├── testutils_test.go   # NEW: fixture + doJSON/assert*/registerAndLogin
│   ├── tasks_test.go       # NEW: TestTaskLifecycle
│   ├── helpers_test.go     # NEW, if you did Practice 2
│   ├── tenancy_test.go     # NEW, if you did Practice 3
│   └── tasks_conflict_test.go  # NEW, if you did Practice 1
├── internal/
│   ├── cache/           # cache.go ratelimit.go
│   ├── data/
│   │   ├── filters.go   tasks.go   users.go   tokens.go   plans.go
│   │   └── filters_test.go   # NEW: TestCalculateMetadata
│   ├── db/              # sqlc output
│   └── validator/
├── migrations/          # 000001 … 000005, up + down
├── sql/queries/         # tasks.sql users.sql tokens.sql billing.sql
├── Makefile             # UPDATED: test, test/int
├── sqlc.yaml   config.toml   docker-compose.yml   prometheus.yml
└── go.mod   go.sum

Plus a second database inside the same Postgres container, taskd_test, which holds nothing between runs by design.

What works end to end: make test/int registers users, logs them in, creates tasks, and proves tenant isolation through the real router, the real middleware chain and real SQL — with the race detector watching. go test ./... still passes on a machine with no database, honestly reporting the database tests as skipped.

What is still fake or missing:

  • Only one integration test exists. The quota 402 and webhook idempotency siblings are described, not written. They are the next two you should own.
  • No CI. Nothing runs these tests except you, from memory. Chapter 26 (CI/CD: the robot that says no) is where a machine starts refusing broken merges.
  • registerAndLogin has a shelf life. Chapter 21 makes users start unactivated and will update the fixture accordingly.
  • bcryptCost may still be a hard-coded 12. Optional today; obvious the first time your suite crosses ten seconds.

For your notes

Copy these into learnings/ch20.md, in your own words:

  1. The goal is confidence per minute of test runtime, not coverage. A testing strategy is an honest risk list with assertions attached. Write the list before writing a test.
  2. Real-DB tests can’t drift from the DB. A mock freezes what you believed about the database the day you wrote it; the database keeps telling the truth. Mock other companies’ systems and clocks, never your own data layer.
  3. httptest.NewServer(app.routes()) tests what you deploy. Routing, middleware order, authentication and JSON all run for real. Calling a handler directly tests the handler and none of its context.
  4. Test isolation and multi-tenancy are the same property. TRUNCATE per test plus a per-test user gives independence for free, because Chapter 12 already made every query user-scoped.
  5. Watch every test fail once, deliberately. A test that has never failed has never been verified — and a green suite that skipped everything is the most expensive kind of comfort.

Chapter 21 — Background work and transactional email

Until now, every request taskd handles is a conversation with one machine: your program talks to Postgres, maybe to Dragonfly, and answers. This chapter introduces the first piece of work that is too slow and too unreliable to do while somebody is waiting — sending an email — and the machinery for doing it after the response has already gone out. Then it spends that machinery on the feature it was built for: new accounts arrive locked, an email carries the key, and only the person who can read that mailbox gets into the product.

What you’ll be able to do by the end

  • Send real email from the API and read it in a browser inbox running on your own laptop.
  • Explain, in one sentence, why signup must never wait for a mail server — and name the four ways to run work in the background, in order of cost.
  • Make new accounts start unactivated, unlock them with an emailed token, and demonstrate the 403200 flip on GET /v1/tasks.
  • Stop the server with Ctrl-C without losing an email that was halfway out the door.
  • Delete expired rows on a schedule, from inside your own program, with no external scheduler.

Time: ~55 minutes reading, ~70 minutes typing.

You need before starting: a working Chapter 20 (Testing what matters), which means everything from Chapter 12 (Ownership) onwards still runs. Prove it in two commands — log in, then use the token:

TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

You should get a JSON body containing a "metadata" key. If you get {"error":"invalid authentication credentials"}, register that user again with the POST /v1/users command from Chapter 11 (Stateful tokens).

Important

Read that command carefully, because this is the last chapter in which it works as written. By the end of the chapter the same three steps end in 403 your account must be activated, and Chapter 20’s TestTaskLifecycle fails for the same reason. Both are repaired inside this chapter — the curl sequence in section 7, the test fixture in Step 16. Nothing is left broken; it is no longer the same sequence.


1. The problem, in plain words

Everything taskd does today happens on one timescale. A Postgres query takes a millisecond or two. A cache read takes less. Even bcrypt, the deliberately slow password hash from Chapter 10 (Users and passwords), takes a fraction of a second. The user presses a button, the server does its work, the answer comes back, and nobody notices the gap.

Email is not on that timescale.

Sending an email means opening a network connection to somebody else’s server — a machine you do not own, running software you did not choose, on the far side of the internet — and conducting a short, chatty conversation with it. That takes hundreds of milliseconds on a good day. On a bad day the mail server is busy and says “try again later”. On a worse day it accepts the connection and then stops responding, and your program sits there waiting.

New word

SMTP — Simple Mail Transfer Protocol, the internet’s protocol for sending email. It is the slowest and flakiest thing this API will ever touch. mail relay — the server that actually accepts your outgoing mail and forwards it onward.

Now imagine the naive version of what we are about to build. Someone signs up. Your registration handler writes the user row, then opens an SMTP connection, then waits. Two things have just gone wrong at once:

  1. Signup got slow. The person who clicked “Create account” is looking at a spinner for as long as the mail relay feels like taking.
  2. Signup got fragile. If the mail relay is down, registration returns 500 — even though the account was created perfectly. Your signup page is now only as available as somebody else’s mail server.

That second one is the real damage. You have taken a piece of your product that works and chained its uptime to a system you do not control.

Think of it like

A restaurant that made you stand at the till until your food was cooked would have a queue out of the door. Real restaurants take the order, hand you a number, and cook in the back. The order is accepted; the cooking continues. That is exactly the shape of this chapter.

So the email goes into the background: the handler starts the work, then returns immediately without waiting for it. And the moment you say “in the background”, four new questions appear at once, which is why this chapter is longer than it looks:

  • What if the program is asked to shut down while an email is halfway sent?
  • What if the background work crashes?
  • Which piece of data may the background work touch, given that the request it came from is over?
  • And how do you see the email during development, when you have no mail relay and no real inbox?

The second half of the chapter spends this machinery on account activation: proof that the email address someone typed at signup is an address they can actually read.

Why this exists

Without that proof, three things rot quietly. Password resets go to addresses people mistyped, so the user is locked out of an account nobody can recover. People sign up with other people’s addresses, which is how harassment and spam campaigns start. And your Stripe customer list — the one you will email about failed payments in Chapter 17 (Entitlements and quotas) — is full of addresses that go nowhere.


2. New words in this chapter

Word What it means here
SMTP The internet’s protocol for sending email; slow and flaky compared to everything else this app does.
mail relay The server that actually accepts and forwards your outgoing mail.
transactional email Automatic one-to-one email triggered by a user’s action — activation, password reset — as opposed to marketing mail. The receipt, not the newsletter.
background work Work started by a request but finished after the response is sent.
goroutine (recap) A piece of work running at the same time as the rest of the program, extremely cheap to start.
sync.WaitGroup A counter of outstanding background jobs, so shutdown can wait for them to finish.
deadlock A program stuck forever waiting for something that will never happen.
recover() (recap) Catches a panic inside a deferred function and turns it back into an ordinary value.
closure A function written inside another function that remembers (“captures”) the variables around it even after the outer function has returned.
worker pool / bounded queue A fixed number of workers pulling jobs off a length-limited list.
durable queue A job list stored on disk (or in Postgres) so queued work survives a crash or restart.
FOR UPDATE SKIP LOCKED The Postgres trick that lets several workers claim different rows from one queue table without blocking each other.
//go:embed A directive that bakes files — templates, specs — into the compiled binary.
template (html/template) A text file with {{.name}} placeholders that Go fills in.
Mailpit A development SMTP server with a web inbox at :8025 that catches all outgoing mail.
account activation Proving the signup email address is real by emailing a token that must be presented back before the product unlocks.
grandfathering Applying a new rule only to new records, so existing users are unaffected.
ticker A Go timer that fires repeatedly; here, the hourly cleanup loop.
janitor This book’s name for a background loop that deletes expired rows on a schedule.
202 Accepted “We took your request and will finish it in the background.”
403 Forbidden “We know who you are; you’re still not allowed.”
SIGKILL The signal that kills a program instantly with no chance to clean up.
scope (token) (recap) A label on a token saying what it may be used for: authentication, activation, password-reset, email-change.

3. The goal

An SMTP mailer with embedded templates, a background() helper whose goroutines are counted into graceful shutdown, real account activation (new users start unactivated, click an emailed token, and only then reach the product), a resend endpoint — and, while we’re building recurring background machinery anyway, the janitors this codebase has quietly owed: expired-token deletion and stripe_events pruning. Mailpit in Compose so email is visible in dev.


4. The thinking

Why email can’t be synchronous

SMTP is the slowest, flakiest thing this API will ever touch — seconds, not milliseconds, with transient failures as a lifestyle. Put it inline in registerUserHandler and you’ve coupled signup latency and signup availability to a mail relay. So email goes to a background goroutine and the handler returns immediately.

Which flavour of background?

There is an escalation ladder here, and knowing which rung you are on — and what would push you up one — matters far more than the code.

Rung What it is Survives a crash? Cost When it is honest
1 A bare go func() No Free Never, in a server. Work is lost on shutdown, and a panic inside it kills the whole process.
2 Counted goroutinego func() wrapped with sync.WaitGroup bookkeeping and a recover(), drained by Chapter 4’s graceful shutdown Survives shutdown, not a crash ~15 lines When losing the job is recoverable by the user themselves.
3 In-process worker pool with a bounded queue No A day’s work Rarely. It buys back-pressure, not durability.
4 Durable queue — the job is a row on disk, so a restart replays it Yes A dependency and a schema The moment losing the job costs money or trust.
New word

worker pool / bounded queue — a fixed number of workers pulling jobs off a length-limited list; when the list is full, new work is refused rather than piling up. durable queue — a job list stored on disk (or in Postgres) so queued work survives a crash or restart. In Go the popular choice is River, a job queue library that keeps its jobs in your own Postgres; the do-it-yourself version is a table plus SELECT ... FOR UPDATE SKIP LOCKED, the Postgres trick that lets several workers claim different rows from one queue table without blocking each other.

We choose (2), and here’s the honest ledger: a crash between “row inserted” and “email sent” loses the email. For activation mail the user has a self-service recovery — the resend endpoint — so the failure is an annoyance, not an incident. The moment a background job appears whose loss costs money or trust with no user-visible retry path, that job graduates straight to (4); (3) is usually a stop that satisfies nobody.

Remember this

Write that criterion down; it’s the whole queue decision. The moment a background job appears whose loss costs money or trust with no user-visible retry path, that job graduates straight to a durable queue.

Why activation at all?

Unverified emails mean password resets to addresses the user mistyped (lockout), signups with other people’s addresses (abuse), and a Stripe customer list you can’t trust.

The mechanism is already sitting in the schema: the tokens table’s scope column. Chapter 11 (Stateful tokens) added that column and admitted, out loud, that it held only one value — "authentication" — and existed as an affordance for later: a shape built now because the design was going to need it soon enough. Today it becomes load-bearing, with a new activation scope. Same generation, same hashing, same lookup; the scope is the only difference.

Remember this

When a design decision from six chapters ago makes a feature cost twenty lines, that’s the compounding this book keeps advertising.

Grandfathering, the migration-story bonus

Flipping the column default to false affects only new rows — every existing user stays activated. A schema change that’s also a correct data migration by doing nothing: the best kind.

New word

grandfathering — applying a new rule only to new records, so existing users are unaffected. It works here because a column DEFAULT is consulted at INSERT time only. Rows written yesterday were written under yesterday’s default and are never revisited.

The mail client

Go’s standard library has net/smtp, and it is frozen — officially closed to new features — and painful to use. The modern maintained choice is wneessen/go-mail. Templates ship inside the binary via embed; the single-binary doctrine from Chapter 1 (Introduction) does not bend for HTML files. Dev email goes to Mailpit, an SMTP sink with a web inbox, because “check the logs for the token” is how activation flows stay untested.

New word

Mailpit — a small program that pretends to be a mail server. It accepts every message, delivers none of them, and shows you what it caught in a web page at http://localhost:8025. Nothing ever leaves your machine.

Janitors

Two tables grow monotonically: tokens (expired rows are unusable but undeleted — flagged as a defect in review) and stripe_events (the idempotency ledger from Chapter 16 (Stripe II: webhooks) only needs to out-remember Stripe’s retry horizon, days not decades). A ticker goroutine running two DELETEs hourly is the entire fix.

It deliberately does not join the WaitGroup — an infinite loop would deadlock the drain — and doesn’t need to: each sweep is one atomic statement, so a mid-sweep SIGKILL loses nothing. Knowing why a goroutine may be killed rudely is the license to let it be.

New word

deadlock — a program stuck forever waiting for something that will never happen. Here the shape would be: shutdown waits for the counter to reach zero; the counter never reaches zero because the janitor loops forever; so shutdown waits forever. SIGKILL — the signal that kills a program instantly with no chance to clean up. Pulling the plug.


5. A picture of it

Two pictures, because this chapter builds two things that meet in the middle.

First: what a new account goes through. Time runs downward. Note where the response is sent relative to where the email is sent — that gap is the entire point of the chapter.

  CLIENT                    taskd                     POSTGRES      MAILPIT
    │                         │                          │             │
    │  POST /v1/users         │                          │             │
    ├────────────────────────▶│                          │             │
    │                         │  INSERT user             │             │
    │                         │  (activated = false)     │             │
    │                         ├─────────────────────────▶│             │
    │                         │  INSERT token            │             │
    │                         │  (scope = activation)    │             │
    │                         ├─────────────────────────▶│             │
    │                         │                          │             │
    │   202 Accepted          │   app.background(...) ───┼──┐          │
    │◀────────────────────────┤   handler is DONE here   │  │          │
    │                         │                          │  │ SMTP     │
    │                         │                          │  └─────────▶│
    │                         │                          │             │
    │  (user opens :8025, copies the 26-character token) │             │
    │                         │                          │             │
    │  PUT /v1/users/activated {"token": "..."}          │             │
    ├────────────────────────▶│  UPDATE activated = true │             │
    │                         ├─────────────────────────▶│             │
    │   200 OK                │  DELETE activation tokens│             │
    │◀────────────────────────┤                          │             │
  1. Registration writes two rows and answers 202 Accepted — “taken, still processing”.
  2. The email is sent by a separate goroutine that outlives the request.
  3. The token in that email is the credential. Presenting it flips activated to true.
  4. Every outstanding activation token for that user is then deleted, so the emailed token is single-use.

Second: the three rings. By the end of this chapter, every route in taskd sits in exactly one of three concentric rings, and which ring a route is in is its access policy.

 ┌────────────────────────────────────────────────────────────┐
 │ PUBLIC — no token needed                                   │
 │   POST /v1/users                POST /v1/tokens/activation │
 │   POST /v1/tokens/authentication  PUT /v1/users/activated  │
 │   GET  /v1/healthcheck          POST /v1/stripe/webhook    │
 │                                                            │
 │  ┌──────────────────────────────────────────────────────┐  │
 │  │ AUTHENTICATED — valid bearer token, activation NOT   │  │
 │  │ required. Chapter 22's fix-your-own-account flows.   │  │
 │  │                                                      │  │
 │  │  ┌────────────────────────────────────────────────┐  │  │
 │  │  │ ACTIVATED — the product itself                 │  │  │
 │  │  │   /v1/tasks/*        /v1/billing/*             │  │  │
 │  │  └────────────────────────────────────────────────┘  │  │
 │  └──────────────────────────────────────────────────────┘  │
 └────────────────────────────────────────────────────────────┘

Why can an unactivated user authenticate at all? Because Chapter 22 (Password reset and the account lifecycle) has flows for fixing your own account — mistyping your email at signup, above all — that require proving a password while not yet activated. Authentication answers “who are you”; activation answers “is your email real”; conflating them strands exactly the users who most need help.


6. The steps

Sixteen steps, in five parts. Part A builds the plumbing, Part B the background machinery, Part C the activation flow, Part D the gate that makes activation mean something, and Part E the janitors and the test repair. Each part ends with something you can run.


Part A — the plumbing

Step 1 — Flip the default, index the tokens table, install the mail library

Two commands. The first creates an empty pair of migration files; the second downloads the mail library into the module.

make db/migrations/new name=activation
go get github.com/wneessen/go-mail

make db/migrations/new runs migrate create -seq -ext sql -dir ./migrations activation, which writes two empty files — 000006_activation.up.sql and 000006_activation.down.sql — and prints both paths. go get downloads the library, records it in go.mod, and prints a line beginning go: added github.com/wneessen/go-mail with the version it chose.

Now fill in the up migration:

-- migrations/000006_activation.up.sql
ALTER TABLE users ALTER COLUMN activated SET DEFAULT false;

-- Fixes a review finding: Postgres does not auto-index FK columns, and the
-- activation/reset flows are about to delete-by-user-and-scope constantly.
CREATE INDEX idx_tokens_user_id ON tokens (user_id);

What this code says, line by line

  1. ALTER TABLE users ALTER COLUMN activated SET DEFAULT false — from now on, an INSERT into users that does not mention activated gets false. Rows already in the table are not touched, not read, not rewritten. That is the grandfathering: your existing test accounts stay activated and keep working.
  2. CREATE INDEX idx_tokens_user_id ON tokens (user_id) — an index is an extra sorted structure the database keeps so it can find matching rows without reading them all. tokens already has one on hash (it is the primary key), but none on user_id.
  3. “Postgres does not auto-index FK columns” — a foreign key is a column that must match a row in another table; tokens.user_id REFERENCES users is one. Many people assume the database indexes those automatically. Postgres does not. Every DELETE FROM tokens WHERE user_id = $1 AND scope = $2 — which this chapter is about to run on every activation, and Chapter 22 on every password reset — would otherwise read the whole table.

The original book gives the down migration in one sentence of prose. Here it is as a file, because a migration without its inverse is half a migration:

-- migrations/000006_activation.down.sql
-- Shown here for the first time: the original describes this file in prose
-- ("Down: default back to `true`, drop the index") without printing it.
ALTER TABLE users ALTER COLUMN activated SET DEFAULT true;
DROP INDEX IF EXISTS idx_tokens_user_id;

Apply it:

make db/migrations/up

What you should seemigrate prints one line per applied migration; the last one mentions 6/u activation followed by how long it took. If it prints nothing at all, every migration was already applied.

Checkpoint

Run make db/psql, then \d users. In the activated row of the table description, the “Default” column now reads false. Type \q to leave psql.


Step 2 — Give development a fake inbox

Add a service to the Compose file. It goes alongside db and cache, at the same indentation as the other service names.

# docker-compose.yml — add this service under `services:`
  mailpit:
    image: axllent/mailpit
    ports:
      - "8025:8025"   # web UI; SMTP is 1025 inside the network
      - "1025:1025"

What this code says

  • image: axllent/mailpit — Docker downloads this published image; nothing to build.
  • Two ports, and they are not interchangeable. 1025 is where Mailpit listens for mail, in the SMTP protocol. 8025 is where it serves a web page showing what it caught. Sending mail to 8025 fails; browsing to 1025 shows nonsense. Confusing them is the most common mistake in this chapter, and it has a callout in section 8 waiting for you.
  • Publishing both to the host matters because make run/api runs the Go program on your machine, not inside Compose. Without "1025:1025" your program cannot reach the mail server.

Start it:

docker compose up -d mailpit

What you should see — Docker pulls the image on first run (a progress display), then prints a line for the created container. Open http://localhost:8025 in a browser: an empty inbox with a Mailpit heading. Empty is correct — nothing has sent anything yet.


Step 3 — Teach the config about SMTP

Config grows an [smtp] block. The koanf pattern is unchanged from Chapter 3 (Configuration and logging) — environment overrides work already, for free.

# config.toml — add this block at the end
[smtp]
host = "localhost"
port = 1025
username = ""          # empty → no auth, no TLS (dev)
password = ""
sender = "taskd <no-reply@taskd.example>"

The original book stops there and says the matching Go fields are “muscle memory”. They are not, if you have never done it before, so here are all three pieces — this is the same TOML block, struct field, k.String line trio that Chapter 3 promised every later chapter would show in full.

First the struct. config lives in cmd/api/config.go; add one nested block to it, next to the existing stripe block:

// cmd/api/config.go — add this field inside the config struct
    // ch. 21 — the [smtp] block: transactional email.
    smtp struct {
        host                       string
        port                       int
        username, password, sender string
    }

Then the loader lines. At the bottom of loadConfig, after the cfg.stripe.* assignments:

// cmd/api/config.go — add these lines at the end of loadConfig, before `return cfg, nil`
    // ch. 21 — same mechanical pattern, one line per knob.
    cfg.smtp.host = k.String("smtp.host")
    cfg.smtp.port = k.Int("smtp.port")
    cfg.smtp.username = k.String("smtp.username")
    cfg.smtp.password = k.String("smtp.password")
    cfg.smtp.sender = k.String("smtp.sender")

What this code says, line by line

  1. The nested anonymous structsmtp struct { ... } declares a field called smtp whose type is a struct written inline, with no name of its own. It is a namespace: it makes call sites read cfg.smtp.host rather than cfg.smtpHost.
  2. username, password, sender string — three fields of the same type declared on one line. Identical to writing them on three lines.
  3. k.String("smtp.host")k is the koanf object holding one flat map of every key from the TOML file and then the environment. "smtp.host" is how [smtp] host = ... is addressed; k.Int converts to a Go int.
  4. Environment overrides need no new code. Chapter 3’s loader turns TASKD_SMTP__HOST into the key smtp.host — prefix stripped, lowercased, double underscore to dot. Production points at a real relay with TASKD_SMTP__HOST and never edits the file.
Warning

username and password are empty here on purpose: empty means “no authentication and no encryption”, which is right for a fake local mail server and catastrophic for a real one. The real relay’s password belongs in TASKD_SMTP__PASSWORD, never in config.toml. A leaked mail relay password is a spam cannon to whoever finds it.


Step 4 — The mailer package

A new package, in a new folder: internal/mailer/. It knows how to turn a template file plus some data into a message, and how to hand that message to an SMTP server.

// internal/mailer/mailer.go — new file
package mailer

import (
    "bytes"
    "embed"
    "html/template"
    "time"

    mail "github.com/wneessen/go-mail"
)

//go:embed templates
var templateFS embed.FS

type Mailer struct {
    client *mail.Client
    sender string
}

func New(host string, port int, username, password, sender string) (*Mailer, error) {
    opts := []mail.Option{
        mail.WithPort(port),
        mail.WithTimeout(10 * time.Second),
    }
    if username != "" {
        opts = append(opts,
            mail.WithSMTPAuth(mail.SMTPAuthPlain),
            mail.WithUsername(username),
            mail.WithPassword(password))
    } else {
        opts = append(opts, mail.WithTLSPolicy(mail.NoTLS)) // dev / Mailpit
    }
    client, err := mail.NewClient(host, opts...)
    if err != nil {
        return nil, err
    }
    return &Mailer{client: client, sender: sender}, nil
}

func (m *Mailer) Send(recipient, templateFile string, data any) error {
    tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)
    if err != nil {
        return err
    }

    exec := func(name string) (string, error) {
        var b bytes.Buffer
        err := tmpl.ExecuteTemplate(&b, name, data)
        return b.String(), err
    }
    subject, err := exec("subject")
    if err != nil {
        return err
    }
    plain, err := exec("plainBody")
    if err != nil {
        return err
    }
    htmlBody, err := exec("htmlBody")
    if err != nil {
        return err
    }

    msg := mail.NewMsg()
    if err := msg.From(m.sender); err != nil {
        return err
    }
    if err := msg.To(recipient); err != nil {
        return err
    }
    msg.Subject(subject)
    msg.SetBodyString(mail.TypeTextPlain, plain)
    msg.AddAlternativeString(mail.TypeTextHTML, htmlBody)

    // Three tries with a beat between them shrugs off transient SMTP grumpiness.
    for i := 1; ; i++ {
        err = m.client.DialAndSend(msg)
        if err == nil || i == 3 {
            return err
        }
        time.Sleep(500 * time.Millisecond)
    }
}

That is the longest block in the chapter. Take it in five pieces.

1. The embed directive.

//go:embed templates
var templateFS embed.FS

This looks like a comment and is not. A line starting //go: with no space after the slashes is a directive: an instruction to the compiler. //go:embed templates says “read the whole templates folder next to this file and bake its contents into the compiled program”. templateFS is then a read-only file system living inside your binary.

New word

//go:embed — a directive that bakes files into the compiled binary. Laminating the instructions onto the machine. The rules that catch people: the directive must sit immediately above the variable with no blank line between, the variable must be package-level, and embed must be imported even though your code never writes embed. anywhere except the type.

Why bother? Because taskd ships as one file. A binary that needs a templates/ folder beside it is not one file; it is a file plus an unwritten deployment instruction, and the first time someone forgets, activation email breaks in production with a runtime error.

2. The struct and the constructor. Mailer holds a configured *mail.Client and the address that outgoing mail claims to be from. New builds it:

  • opts := []mail.Option{...} — a slice of option values. This is a common Go pattern: instead of a constructor with nine parameters, the library exposes small functions (mail.WithPort(...)) that each carry one setting, and you pass however many you need.
  • mail.WithTimeout(10 * time.Second) — the timeout rule from Chapter 2 (The skeleton) applied to outbound calls. Without it, a mail server that accepts your connection and then goes quiet holds the goroutine forever.
  • if username != "" — the config decides the mode. With a username, use plain SMTP authentication with those credentials. Without one, mail.WithTLSPolicy(mail.NoTLS) explicitly turns off encryption, because Mailpit does not speak it. Production sets the username, so production gets auth and TLS. Dev leaves it empty, so dev gets neither.
  • mail.NewClient(host, opts...) — the ... spreads the slice into the function’s variadic parameter, i.e. “pass each element as a separate argument”.

3. Parsing the template.

// internal/mailer/mailer.go — excerpt from Send(), already typed above
tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile)

ParseFS reads the named file out of the embedded file system — not off disk — and parses it. templateFile will be "activation.tmpl"; the "templates/" prefix is added here so callers pass a bare filename.

4. Rendering three pieces from one file.

// internal/mailer/mailer.go — excerpt from Send(), already typed above
exec := func(name string) (string, error) {
    var b bytes.Buffer
    err := tmpl.ExecuteTemplate(&b, name, data)
    return b.String(), err
}

exec is a function stored in a variable — a small helper defined where it is used, so the same four lines are not written three times. bytes.Buffer is a growable in-memory chunk of bytes that can be written to like a file; ExecuteTemplate fills in the placeholders and writes the result into it; b.String() reads it back out as text. It is called three times, for the three named sections the template file will define.

5. Building and sending the message.

  • msg.From and msg.To return errors because an address can be malformed.
  • SetBodyString(mail.TypeTextPlain, plain) sets the plain-text body; AddAlternativeString(mail.TypeTextHTML, htmlBody) attaches the HTML version as an alternative. Mail clients that render HTML show that one; the rest show the plain text. Sending both is standard practice and costs nothing.
  • The retry loop. for i := 1; ; i++ is a for loop with no stop condition — the empty middle slot means “loop forever” — and the exit lives inside: return when the send succeeded (err == nil) or when this was the third attempt. Between attempts it sleeps half a second. Transient SMTP failures (“try again later”) are normal, and two extra tries a second apart clears most of them.
Note

Three attempts, half a second apart, is a deliberately simple retry policy — no exponential backoff, no jitter. It is enough for a mail relay having a bad second and not enough to be a nuisance if the relay is truly down, because the whole thing is bounded at roughly 1 second plus three ten-second timeouts.


Step 5 — The activation email template

Create the folder internal/mailer/templates/ and put one file in it, internal/mailer/templates/activation.tmpl. The //go:embed templates directive from Step 4 will fail to compile if this folder does not exist, so do it now.

{{define "subject"}}Activate your taskd account{{end}}

{{define "plainBody"}}
Hi {{.name}},

Welcome to taskd. Send the request below within 72 hours to activate:

PUT /v1/users/activated
{"token": "{{.activationToken}}"}
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html><body>
<p>Hi {{.name}},</p>
<p>Welcome to taskd. Activate within 72 hours using this token:</p>
<pre>PUT /v1/users/activated
{"token": "{{.activationToken}}"}</pre>
</body></html>
{{end}}

What this code says

  • {{define "subject"}} ... {{end}} declares a named section. One file, three sections — subject, plain body, HTML body — which is exactly what Send asks for by name. Keeping all three in one file means the wording of an email can never drift apart across files. The convention is borrowed straight from Let’s Go Further by Alex Edwards, one of the two influences named in the preface, because it is correct and there is nothing to improve on.
  • {{.name}} is a placeholder. The dot means “the data passed in”; .name looks up the key name in it. The Go code will pass a map[string]any with the keys name and activationToken, and the names must match exactly.
  • html/template escapes automatically. If a user registers with the name <script>, the HTML body receives &lt;script&gt;. That protection is why the import is html/template and not text/template.
Note

An API emails instructions, not a clickable button, because taskd has no web pages to click through to. When a web frontend exists, the template links to a page that fires this request on the user’s behalf. The token is the same either way.


Part B — background work that survives shutdown

Step 6 — The background() helper

One small file, and the most reusable thing in the chapter.

// cmd/api/background.go — new file
package main

import "fmt"

func (app *application) background(fn func()) {
    app.wg.Add(1)
    go func() {
        defer app.wg.Done()
        defer func() {
            if err := recover(); err != nil {
                app.logger.Error("background goroutine panic",
                    "error", fmt.Sprintf("%v", err))
            }
        }()
        fn()
    }()
}

Nine lines of body, and every one of them is preventing a specific disaster:

   app.background(fn)
        │
        ├─ app.wg.Add(1)              ← counter goes 0 → 1
        │                               prevents: shutdown exiting before the
        │                               email is sent
        │
        └─ go func() { ... }()        ← the work now runs concurrently;
             │                          the handler returns immediately
             │
             ├─ defer wg.Done()       ← counter goes 1 → 0 on the way OUT,
             │                          whatever happens — including a panic
             │
             ├─ defer recover()       ← catches a panic in fn and logs it
             │                          prevents: one bad email killing the
             │                          entire server process
             │
             └─ fn()                  ← the actual work

What this code says, line by line

  1. func (app *application) background(fn func()) — a method on the application struct (the one box from Chapter 2 holding the logger, config, DB pool and now the mailer). Its parameter fn has type func(): a function that takes nothing and returns nothing. Functions are values in Go; you can pass them around like any other.
  2. app.wg.Add(1)wg is a sync.WaitGroup, added to the struct in the next step. Think of it as a counter of outstanding jobs. Add(1) increments it. Crucially this happens before the goroutine starts, in the caller’s own execution — if it happened inside the goroutine, shutdown could reach the count check before the goroutine had even begun.
  3. go func() { ... }() — starts a goroutine running an anonymous function. The trailing () calls it. The go keyword means “run this alongside everything else and continue immediately”.
  4. defer app.wg.Done()Done() decrements the counter. defer schedules it for when this function exits, by any route, including panicking. If you decremented at the bottom instead, a panic would skip it and the counter would never return to zero — shutdown would then hang until its own timeout, every time, forever.
  5. defer func() { if err := recover(); ... }() — a deferred anonymous function calling recover(), the same pattern as recoverPanic in Chapter 4 (A server that dies well). Here it matters more, not less: a panic in a normal handler is caught by middleware, but a panic in a goroutine that nobody recovers takes down the entire process — every in-flight request of every user, because of one malformed email address.
  6. Order of the two defers. Deferred calls run last-registered-first. wg.Done() is registered first, so it runs last — after recover() has already swallowed the panic. Swap them and the panic escapes before Done() runs.
  7. fmt.Sprintf("%v", err)recover() returns any (it could be anything the panicking code passed). %v renders whatever it is as text so the structured logger can store it.
New word

sync.WaitGroup — a counter of outstanding background jobs, so shutdown can wait for them to finish. The head-count before the coach leaves.


Step 7 — Wire the mailer and the WaitGroup into main.go

The application struct grows two fields, and main() grows a mailer and a janitor.

// cmd/api/main.go — the application struct, with two new fields
type application struct {
    config  config
    logger  *slog.Logger
    db      *pgxpool.Pool
    q       *db.Queries
    cache   *cache.Cache
    sfGroup singleflight.Group
    wg      sync.WaitGroup   // counts in-flight background work
    mailer  *mailer.Mailer
}
// cmd/api/main.go — in main(), after the cache block:
m, err := mailer.New(cfg.smtp.host, cfg.smtp.port,
    cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender)
if err != nil {
    logger.Error("configuring mailer", "error", err)
    os.Exit(1) // unlike the cache, mail is required: activation depends on it
}

app := &application{
    config: cfg, logger: logger, db: pool,
    q: db.New(pool), cache: appCache, mailer: m,
}

go app.janitor() // hourly cleanup loop from step 15 — plain goroutine, see below

What this code says, line by line

  1. wg sync.WaitGroup — note there is no pointer and no initialisation. A zero WaitGroup is ready to use, which is why it can be a plain field with no setup. It also must never be copied after first use, which is one more reason every handler is a method on *application (a pointer) rather than on a copy.
  2. mailer *mailer.Mailer — the field name and the package name are both mailer. Go allows it; inside a method you write app.mailer.Send(...) and there is no ambiguity.
  3. os.Exit(1) on mailer failure — compare with the cache twenty lines above, which logs a warning and carries on with appCache = nil. The difference is stated in the comment: a missing cache makes taskd slower, a missing mailer makes new accounts impossible to activate. Required dependencies are proven at boot; optional ones are allowed to fail at boot. Deciding which is which, out loud, is the design act.
  4. go app.janitor() — a plain go, not app.background(...). That is deliberate and is explained in Step 15. Written here because this is where it belongs; the function itself arrives later, so the code will not compile until then.
  5. New imports. sync for the WaitGroup, and github.com/yourname/taskd/internal/mailer for the package. Your editor’s Go tooling adds both when you save.

You will need import "sync" and the mailer import present before this compiles. Until Step 15 adds janitor, go build ./... will report app.janitor undefined — that is expected, and the tail of the chapter fixes it.


Step 8 — Teach shutdown to wait

Chapter 4 built a shutdown sequence: on SIGINT or SIGTERM, stop accepting connections, let in-flight requests finish, exit. There is now a third kind of work in the building, and it is not in-flight requests. Two lines, added to serve() in cmd/api/server.go, after the shutdownError check succeeds and before the final return:

// cmd/api/server.go — add inside serve(), after the <-shutdownError check
    app.logger.Info("waiting for background tasks", "addr", srv.Addr)
    app.wg.Wait()

app.wg.Wait() blocks until the counter reaches zero. If three activation emails are mid-flight when you press Ctrl-C, the process waits for all three, then exits.

The shutdown choreography now reads:

   SIGTERM
      │
      ▼
 ┌────────────────┐  ┌───────────────────┐  ┌──────────────────┐
 │ 1. stop        │  │ 2. drain in-flight│  │ 3. drain         │   ┌──────┐
 │    accepting ──┼─▶│    HTTP requests ─┼─▶│  background work ┼──▶│ exit │
 │ srv.Shutdown() │  │ (same call, ≤30s) │  │ app.wg.Wait()    │   └──────┘
 └────────────────┘  └───────────────────┘  └──────────────────┘
      ch. 4                 ch. 4                ch. 21 (new)

Each stage waits for the previous; nothing is dropped.

Warning

Stage 3 has no timeout. wg.Wait() waits as long as it takes. That is safe here only because every job it counts is bounded — the mailer’s own 10-second timeout, times three attempts, is the worst case for one email. Put unbounded work on this WaitGroup and you have converted “graceful shutdown” into “shutdown that never finishes”. Compose’s stop_grace_period and Kubernetes’ termination grace period exist as the outer limit, and they end in SIGKILL.

What you should see — start the server with make run/api, then press Ctrl-C. Three lines, in this order:

level=INFO msg="shutting down server" signal=interrupt
level=INFO msg="waiting for background tasks" addr=:4000
level=INFO msg="stopped server" addr=:4000

(Each line is also prefixed with time= and the timestamp, from the text log handler in Chapter 3.)


Part C — the activation flow

Step 9 — New scopes and token validation

Chapter 11 defined one scope constant. Three more join it, in internal/data/tokens.go:

// internal/data/tokens.go — replaces the single-constant block from ch. 11
const (
    ScopeAuthentication = "authentication"
    ScopeActivation     = "activation"
    ScopePasswordReset  = "password-reset"
    ScopeEmailChange    = "email-change"
)

func ValidateTokenPlaintext(v *validator.Validator, token string) {
    v.Check(token != "", "token", "must be provided")
    v.Check(len(token) == 26, "token", "must be 26 bytes long")
}

What this code says

  1. Four constants, one const ( ... ) block. ScopePasswordReset and ScopeEmailChange are not used until Chapter 22; they are declared together because the set of scopes is one idea and splitting it across two chapters would make the token table’s meaning harder to find.
  2. ValidateTokenPlaintext follows the validator pattern from Chapter 8 (CRUD done properly): each v.Check records a field-name → message pair when its condition is false, and the handler turns any collected errors into a 422.
  3. len(token) == 26 — every token this codebase generates is 16 random bytes encoded as base32, which is always exactly 26 characters. Checking the length before hashing means a token of the wrong length gets a clear validation message instead of a generic “invalid token”.

Step 10 — Registration learns to email

The tail of registerUserHandler in cmd/api/users.go, after the insert. The insert now yields activated: false from the new column default, without a line of Go changing.

// cmd/api/users.go — the tail of registerUserHandler, replacing the old 201 write
    token, err := data.GenerateToken(user.ID, 3*24*time.Hour, data.ScopeActivation)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
        Hash: token.Hash, UserID: user.ID,
        Expiry: token.Expiry, Scope: token.Scope,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.background(func() {
        data := map[string]any{
            "name":            user.Name,
            "activationToken": token.Plaintext,
        }
        if err := app.mailer.Send(user.Email, "activation.tmpl", data); err != nil {
            app.logger.Error("sending activation email", "error", err)
        }
    })

    app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)

What this code says, line by line

  1. data.GenerateToken(user.ID, 3*24*time.Hour, data.ScopeActivation) — the same function Chapter 11 wrote for login tokens. Three days of validity, and the scope is the only thing that makes this an activation token rather than a session.
  2. InsertToken before the send. The row is safely in Postgres before any email exists. Do it the other way round and a database failure produces an email pointing at a token that does not exist — an unfixable dead end for that user.
  3. app.background(func() { ... }) — an anonymous function passed as the fn parameter. This is a closure: a function written inside another function that remembers (“captures”) the variables around it even after the outer function has returned. It captures user and token, so those values remain valid inside the goroutine long after registerUserHandler has returned. A photograph that keeps the scene after everyone has left the room.
  4. What it does not capture is r. This is the whole reason the pattern exists. The request’s context is cancelled the instant the handler returns; using r.Context() here would hand the goroutine a signal that says “already over”.
  5. app.logger.Error(...) and no more. The email failing cannot affect the response — the response has already been sent. All the background work can do is record the failure. The user’s recovery is the resend endpoint in Step 12.
  6. http.StatusAccepted — 202, not 201. Precise HTTP for “created, processing continues”.
New word

202 Accepted — “we took your request and will finish it in the background.” 201 Created claims the work is complete; here it is not, because the email has not gone yet. Think of 202 as “your order is placed” — before the food is cooked.

Note

Inside the closure, the local variable data shadows the imported package data. It compiles, because nothing inside those braces refers to the package — but it is a trap waiting for the next person to edit the block. The extracted version in the next step renames it to payload, which is the fix.


Step 11 — The activation endpoint

One new query and one new handler. First the query, appended to sql/queries/users.sql:

-- sql/queries/users.sql (additions)

-- name: ActivateUser :one
UPDATE users SET activated = true, version = version + 1
WHERE id = $1
RETURNING id, created_at, name, email, activated, version;

The version = version + 1 keeps the optimistic-locking column from Chapter 8 honest: any change to a row bumps its version. The RETURNING list deliberately omits password_hash, so the row this query produces is structurally incapable of leaking it.

Run make sqlc to regenerate internal/db. It prints nothing when it succeeds.

Now the handler. This is a new file — cmd/api/accounts.go — which will collect every account-lifecycle handler, including Chapter 22’s.

// cmd/api/accounts.go — new file
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Token string `json:"token"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    if data.ValidateTokenPlaintext(v, input.Token); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Same move as the login middleware — hash the presented token,
    // look the hash up — but with the activation scope: the emailed
    // token IS the credential here; no login required or expected.
    hash := sha256.Sum256([]byte(input.Token))
    user, err := app.q.GetUserForToken(r.Context(), db.GetUserForTokenParams{
        Hash: hash[:], Scope: data.ScopeActivation,
    })
    if err != nil {
        v.AddError("token", "invalid or expired activation token")
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Flip the flag (version bump included), then burn every
    // outstanding activation token — single use, enforced.
    activated, err := app.q.ActivateUser(r.Context(), user.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    _ = app.q.DeleteTokensForUser(r.Context(), db.DeleteTokensForUserParams{
        UserID: user.ID, Scope: data.ScopeActivation,
    })

    app.writeJSON(w, http.StatusOK, envelope{"user": activated}, nil)
}

The file needs these imports: crypto/sha256, net/http, and the project’s internal/data, internal/db and internal/validator packages.

What this code says, line by line

  1. if data.ValidateTokenPlaintext(v, input.Token); !v.Valid() — an if with an init statement: run the first part, then test the condition. The function returns nothing; it fills v with any problems, and !v.Valid() asks whether it found any.
  2. sha256.Sum256([]byte(input.Token)) — hash the token the user presented. The database stores only hashes, exactly as Chapter 11 established, so the lookup must hash first. hash[:] converts the fixed-size [32]byte array into the []byte slice the query wants.
  3. Scope: data.ScopeActivation — the pin. An authentication token presented here finds nothing, because the query requires hash and scope to match. The scope column is what stops one kind of token being spent as another.
  4. The single error branch after the lookup. Not found, expired, wrong scope — all three produce the same 422 with “invalid or expired activation token”. The user cannot tell which, and does not need to; the recovery is identical in every case.
  5. _ = app.q.DeleteTokensForUser(...) — the blank identifier discards the returned error on purpose. The account is already activated; if the cleanup delete fails, the correct behaviour is still to report success, and the leftover rows are the janitor’s problem an hour from now.
  6. Deleting all activation tokens, not just this one, is what makes the emailed token single-use. Press the same request twice and the second attempt gets the 422.

Step 12 — Extract sendActivationEmail, then build the resend endpoint

A second caller for the token-mint-and-background-send is about to appear, so extract it now. This is the “extract on the second caller” rule: write it inline the first time, factor it out the moment you need it twice, and no earlier.

Note

The original book never prints this helper, although Chapter 22 (Password reset and the account lifecycle) calls it by name and describes it as this chapter’s token-mint-and-background-send, “extracted into a 15-line helper”. This is that extraction, shown here for the first time.

// cmd/api/users.go — new function, replacing the inline block from Step 10
// sendActivationEmail mints an activation token, stores its hash, and hands
// the SMTP dial to a counted background goroutine.
//
// Note the closure captures plain values rather than touching r — the
// request (and its context) is gone by the time the goroutine runs.
func (app *application) sendActivationEmail(r *http.Request, userID int64, name, email string) error {
    token, err := data.GenerateToken(userID, 3*24*time.Hour, data.ScopeActivation)
    if err != nil {
        return err
    }
    err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
        Hash: token.Hash, UserID: userID,
        Expiry: token.Expiry, Scope: token.Scope,
    })
    if err != nil {
        return err
    }

    app.background(func() {
        payload := map[string]any{
            "name":            name,
            "activationToken": token.Plaintext,
        }
        if err := app.mailer.Send(email, "activation.tmpl", payload); err != nil {
            app.logger.Error("sending activation email", "error", err)
        }
    })
    return nil
}

The tail of registerUserHandler now shrinks to one call plus the response:

// cmd/api/users.go — the tail of registerUserHandler, after the extraction
    // ch. 21: the row lands unactivated (the column default flipped to
    // false), so the account is only usable once the emailed token comes
    // back. Mail is minted and sent in the background.
    if err := app.sendActivationEmail(r, user.ID, user.Name, user.Email); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // 202, not 201: created, and processing (the email) continues.
    app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil)

What changed, and what didn’t

  • The function takes r — because the database insert legitimately belongs to the request and should be cancelled if the client hangs up. It takes name and email as plain strings — because the email send does not, and must not, touch the request.
  • Both error paths return the error instead of writing a response, so each caller decides. The registration handler turns it into a 500.
  • The behaviour is identical to Step 10’s inline version. Nothing about the running program changed.

Now the resend endpoint. If an activation email is lost — an outage, a typo in the relay config, a spam filter — this is the user’s only way out, which is why the queue argument in section 4 leans on it. The original describes it in prose only:

Note

createActivationTokenHandler is described in one sentence in the original and never printed, even though the routes table registers it. This listing is that description turned into code, following the same patterns as the handlers around it.

// cmd/api/accounts.go — add this function below activateUserHandler
// createActivationTokenHandler is the resend endpoint. It ALWAYS answers 202
// with the same message: a 404-vs-202 split would turn it into an
// account-existence oracle for whoever wants your user list.
func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email string `json:"email"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    data.ValidateEmail(v, input.Email)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    msg := "if that address has an unactivated account, an email is on its way"

    user, err := app.q.GetUserByEmail(r.Context(), input.Email)
    if err != nil || user.Activated {
        app.writeJSON(w, http.StatusAccepted, envelope{"message": msg}, nil)
        return
    }

    if err := app.sendActivationEmail(r, user.ID, user.Name, user.Email); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.writeJSON(w, http.StatusAccepted, envelope{"message": msg}, nil)
}

What this code says, line by line

  1. data.ValidateEmail(v, input.Email) — the same validator Chapter 10 used at registration. A malformed address is a genuine client mistake and gets a 422; that leaks nothing, because it is a statement about the string, not about the database.
  2. msg declared once and written twice. Two different code paths, byte-identical response. If the two messages were written separately they would drift apart in some future edit, and the difference would be the leak.
  3. if err != nil || user.Activated — three situations collapse into one response: no such user, the lookup failed, or the account is already activated. All get 202 and the same sentence.
  4. Why not a helpful 404? Because “404 for unknown addresses, 202 for known ones” is an account-existence oracle: anyone with a list of email addresses can discover which ones have taskd accounts by watching the status codes. That list is worth money to a phishing campaign. Chapter 10 met this idea as user enumeration; this is the same problem, and here we can afford the uniform answer, so we take it.
  5. sendActivationEmail failing is a 500, unlike the send failing later. The difference: this error comes from token generation or the database insert, both of which happen before the response, so we can still tell the truth about them.
Note

There is an honest inconsistency here worth naming, because the original book promised to revisit it and never did. registerUserHandler still answers “a user with this email address already exists” on a duplicate signup — which is exactly the oracle this endpoint refuses to be. Chapter 10 documented that as an accepted trade-off (a registration form that cannot tell you your address is taken is genuinely worse to use) and said it would be revisited “with the SMTP work”. This is the SMTP work, and the trade-off is being re-accepted rather than resolved. The fix, if you want it: answer the same 202 for a duplicate and send the existing account a “someone tried to sign up with your address” email. That costs one more template and one more branch.


Part D — the gate

Step 13 — The 403 response, and the middleware that sends it

First the response function. errors.go collects every error the API can emit, one small function per shape, so the wording of an error lives in exactly one place.

Note

The original references inactiveAccountResponse only in a code comment — “403 in errors.go” — and never lists it. Here it is, with the status and message the comment specifies.

// cmd/api/errors.go — add at the end
// --- ch. 21: activation ----------------------------------------------

func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) {
    app.errorResponse(w, r, http.StatusForbidden,
        "your account must be activated to access this resource")
}

errorResponse is the single funnel from Chapter 8: it wraps the message in {"error": ...} and writes it with the given status. http.StatusForbidden is 403.

New word

403 Forbidden — “we know who you are; you’re still not allowed.” Contrast with 401 Unauthorized, which means “I don’t know who you are — try again with credentials.” Sending 401 here would tell the user to log in again, which they have already done, and which would not help.

Now the middleware, in cmd/api/middleware.go:

// cmd/api/middleware.go — add this middleware
func (app *application) requireActivatedUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := app.contextGetUser(r)
        if !user.Activated {
            app.inactiveAccountResponse(w, r) // 403 in errors.go:
            return                            // "your account must be activated"
        }
        next.ServeHTTP(w, r)
    })
}

What this code says

  1. The middleware shape is Chapter 4’s, unchanged: take the next handler, return a new handler that does something and then either calls next.ServeHTTP or does not.
  2. app.contextGetUser(r) retrieves the user that the authenticate middleware attached to the request context. It never fails: an unauthenticated request carries the AnonymousUser value, whose Activated field is false — so an anonymous request that somehow reached here would be refused, which is the right default.
  3. return without calling next is how a middleware stops a request. Forgetting that return would send the 403 and run the handler — the bug Chapter 8’s pitfalls warned about, producing http: superfluous response.WriteHeader call in the terminal.

Step 14 — Three rings in routes.go

Routing now expresses the picture from section 5:

// cmd/api/routes.go — inside routes(), within the r.Route("/v1", ...) block
// public:
r.Post("/users", app.registerUserHandler)
r.Post("/tokens/authentication", app.createAuthTokenHandler)
r.Post("/tokens/activation", app.createActivationTokenHandler)
r.Put("/users/activated", app.activateUserHandler)

// authenticated (activation not required — ch. 22 lives here):
r.Group(func(r chi.Router) {
    r.Use(app.requireAuthenticatedUser)

    // activated:
    r.Group(func(r chi.Router) {
        r.Use(app.requireActivatedUser)
        // ... all existing /tasks and /billing routes move here unchanged
    })
})

What this code says

  1. r.Group(func(r chi.Router) { ... }) creates a nested scope with its own middleware. Routes registered inside it get the group’s middleware; routes outside do not. Nothing about the handlers changes — only which wrappers they sit inside.
  2. The two new public routes. POST /v1/tokens/activation is the resend endpoint; PUT /v1/users/activated is the activation itself. Both must be public: someone who cannot get in is precisely who needs them.
  3. PUT, not POST, for activation — because it is idempotent in intent: it sets a flag to a known value rather than creating a new thing each time.
  4. “move here unchanged” means literally cut and paste: the r.Route("/tasks", ...) and r.Route("/billing", ...) blocks you already have, moved inside the inner group. Their bodies do not change at all.
  5. The two new routes join the per-IP rate-limit group from Chapter 14 (Rate limiting) if you have one wrapping the public POSTs. An unauthenticated endpoint that sends email is a mail cannon otherwise.
Common mistake

You’ll see: everything still works, including /v1/tasks for an unactivated user. It means: the /tasks routes are still registered in the outer group — the paste moved the middleware but not the routes. Fix: the r.Route("/tasks", ...) block must be inside the braces of the group that calls r.Use(app.requireActivatedUser). Count the closing braces.


Part E — janitors, and the suite this chapter breaks

Step 15 — The janitors

Two DELETE statements and a loop. First the queries:

-- sql/queries/tokens.sql (addition)
-- name: DeleteExpiredTokens :exec
DELETE FROM tokens WHERE expiry < now();
-- sql/queries/billing.sql (addition)
-- name: PruneStripeEvents :exec
DELETE FROM stripe_events WHERE received_at < now() - interval '30 days';
Note

If you did Chapter 11’s Exercise 1, you already wrote DeleteExpiredTokens yourself. This is the same query — compare the two, then keep one. Thirty days for stripe_events is chosen to comfortably out-last Stripe’s own webhook retry window, which is the only thing that ledger has to out-remember.

Then the loop, in the file background.go from Step 6:

// cmd/api/background.go (addition) — started once from main: go app.janitor()
func (app *application) janitor() {
    ticker := time.NewTicker(time.Hour)
    defer ticker.Stop()
    for range ticker.C {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        if err := app.q.DeleteExpiredTokens(ctx); err != nil {
            app.logger.Error("janitor: tokens", "error", err)
        }
        if err := app.q.PruneStripeEvents(ctx); err != nil {
            app.logger.Error("janitor: stripe events", "error", err)
        }
        cancel()
    }
}

Add "context" and "time" to the file’s imports. Run make sqlc again so DeleteExpiredTokens and PruneStripeEvents exist as Go methods.

What this code says, line by line

  1. ticker := time.NewTicker(time.Hour) — a ticker is a Go timer that fires repeatedly. It owns a channel, ticker.C, and sends the current time into that channel once an hour. The hourly church bell.
  2. defer ticker.Stop() — releases the ticker’s resources when the function returns. It never does return here, but the habit costs nothing and protects the next person who changes the loop.
  3. for range ticker.C — ranging over a channel means “take each value as it arrives, and block in between”. The loop body therefore runs once an hour and the goroutine costs nothing in between. The value itself is discarded; only the timing matters.
  4. context.Background(), not a request context — there is no request. context.Background() is the root context: no deadline, no cancellation, the starting point when work belongs to the program rather than to a caller. WithTimeout(..., 30*time.Second) then bounds it, so a pathological delete cannot hold a pool connection forever.
  5. cancel() at the end of each iteration, not defer cancel()defer would run only when the function exits, which is never, so the contexts would pile up for the life of the process. This is one of the few places where the “always defer cancel()” habit is wrong.
  6. Errors are logged, not returned. Nobody is waiting. A failed sweep is retried in an hour by the nature of the loop.

Why this goroutine is not on the WaitGroup. Look again at Step 8: shutdown calls app.wg.Wait(), which blocks until the counter is zero. janitor never returns. Put it on the WaitGroup and the counter never returns to zero, so every shutdown hangs — a deadlock — and after your orchestrator’s grace period the process is SIGKILLed anyway.

It does not need protection, either, and knowing why is the point. Each sweep is a single DELETE statement, which Postgres executes atomically: it either fully happens or fully does not. Killed halfway, nothing is half-deleted and nothing is lost — the next process to boot sweeps again an hour later.

Remember this

Only finite work joins the WaitGroup. Infinite loops stay off it, and earn that by being safe to kill mid-stride.


Step 16 — Repair the test suite this chapter just broke

Chapter 20 (Testing what matters) wrote TestTaskLifecycle: register a user, log in, create a task, expect 201. As of Step 14 that test gets 403 your account must be activated, because its helper does exactly what the old curl sequence did.

Note

The original book demonstrates the new 403 for curl and never mentions the test it invalidates. This step is new. A feature that changes an invariant changes the fixtures — that is not overhead, it is the maintenance skill the book is trying to teach.

The test fixture cannot read email, and should not try. It flips the flag directly:

// cmd/api/testutils_test.go — replaces registerAndLogin
// registerAndLogin creates an account and returns a usable bearer token.
// ch. 21 made new users unactivated and put the activation token behind
// email — so the fixture flips the flag directly, which is why it now also
// takes the *application.
func registerAndLogin(t *testing.T, app *application, srv *httptest.Server, email string) string {
    t.Helper()

    res := doJSON(t, srv, "POST", "/v1/users", "",
        fmt.Sprintf(`{"name":"Test User","email":%q,"password":"pa55word123"}`, email))
    if res.StatusCode != http.StatusAccepted && res.StatusCode != http.StatusCreated {
        t.Fatalf("register %s: status = %d", email, res.StatusCode)
    }

    _, err := app.db.Exec(context.Background(),
        `UPDATE users SET activated = true WHERE email = $1`, email)
    if err != nil {
        t.Fatal(err)
    }

    res = doJSON(t, srv, "POST", "/v1/tokens/authentication", "",
        fmt.Sprintf(`{"email":%q,"password":"pa55word123"}`, email))
    assertStatus(t, res, http.StatusCreated)

    var out struct {
        AuthenticationToken struct {
            Token string `json:"token"`
        } `json:"authentication_token"`
    }
    if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
        t.Fatal(err)
    }
    return out.AuthenticationToken.Token
}

Then update the two call sites in cmd/api/tasks_test.go to pass app:

// cmd/api/tasks_test.go — both calls gain the app argument
    token := registerAndLogin(t, app, srv, "a@example.com")
    ...
    token2 := registerAndLogin(t, app, srv, "b@example.com")

What this code says

  1. res.StatusCode != http.StatusAccepted && res.StatusCode != http.StatusCreated — accept either, so the helper survives the exact chapter boundary where registration changed from 201 to 202. A fixture that pins the old status is a fixture that fails for the wrong reason.
  2. app.db.Exec(...) with raw SQL — the fixture reaches past the API on purpose. Going through the real activation endpoint would mean parsing an email, and the test would then be testing Mailpit.
  3. The helper now takes *application because that is where the pool lives. This is the whole reason the signature changed.
Warning

newTestApplication builds an application with no mailer — the field stays nil. Registration therefore panics inside the background goroutine when it reaches app.mailer.Send, and background()'s recover() catches it and logs it to io.Discard. The test passes and you see nothing. That is acceptable here (the tests do not test email) but it is worth knowing that this is why: the recover() you wrote in Step 6 is what keeps a nil mailer from taking the test binary down.

Run them:

make test/int

What you should see — one line per package: ok followed by the package path and a duration for the packages that have tests, and a line containing [no test files] for the ones that do not. No FAIL anywhere.


7. Checkpoint: prove it works

The end-to-end loop. Start everything:

make sqlc
docker compose up -d
make db/migrations/up
make run/api

Leave that running and open a second terminal.

1. Register a new user.

curl -i -d '{"name":"Ada","email":"ada@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users

You should see HTTP/1.1 202 Accepted and a body containing "activated":false.

2. Read the email. Open http://localhost:8025 in a browser. One message is waiting, from taskd <no-reply@taskd.example>, subject Activate your taskd account. The body contains a 26-character token. Copy it.

3. Log in and get refused.

TOKEN=$(curl -s -d '{"email":"ada@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -i -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

You should see HTTP/1.1 403 Forbidden and {"error":"your account must be activated to access this resource"}. Logging in works; the product does not. That is the middle ring and the inner ring behaving differently, exactly as designed.

4. Activate. Paste the 26 characters from Mailpit:

curl -i -X PUT -d '{"token":"PASTE_THE_26_CHARACTERS_HERE"}' \
  localhost:4000/v1/users/activated

HTTP/1.1 200 OK, and a body whose "user" object now has "activated":true and "version":2 — the version bumped because the row changed.

5. Retry the product.

curl -i -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

HTTP/1.1 200 OK with the usual "tasks" and "metadata" keys.

Checkpoint

That 403200 flip, with nothing in between except pasting a token out of a web inbox, is the chapter working. If you want one more: press Ctrl-C in the server terminal and watch for msg="waiting for background tasks" between the shutdown and stopped lines.

If you got something else:

You got Cause Fix
202 but Mailpit stays empty, and the server logs sending activation email with a connection refused error The API cannot reach Mailpit’s SMTP port docker compose ps — is mailpit running? Is smtp.port 1025 and not 8025?
403 even after activating You activated a different account, or pasted a truncated token The token is exactly 26 characters. curl the activation again; a wrong length gives 422 must be 26 bytes long
401 invalid or missing authentication token at step 5 $TOKEN is empty — step 3’s login failed Re-run step 3 without the `
429 rate limit exceeded Chapter 14’s per-IP limiter, if you fired the public endpoints in a tight loop Wait a few seconds and retry

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: ./main.go:88:24: cfg.smtp undefined (type config has no field or method smtp) It means: the [smtp] block is in config.toml but the Go struct never gained a matching field. TOML and Go do not talk to each other; koanf only knows the keys you ask it for. Fix: Step 3 — all three pieces: the TOML block, the struct field, the k.String/k.Int lines.

Common mistake

You’ll see: internal/mailer/mailer.go:13:12: pattern templates: no matching files found It means: //go:embed templates ran at compile time and there is no templates folder next to mailer.go — or it exists but is empty. Fix: mkdir -p internal/mailer/templates and create activation.tmpl in it (Step 5). An empty folder is not enough; embed needs at least one file.

Common mistake

You’ll see: in the server log, msg="sending activation email" with an error mentioning dial tcp 127.0.0.1:8025: connect: connection refused, roughly 1.5 seconds after registering. It means: you pointed the mailer at Mailpit’s web port. 8025 serves HTML to browsers; it does not speak SMTP. Fix: port = 1025 in the [smtp] block. The 1.5 seconds is your three retries at half a second apart, working exactly as designed.

Common mistake

You’ll see: nothing wrong at all in development. In production, emails that “sent successfully” according to the logs never arrive, or arrive minutes later. It means: you used r.Context() inside the background closure. The context is cancelled the instant the handler returns, so the SMTP dial is cancelled too — but sometimes it has already succeeded, which is why it looks fine locally. Fix: background work captures values, never r. If it needs a context, it builds one with context.Background().

Common mistake

You’ll see: Ctrl-C prints shutting down server and waiting for background tasks, and then the process sits there until you kill it. It means: something infinite joined the WaitGroup — most likely app.background(app.janitor) instead of go app.janitor(). Fix: the janitor is a plain go call. Only work that finishes may be counted.

Common mistake

You’ll see: ./routes.go:22:26: app.createActivationTokenHandler undefined (or the same for inactiveAccountResponse / sendActivationEmail). It means: a route or middleware references a function that has not been written yet. Fix: Steps 12 and 13. Go compiles the whole package at once, so an unwritten function fails the build even if nothing ever calls it at runtime.


9. Pitfalls

  • r.Context() in background closures. Repeated from the code comment because it’s the one that gets everyone: the request context is canceled the instant the handler returns, so the “background” work dies immediately — sometimes, in tests, after succeeding, which is the worst kind of bug. Background work builds its own context.

  • Sending before committing. Mail after the token row is safely inserted, never optimistically before — or a DB failure yields an email pointing at a token that doesn’t exist. When a flow has a fallible write and a fallible send, the write goes first and the send is the retryable one.

  • Activation tokens in logs. The plaintext token is a credential for the account. The “never log bodies” rule from Chapter 19 (Structured logging) now protects sign-ups too — one more reason it was a rule and not a habit. Note what the code logs when a send fails: the error, not the payload.

  • Blocking the drain. Only finite work joins the WaitGroup. The janitor’s infinite loop stays off it, or every deploy hangs for the full shutdown timeout — a bug you’d meet ten chapters of uptime from now, at 2 a.m., during a rollback.

  • SMTP creds in config.toml. Same rule as Stripe keys: dev values in the file, real values via TASKD_SMTP__* env only. The mail relay password is a spam cannon to whoever finds it.

  • Deliverability is not covered by any of this. Mailpit accepts everything, which makes development pleasant and proves nothing about the real world. A real relay needs a sending domain with SPF and DKIM records so receiving servers believe the mail is yours, and the first messages from a new domain often land in spam regardless. Since taskd is unusable until the activation email arrives, deliverability is your signup funnel. Budget a day for it before launch, and note that a user whose activation mail bounces permanently is currently stuck: the resend endpoint will bounce too.


10. Check yourself — quiz

  1. Why can’t registerUserHandler send the activation email itself and then return?
  2. Name the four rungs of the background-work ladder, and the one-sentence criterion for jumping to the top one.
  3. What exactly does app.wg.Add(1) count, and what would go wrong if it were called inside the goroutine instead of before it?
  4. background() has two deferred functions. What disaster does each one prevent, and why is their registration order load-bearing?
  5. Registration returns 202 instead of 201. What is the difference, in one sentence each?
  6. Why is the janitor started with go app.janitor() rather than app.background(app.janitor)?
  7. The resend endpoint answers 202 for an address with no account, an address with an activated account, and an address with an unactivated account. What would a 404 in the first case give away?
  8. Existing users in the database stay activated after migration 000006. Why does that require no UPDATE statement?
Answers
  1. Because SMTP is slow (hundreds of milliseconds to seconds) and unreliable, so doing it inline couples both signup latency and signup availability to a mail relay you don’t control. If the relay is down, a perfectly successful account creation returns 500.

  2. (1) a bare go func(); (2) a counted goroutine with sync.WaitGroup plus recover(); (3) an in-process worker pool with a bounded queue; (4) a durable queue such as River or a Postgres table with FOR UPDATE SKIP LOCKED. The criterion: the moment a background job appears whose loss costs money or trust with no user-visible retry path, it graduates straight to (4).

  3. It counts one outstanding background job, so serve()'s app.wg.Wait() knows whether it is safe to exit. Called inside the goroutine, there would be a window where the handler has returned but the goroutine hasn’t run yet — during which the counter reads zero and shutdown could exit, dropping the email. Increment before you start, always.

  4. defer app.wg.Done() guarantees the counter comes back down even if the work panics — otherwise shutdown hangs forever. defer func(){ recover() }() stops a panic in background work from killing the whole process, because an unrecovered panic in any goroutine terminates the program. Order matters because deferred calls run last-registered-first: Done() is registered first so it runs last, i.e. after the recover has already neutralised the panic.

  5. 201 Created says the work is finished and a new thing exists. 202 Accepted says the request was taken and processing continues — true here, because the email has not been sent when the response goes out.

  6. Because janitor never returns. app.background would add it to the WaitGroup, and app.wg.Wait() at shutdown would then block forever — a deadlock on every deploy. It doesn’t need the protection anyway: each sweep is one atomic DELETE, safe to kill mid-loop.

  7. A 404-versus-202 split turns the endpoint into an account-existence oracle: anyone with a list of addresses can discover which have taskd accounts by watching the status codes. That is user enumeration, and the list is directly useful for phishing and credential stuffing.

  8. Because a column DEFAULT is consulted only at INSERT time. ALTER COLUMN ... SET DEFAULT false changes what future inserts get; it never reads or rewrites existing rows. That is grandfathering, and it is why this schema change is also a correct data migration by doing nothing.


11. Practice

Exercise 1 — Drill the full activation loop (easy)

Do the section 7 sequence twice, from a clean start, without looking at the commands the second time. Then answer in your notes: at which exact step does the account become usable, and which two database rows changed?

Solution

The sequence: register (202) → read Mailpit → log in (201) → GET /v1/tasks (403) → PUT /v1/users/activated (200) → GET /v1/tasks (200).

The account becomes usable at the PUT. Two rows changed: the users row (activated false → true, version 1 → 2) and the tokens table lost every row for that user with scope = 'activation' — deleted by DeleteTokensForUser, which is what makes the emailed token single-use.

Verify from SQL:

make db/psql
-- typed at the psql prompt, not saved to a file
SELECT email, activated, version FROM users WHERE email = 'ada@example.com';
SELECT scope, expiry FROM tokens WHERE user_id = (
    SELECT id FROM users WHERE email = 'ada@example.com');

The first query shows t and 2. The second shows only the authentication token from your login — no activation row survives.

Exercise 2 — Watch the janitor work (medium)

An hourly loop is hard to observe. Temporarily speed it up, plant an already-expired token by hand, and watch it disappear.

Solution

Change one number in cmd/api/background.go:

// cmd/api/background.go — TEMPORARY: 10 seconds instead of an hour
    ticker := time.NewTicker(10 * time.Second)

Restart the server, then plant an expired row. Any 32 bytes will do for the hash, because nothing is going to look it up:

make db/psql
-- typed at the psql prompt, not saved to a file
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES (sha256('never-used'::bytea), 1, now() - interval '1 day', 'activation');

SELECT count(*) FROM tokens WHERE expiry < now();

The count is 1. Wait fifteen seconds, run the SELECT again: it is 0. If user id 1 does not exist in your database, use an id that does — the foreign key is enforced.

Put time.Hour back when you are done. Ten seconds of DELETE traffic forever is not a production setting.

Exercise 3 — Add a welcome email (harder)

Send a second email at registration: a welcome message with no token in it. The point is to do the chapter’s own move once more, alone.

Solution

A new template file, internal/mailer/templates/welcome.tmpl, with the same three sections:

{{define "subject"}}Welcome to taskd{{end}}

{{define "plainBody"}}
Hi {{.name}},

Your taskd account is ready. Activate it with the token in the other email,
then start creating tasks.
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html><body>
<p>Hi {{.name}},</p>
<p>Your taskd account is ready. Activate it with the token in the other
email, then start creating tasks.</p>
</body></html>
{{end}}

No //go:embed change is needed — the directive embeds the whole folder, so a new file in it is picked up on the next build. That is the payoff of embedding a directory rather than naming files.

Then one more send inside the same background block in sendActivationEmail, so both emails share one goroutine and one WaitGroup slot:

// cmd/api/users.go — inside sendActivationEmail's app.background closure
    app.background(func() {
        payload := map[string]any{
            "name":            name,
            "activationToken": token.Plaintext,
        }
        if err := app.mailer.Send(email, "activation.tmpl", payload); err != nil {
            app.logger.Error("sending activation email", "error", err)
        }
        if err := app.mailer.Send(email, "welcome.tmpl", payload); err != nil {
            app.logger.Error("sending welcome email", "error", err)
        }
    })

Verify: register a new user, refresh http://localhost:8025. Two messages, same recipient.

Then think about it: the welcome mail has no recovery path — no resend endpoint, nothing the user can do if it is lost. By the chapter’s own criterion, does it belong on rung 2? Yes, but only because losing it costs nothing: it carries no credential and blocks nothing. Apply the criterion to the consequence of loss, not to the code.


12. FAQ

Where do emails actually go in development? Nowhere. Mailpit accepts every message and delivers none of them; it shows you what it caught at http://localhost:8025. You can register with anyone@anywhere.test and read the mail. Nothing leaves your machine, which is exactly what you want while you are typing test addresses into curl.

What happens if the server crashes between the token insert and the email send? The token row exists and no email was sent. That user cannot activate — until they use the resend endpoint, which mints a fresh token and tries again. This is the honest cost of rung 2 on the ladder, stated up front rather than discovered in production. It is acceptable only because that recovery path exists.

Why not a proper job queue? Because a durable queue is a dependency, a schema, a worker process, and a whole class of new failure modes (“the queue is backed up”), and today taskd has exactly one background job whose loss the user can fix themselves. Adding it now would be building infrastructure for a problem you do not have. The criterion in section 4 tells you the day that changes — and on that day you build it, for that job.

Why must users activate at all? It is friction on my signup funnel. It is, and it is worth it. Without it: password resets go to mistyped addresses and lock people out permanently; anyone can create an account with someone else’s address, which is a harassment vector and a support nightmare; and every “we could not charge your card” email you send from Chapter 17 goes into the void. The friction buys a user list you can act on.

Why is the email template inside the binary instead of a file next to it? Because “one binary, no runtime assets” is a rule this codebase keeps, and rules are only worth anything when they hold in the inconvenient case. A binary plus a templates/ folder is a deployment instruction somebody will eventually forget, and the failure appears at send time — in production, when a real person is waiting for a real email.

Can I use SendGrid, Postmark or Amazon SES instead? Yes, and you will in production. All of them speak SMTP, so the change is four environment variables — TASKD_SMTP__HOST, __PORT, __USERNAME, __PASSWORD — and no code at all; setting a username is what switches the mailer into authenticated, TLS mode. Their HTTP APIs are richer (delivery events, bounce webhooks), and swapping Mailer.Send for one is a contained change precisely because every caller only knows Send(recipient, template, data).


13. Where we are

taskd now does work that outlives a request, and has a real account lifecycle: sign up, receive mail, activate, use the product. It also finally cleans up after itself. Chapter 22 (Password reset and the account lifecycle) reuses every piece of this — the mailer, background(), the scope column, the uniform-202 pattern — to add reset, change-password, change-email and delete-account.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go            # UPDATED: wg + mailer fields, janitor started
│   ├── server.go          # UPDATED: app.wg.Wait() in the shutdown sequence
│   ├── routes.go          # UPDATED: three rings, two new public routes
│   ├── config.go          # UPDATED: cfg.smtp struct + five koanf lines
│   ├── background.go      # NEW: background() and janitor()
│   ├── accounts.go        # NEW: activation + resend handlers
│   ├── users.go           # UPDATED: 202, sendActivationEmail
│   ├── middleware.go      # UPDATED: requireActivatedUser
│   ├── errors.go          # UPDATED: inactiveAccountResponse
│   ├── db.go  helpers.go  context.go  healthcheck.go  tasks.go
│   ├── tokens.go  billing.go  webhooks.go  entitlements.go  metrics.go
│   ├── testutils_test.go  # UPDATED: registerAndLogin activates its user
│   └── tasks_test.go      # UPDATED: both registerAndLogin calls pass app
├── internal/
│   ├── mailer/            # NEW
│   │   ├── mailer.go
│   │   └── templates/
│   │       └── activation.tmpl
│   ├── cache/  data/  db/  validator/
│   └── data/tokens.go     # UPDATED: four scopes + ValidateTokenPlaintext
├── migrations/            # NEW: 000006_activation up + down
├── sql/queries/
│   ├── users.sql          # UPDATED: ActivateUser
│   ├── tokens.sql         # UPDATED: DeleteExpiredTokens
│   └── billing.sql        # UPDATED: PruneStripeEvents
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml   prometheus.yml
└── go.mod   go.sum

What works end to end: registration mints an activation token and emails it without making the user wait; the email is visible in a browser inbox; presenting the token unlocks the product; a lost email is recoverable from a public endpoint that leaks nothing; shutting the server down waits for in-flight email; and expired tokens and stale Stripe events delete themselves hourly.

What is still fake or missing:

  • Mail goes to Mailpit, which delivers nothing. A real relay, plus SPF and DKIM records for your sending domain, is production work Chapter 27 (Going live) approaches from the operations side.
  • A forgotten password is still unrecoverable. Chapter 22 fixes that, in this chapter’s middle ring, using this chapter’s mailer.
  • No welcome email, no notification email, no digest. Everything transactional so far is activation.
  • password-reset.tmpl and email-change.tmpl do not exist yet. Chapter 22 writes them, in the three-define shape you just built.

For your notes

Copy these into learnings/ch21.md, in your own words:

  1. Anything slow or flaky goes off the request path. The handler’s job is to make the durable change and answer; the slow, retryable half runs after. 202 Accepted is the honest status code for that arrangement.
  2. The queue criterion, verbatim: the moment a background job appears whose loss costs money or trust with no user-visible retry path, that job graduates straight to a durable queue. Until then, a counted goroutine is not laziness, it is a decision with a stated cost.
  3. Background work captures values, never the request. r.Context() is dead the instant the handler returns. This bug produces no compile error and sometimes no symptom in development, which is precisely why it deserves a rule.
  4. Only finite work joins the WaitGroup. Infinite loops stay off it and earn that by being safe to kill mid-stride — one atomic statement per sweep.
  5. A design decision made six chapters early paid for this feature. The scope column existed before anything needed it, so activation cost a constant and a new value, not a schema change. That is what “affordance for later” buys, and it is worth building deliberately.

Chapter 22 — Password reset and the account lifecycle

An account is not a row you create once and forget. People forget passwords, change jobs and email addresses, get their laptop stolen, and eventually leave. This chapter builds the four flows that every real product needs and every tutorial skips: reset a forgotten password, change a known password, change an email address safely, and delete an account in a way that stops the money first. Nothing new is invented for it. Every flow rides the token machinery from Chapter 11 (Stateful tokens) and the mail pipeline from Chapter 21 (Background work and transactional email), which is the point: a good design pays you back later.

What you’ll be able to do by the end

  • Send a password-reset email, use the token, and watch every old login token stop working.
  • Explain why a reset token lives for 45 minutes when an activation token lives for 72 hours.
  • Change an email address without ever pointing an account at an address nobody has proved they can read.
  • Delete a paying account without leaving a subscription billing a customer who no longer exists.
  • Say, out loud and correctly, what “fail closed” means for a DELETE endpoint.

Time: ~55 minutes reading, ~60 minutes typing.

You need before starting: a working Chapter 21 (Background work and transactional email). Two things must be true: the API runs, and Mailpit catches its mail. Prove both in one drill — register a user and look in the fake inbox:

curl -s -X POST localhost:4000/v1/users \
  -d '{"name":"Sain","email":"sain@example.com","password":"pa55word123"}'

You should get back a JSON body containing a "user" key whose "activated" field is false (registration answers 202, not 201, since Chapter 21 — the email is still on its way). Now open http://localhost:8025 in a browser: Mailpit’s inbox should show one message, Activate your taskd account, containing a 26-character token. If the inbox is empty, the mailer is not wired — go back to Chapter 21 before continuing, because every flow in this chapter delivers its credential by email.

Tip

If that address already exists from an earlier chapter you will get {"error":{"email":"a user with this email address already exists"}} instead. Pick a fresh address and use it consistently for the rest of the chapter.

New word

Mailpit — a development SMTP server with a web inbox at http://localhost:8025. It accepts every email your program sends and shows it in a web page instead of delivering it to a real human. Nothing escapes.


1. The problem, in plain words

Think about a physical key to a flat.

Losing the key is not an unusual event, so buildings have a procedure for it. The procedure is never “the locksmith mails you a copy of your old key” — it is “prove, by some other means, that you are the tenant, and then we change the lock”. The old key stops working. That last part is not a detail; it is the entire purpose. If the old key still opened the door, the procedure would be decoration.

Software accounts have exactly the same three problems, and exactly the same answers:

Real-world event What the account needs What goes wrong if you skip it
Lost key Password reset by email Every forgotten password becomes a support ticket, or a lost customer
Suspicious neighbour Password change plus logging out everywhere The thief keeps their copy of the key
Moving house Email change Your only recovery route points at an address you can no longer read
Moving out Account deletion You keep charging someone who left

Right now, taskd has none of them. A user who forgets their password is stuck forever: no endpoint on the whole API can change password_hash after registration. A user who typed sian@exmaple.com at signup can never activate, never reset, and never fix it, because the fix would have to arrive by email at an address that does not exist. And a user who wants to leave has no way to leave — while their card keeps being charged every month.

The four flows are built together because they share one question: what is this account’s email address actually worth right now? Answer it once and the flows fall out of it.

Why this exists

These flows feel like paperwork, and they are the flows most likely to appear in a news story about your company. Reset is the endpoint attackers probe first, because it hands out account access to whoever controls an inbox. Deletion is the endpoint that shows up on a bank statement when it is wrong. There is no low-stakes half of this chapter.


2. New words in this chapter

Word What it means here
account lifecycle The whole life of an account: created, verified, changed, recovered, deleted.
possession-based authentication Proving who you are by controlling something (an inbox), rather than by knowing something (a password).
session One logged-in period, represented here by one row in the tokens table with scope authentication.
revocation Deliberately destroying a credential before it expires, so it stops working immediately.
password reset Replacing “what you know” with “what you can read in your inbox” — and revoking every existing session on success.
pending email The new address parked in a separate column until a token sent to it confirms the swap.
confirmation token A random string emailed to an address, which proves the recipient can read that address.
TTL (time to live) How long a token is allowed to work before it expires by itself.
enumeration Walking through emails or IDs one by one to discover which ones exist on your system.
timing oracle Learning a secret by measuring how long an answer takes, rather than by reading the answer.
hard delete Removing the row from the database. Gone.
soft delete Marking the row dead with a deleted_at column and filtering it out of every future query.
tombstone The dead-but-present row a soft delete leaves behind.
GDPR European data-protection law; the reason “delete my account” must really delete data.
data minimisation The principle of keeping as little personal data as you can get away with.
best-effort We try, and if it fails we log it and carry on rather than failing the request.
fail closed When something goes wrong, refuse. The opposite of fail open, which allows.
resource_missing Stripe’s error code for “that object is already gone”, treated here as success.
202 Accepted “We took your request; the rest happens in the background.” Used when an email is on its way.
204 No Content Success, and there is deliberately nothing to send back.
409 Conflict Someone else got there first; the state you assumed is no longer true.
23505 Postgres’s error code for a violated UNIQUE constraint.
citext A Postgres text type that compares case-insensitively, so Bob@x.com and bob@x.com are one value.
NULL SQL’s “no value here at all” — not an empty string, and not comparable with =.

3. The goal

The flows every real account eventually needs: password reset by email, password change, email change (safe in both the activated and unactivated cases, via a pending_email column), and account deletion that settles up with Stripe before dropping the row. All of it riding the token machinery and mail pipeline that already exist.


4. The thinking

4.1 Reset is authentication by email possession

There are three classic ways to prove who you are: something you know (a password), something you have (a phone, an inbox), something you are (a fingerprint). A password reset flow quietly swaps the first for the second. Request a short-lived password-reset token by email, present it with a new password, and you are in — without ever knowing the old password.

New word

possession-based authentication — proving identity by demonstrating control of something. A reset link proves you can read one inbox. That is the whole proof.

Which means a reset flow is exactly as strong as the user’s inbox and not one bit stronger. If someone else can read that inbox, they own the account. You cannot fix that from inside taskd, but you can refuse to make it worse, and two consequences follow directly.

Consequence one: the TTL is 45 minutes, not 72 hours. Compare the two tokens honestly:

Activation token Reset token
What it unlocks An empty, brand-new account A live account with data and a card on file
What an attacker gains by stealing it Almost nothing Everything
How urgently the user needs it They have signed up; they might read mail tomorrow They are sitting at the login screen right now
TTL chosen 72 hours 45 minutes

An activation token gates entry to an empty account. A reset token is the account. A stolen inbox backup from last year is useless against a 45-minute window and gold against a 72-hour one.

Remember this

An activation token gates entry to an empty account; a reset token is the account. Never reuse one TTL for the other out of tidiness.

Consequence two: a successful reset revokes every authentication token. Most people reset a password because they suspect someone else has it. If the thief is already logged in — holding a valid 24-hour session token — and the reset only changes password_hash, then the thief keeps their access and the victim gets a false sense of safety. Changing the lock without collecting the outstanding keys is theatre.

Warning

Miss the revocation and reset is theatre. This is the single line of this chapter most often left out of real codebases, and it is the line the whole flow exists for.

4.2 Enumeration, decided once for both endpoints

Chapter 21 (Background work and transactional email) made the resend endpoint answer 202 to everything, so that nobody could use it to test which addresses have accounts. The reset-request endpoint has the identical exposure, so it takes the identical decision: uniform 202, whether or not the email exists.

New word

enumeration — using an endpoint’s different answers to build a list of which accounts exist. “No such user” versus “email sent” is a free yes/no oracle, and 10,000 requests turn it into a customer list.

There is a real cost, and pretending otherwise would be dishonest:

Option Wins Loses
Explicit “no matching email address found” (Let’s Go Further does this) The user who typos their address finds out immediately Anyone can enumerate your customers with a script
Uniform 202 for every input (our choice) Nothing leaks A user who typos waits for mail that never arrives
Note

Let’s Go Further is Alex Edwards’s Go book, one of the two influences named in the preface. It returns the explicit error here. That is a legitimate call the other way; what is not legitimate is failing to notice you made a call at all.

We pick the quiet option, and we write the cost down.

4.3 Email change is the subtle one

Here is the naive version, which is what most people write first:

PUT /v1/me/email  {"new_email": "...", "password": "..."}
        │
        └──▶ check password ──▶ UPDATE users SET email = new_email

Look at what that allows. One password check — from a laptop left unlocked for thirty seconds, or a browser session someone borrowed — repoints the account at an address nobody has proved they can read. The attacker then requests a password reset to their address, and the account is theirs forever. The real owner has lost their only recovery route.

The correct shape separates asking from proving:

  1. The new address goes into a separate column, pending_email. The live email is untouched.
  2. A confirmation token goes to the new address.
  3. Only presenting that token swaps the columns.

Throughout, the verified address keeps working. Fat-fingering the new address costs the user nothing at all — the token flies off into the void, nothing changes, they try again.

Except there is one user this ceremony fails: the person from Chapter 21 who mistyped their signup email and therefore cannot receive anything. For them, sending a confirmation to an unverified address protects nothing, because their current address is unverified too. There is no security to preserve; there is only a person locked out of a product they signed up for.

So the reasoning gives us the exception rather than us asserting it: if the account is unactivated, update the address directly (the password is still required) and resend activation to the new address. One handler, two branches, each matching what the current email is actually worth.

Remember this

The ceremony protects the value of a verified address. Where the address has no value, the ceremony has no purpose.

4.4 Deletion order is a money problem

Deleting a user touches two systems: our database and Stripe. Which goes first is not a matter of taste.

Drop the row first and the Stripe subscription keeps billing a customer who no longer exists in your system. You will not notice — your database has no record of them to reconcile against. The person who notices is the ex-customer, on a bank statement, and that is the worst way for a SaaS bug to be discovered.

So the order is fixed: cancel the subscription at Stripe → then delete the row. Deleting the row takes the tasks, tokens and subscription projection with it, because Chapter 11 (Stateful tokens), Chapter 12 (Ownership) and Chapter 15 (Stripe I) each declared their foreign key to users as ON DELETE CASCADE.

New word

ON DELETE CASCADE — a rule on a foreign key that says: when the referenced row is deleted, delete the rows pointing at it too. One DELETE FROM users therefore also empties that user’s tasks, tokens and subscription row.

There is a lovely detail waiting at the end of this. When we cancel at Stripe, Stripe sends us a customer.subscription.deleted webhook — and it arrives after our user row is gone. The Chapter 16 (Stripe II: webhooks) handler already tolerates that: it looks up the user, finds nobody, and skips the work rather than failing. That tolerance was written as defensive style. As of this chapter it is load-bearing.

Deleting the Stripe Customer object as well is a genuine judgment call: tidiness and data minimisation on one side, losing the link from invoice history on the other. We delete it, best-effort, and log if it fails.


5. A picture of it

Six new endpoints, in two groups. What decides the group is whether the caller can already prove who they are.

  PUBLIC RING (no token; behind the ch. 14 per-IP rate limiter)
  ┌──────────────────────────────────────────────────────────────┐
  │ POST /v1/tokens/password-reset   "I forgot my password"      │
  │ PUT  /v1/users/password          "here is the emailed token" │
  │ PUT  /v1/users/email             "here is my change token"   │
  └──────────────────────────────────────────────────────────────┘
        the credential is the EMAILED TOKEN

  AUTHENTICATED RING (valid bearer token; activation NOT required)
  ┌──────────────────────────────────────────────────────────────┐
  │ PUT    /v1/me/password           change a known password     │
  │ PUT    /v1/me/email              start an email change       │
  │ DELETE /v1/me                    close the account           │
  └──────────────────────────────────────────────────────────────┘
        the credential is the BEARER TOKEN + the password again

Walking it:

  1. The public three must be public: a person who has forgotten their password cannot log in first, so their proof of identity arrives by email instead.
  2. The /me three require a valid session and the password again. A borrowed session should not be able to change the lock or close the account.
  3. The /me three sit in the authenticated ring, not the activated ring from Chapter 21, because the unactivated user with the typo’d email needs PUT /v1/me/email most of all.

All four token scopes as they now stand — one table worth pinning above your desk:

Scope TTL What it is, in security terms
authentication 24 hours One session. Holding it is being logged in.
activation 72 hours Proof that an inbox exists, for an account with nothing in it yet.
password-reset 45 minutes Full account takeover in one string. Shortest life for a reason.
email-change 24 hours Proof that a new inbox exists, for an account that already works.

6. The steps

Five parts. Part A is the database, then one part per flow.

Part A — the column and the queries

Step 1 — Add pending_email

We need somewhere to park an address that has been requested but not yet proved. It cannot be the email column itself; that is the entire argument of §4.3.

make db/migrations/new name=pending_email

That runs migrate create -seq -ext sql -dir ./migrations pending_email, which creates an empty .up.sql / .down.sql pair numbered 000007. Fill them in:

-- migrations/000007_pending_email.up.sql — new file
ALTER TABLE users ADD COLUMN pending_email citext;
-- migrations/000007_pending_email.down.sql — new file
ALTER TABLE users DROP COLUMN IF EXISTS pending_email;

What this says, line by line

  • ALTER TABLE users — change the shape of an existing table rather than creating a new one.
  • ADD COLUMN pending_email citextcitext is the case-insensitive text type Chapter 10 (Users and passwords) installed for email. Using the same type means the two columns compare the same way when we later swap one into the other.
  • No NOT NULL, and no UNIQUE. Both omissions are decisions. NULL is the normal state here: almost every row has no email change in flight, and NULL is SQL’s way of saying “no value at all” — distinct from an empty string. And leaving out UNIQUE means two users may covet the same address at once; only one of them can ever land it, because users.email is still UNIQUE. Postgres crowns whoever confirms first.
  • DROP COLUMN IF EXISTS in the down file — the mechanical inverse. IF EXISTS makes rolling back twice harmless instead of an error.
Note

The original edition prints only the up half. The down file is shown here because Chapter 5 (PostgreSQL and migrations) promised every migration is paired with a file that undoes it, and a promise you keep for two migrations out of seven is not a promise.

Apply it:

make db/migrations/up

What you should see: a line naming the version it moved to, then the prompt back. To be sure, open psql with make db/psql and run \d users — the column list should now include a row for pending_email with type citext. Type \q to leave.

Step 2 — Six queries

Open sql/queries/users.sql and add these to the bottom of the file. They are the entire data layer for this chapter.

-- sql/queries/users.sql — add these to the existing file

-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;

-- name: UpdateUserPassword :exec
UPDATE users SET password_hash = $2, version = version + 1 WHERE id = $1;

-- name: UpdateUserEmail :one
UPDATE users SET email = $2, version = version + 1
WHERE id = $1
RETURNING id, created_at, name, email, activated, version;

-- name: SetPendingEmail :exec
UPDATE users SET pending_email = $2 WHERE id = $1;

-- name: ConfirmPendingEmail :one
UPDATE users SET email = pending_email, pending_email = NULL,
    version = version + 1
WHERE id = $1 AND pending_email IS NOT NULL
RETURNING id, created_at, name, email, activated, version;

-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;

What this says, line by line

  • The -- name: comment is the interface, as in every chapter since Chapter 7 (sqlc): the name becomes the Go function, and the suffix declares the shape of the result. :one returns exactly one row, :exec returns nothing but an error.
  • GetUserByID uses SELECT *, unlike the token lookup. That is on purpose. The GetUserForToken query in Chapter 11 deliberately omits password_hash so the middleware’s user object physically cannot leak it. But every flow in this chapter has to check a password, and two of them need pending_email, so these handlers fetch the full row on purpose, once, by ID.
  • version = version + 1 appears on every write. That is the optimistic-concurrency counter from Chapter 8 (CRUD done properly): every change to a user row bumps it.
  • RETURNING id, created_at, name, email, activated, version hands back the updated row minus password_hash and pending_email. The response body is built from what the query returns, so a column that is never selected can never be serialised into JSON by accident.
  • ConfirmPendingEmail is the interesting one. It copies pending_email into email, blanks pending_email, and bumps the version — in one statement, so there is no moment where both columns hold the address. The guard AND pending_email IS NOT NULL means the statement matches nothing when no change is parked.
New word

IS NOT NULL — SQL’s NULL means “no value”, and it is not equal to anything, not even to another NULL. So pending_email = NULL is never true; you must write IS NULL / IS NOT NULL. Getting this wrong produces a query that silently matches zero rows.

That guard has a consequence the handler must cope with: a :one query that matches no rows returns pgx.ErrNoRows. We will handle it in Step 8 rather than let it become a 500.

Regenerate the Go:

make sqlc

What you should see: no output. On success sqlc generate prints nothing and rewrites internal/db/users.sql.go. Inside it you now have GetUserByID(ctx, id) (User, error), SetPendingEmailParams{ID int64; PendingEmail *string}, ConfirmPendingEmail(ctx, id) and the rest. Note PendingEmail is a *string — a pointer to a string — because the column is nullable, and nil is how Go spells NULL.

Part B — reset request

Step 3 — POST /v1/tokens/password-reset

This handler is the front door of the whole flow, and it gives away nothing.

// cmd/api/accounts.go — add this handler (same file as ch. 21's activation handlers)
func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email string `json:"email"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    msg := "if that address has an account, a reset email is on its way"

    user, err := app.q.GetUserByEmail(r.Context(), input.Email)
    if err != nil || !user.Activated {
        app.writeJSON(w, http.StatusAccepted, envelope{"message": msg}, nil)
        return
    }

    token, err := data.GenerateToken(user.ID, 45*time.Minute, data.ScopePasswordReset)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
        Hash: token.Hash, UserID: user.ID,
        Expiry: token.Expiry, Scope: token.Scope,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    app.background(func() {
        payload := map[string]any{"resetToken": token.Plaintext}
        if err := app.mailer.Send(user.Email, "password-reset.tmpl", payload); err != nil {
            app.logger.Error("sending reset email", "error", err)
        }
    })

    app.writeJSON(w, http.StatusAccepted, envelope{"message": msg}, nil)
}

What this code says, line by line

  • The anonymous input struct exists only to receive the body. The backtick text after the field is a struct tag: a label telling the JSON decoder that this field is spelled email in the request.
  • msg := "..." is declared once, above the branch, and used by both exits. That is not tidiness — it is how you make sure the two branches cannot diverge. A future edit that changes one message changes both.
  • if err != nil || !user.Activated — one condition covering two very different situations: no such user, and a user who never proved their address. Both fall into the same generic 202. Unactivated accounts are declined on purpose: resetting a password on an unverified address would strengthen an attacker’s grip on a squatted email.
  • 45*time.Minute — §4.1’s argument, expressed as code. time.Minute is a constant of type time.Duration; multiplying it by 45 gives 45 minutes.
  • data.ScopePasswordReset is the constant "password-reset" from Chapter 21’s scope list. The scope is the only thing distinguishing this token from an activation token in the database.
  • InsertToken stores the token’s hash, never its plaintext. Chapter 11 established this: the plaintext exists only in the email; a database leak yields nothing usable.
  • app.background(func() { ... }) hands the SMTP dial to a counted goroutine, so the HTTP response goes out immediately and graceful shutdown still waits for the mail. The closure captures user and token by value; it does not touch r, whose context is dead the moment the handler returns.
  • http.StatusAccepted is 202: taken, and finishing in the background.
Common mistake

You’ll see: the reset email never arrives, and the log shows a background error mentioning the context being canceled. It means: the closure used r.Context() instead of building its own — the request context is canceled the instant the handler returns. Fix: capture plain values, as above. This is Chapter 21’s pitfall and it catches everyone once.

Note

Unlike the resend endpoint, this handler does not call data.ValidateEmail on the input. It does not need to: a malformed address cannot match a row, so it falls into the same uniform 202. The original edition writes it this way and we keep it — but if you ever want the endpoint to reject junk early, that is the line you would add, and it changes nothing about the uniform response.

Step 4 — The reset email template

The original edition says password-reset.tmpl “follows the activation template’s three-define shape” and never prints it. Without the file, template.ParseFS fails at send time and the flow you are about to rehearse produces no mail at all. Here it is, printed for the first time, reconstructed in exactly the shape Chapter 21 established.

Create internal/mailer/templates/password-reset.tmpl:

{{define "subject"}}Reset your taskd password{{end}}

{{define "plainBody"}}
Hi,

Send the request below within 45 minutes to reset your password:

PUT /v1/users/password
{"password": "your new password", "token": "{{.resetToken}}"}

If you did not request a password reset, ignore this email — nothing has changed.
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html><body>
<p>Hi,</p>
<p>Reset your password within 45 minutes using this token:</p>
<pre>PUT /v1/users/password
{"password": "your new password", "token": "{{.resetToken}}"}</pre>
<p>If you did not request a password reset, ignore this email — nothing has changed.</p>
</body></html>
{{end}}

What this says

  • Three define blocks in one file: subject, plainBody, htmlBody. Chapter 21’s Send method executes all three by name, so a file missing one of them fails at send time.
  • {{.resetToken}} pulls the key resetToken out of the map the handler passed. The key names must match exactly — payload := map[string]any{"resetToken": token.Plaintext} in Step 3, and {{.resetToken}} here.
  • The body names the deadline in words. This is the only place a user learns their token expires, and “it stopped working and nobody told me” is a support ticket you can prevent with one sentence.
  • No {{.name}} here, unlike the activation template — this handler does not pass a name, so referencing one would render empty.
  • The template embeds into the binary automatically: Chapter 21’s //go:embed templates directive takes the whole folder, so a new file in it needs no code change.

Part C — reset completion, and the payoff

Step 5 — PUT /v1/users/password

Here is the flow this handler completes, with the part everyone forgets drawn in:

  user            taskd                     tokens table        thief
   │                │                            │                │
   │ POST reset ───▶│  mint 45-min token ───────▶│ (reset row)    │
   │                │  202 (always the same)     │                │ holds a
   │◀── email ──────│                            │                │ valid auth
   │                │                            │                │ token
   │ PUT password ─▶│  hash token, look up ─────▶│                │
   │   + token      │  UPDATE password_hash      │                │
   │                │  DELETE reset tokens ─────▶│ (gone)         │
   │                │  DELETE auth  tokens ─────▶│ (all gone) ────┼──▶ 401
   │◀─── 200 ───────│                            │                │

The last DELETE is the reason the flow exists.

// cmd/api/accounts.go — add this handler
func (app *application) resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Password string `json:"password"`
        Token    string `json:"token"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    data.ValidatePasswordPlaintext(v, input.Password)
    data.ValidateTokenPlaintext(v, input.Token)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    hash := sha256.Sum256([]byte(input.Token))
    user, err := app.q.GetUserForToken(r.Context(), db.GetUserForTokenParams{
        Hash: hash[:], Scope: data.ScopePasswordReset,
    })
    if err != nil {
        v.AddError("token", "invalid or expired password reset token")
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    var pw data.Password
    if err := pw.Set(input.Password); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.UpdateUserPassword(r.Context(), db.UpdateUserPasswordParams{
        ID: user.ID, PasswordHash: pw.Hash,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // The security payoff: this token family AND every live session die
    // together. If the reset was triggered by a stolen password, the
    // thief's logged-in sessions evaporate the moment the owner resets.
    for _, scope := range []string{data.ScopePasswordReset, data.ScopeAuthentication} {
        _ = app.q.DeleteTokensForUser(r.Context(), db.DeleteTokensForUserParams{
            UserID: user.ID, Scope: scope,
        })
    }

    app.writeJSON(w, http.StatusOK,
        envelope{"message": "your password was successfully reset"}, nil)
}

What this code says, line by line

  • sha256.Sum256([]byte(input.Token)) re-does to the presented token exactly what GenerateToken did when it was minted. We then look up the hash. This mirroring is why the database never has to hold a usable credential.
  • hash[:] converts the fixed-size [32]byte array that Sum256 returns into a []byte slice, which is what the query parameter wants. The [:] means “a slice covering the whole array”.
  • The lookup passes Scope: data.ScopePasswordReset. A stolen activation token cannot be replayed here, because the scope will not match. The query also checks expiry > now() in SQL, so no Go code path can forget the deadline.
  • On a bad token we deliberately answer with a validation error, not a 401. The token is input the client got wrong, and the message says only “invalid or expired” — never which of the two.
  • var pw data.Password; pw.Set(...) runs bcrypt over the new password, producing a fresh salted hash. Chapter 10 built this type.
  • for _, scope := range []string{a, b} — a loop over a two-element slice written inline. range over a slice yields index and value; _ discards the index because we only want the value. It is two DELETEs expressed as one loop.
  • _ = app.q.DeleteTokensForUser(...) — the leading _ = deliberately discards the error. This is Go’s blank identifier: “I have seen this value and I am choosing to ignore it.” The password is already changed; failing the request now would tell the user the reset did not work when it did.
Warning

Deleting ScopeAuthentication here is the difference between a reset and a ritual. Delete the reset token alone and the thief’s session survives the reset that was performed because of the thief.

Part D — the authenticated trio

These three mount inside Chapter 21’s middle ring: a valid session is required, activation is not.

Step 6 — Change a known password

// cmd/api/accounts.go — add this handler
func (app *application) changePasswordHandler(w http.ResponseWriter, r *http.Request) {
    sessionUser := app.contextGetUser(r)

    var input struct {
        CurrentPassword string `json:"current_password"`
        NewPassword     string `json:"new_password"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    v := validator.New()
    data.ValidatePasswordPlaintext(v, input.NewPassword)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user, err := app.q.GetUserByID(r.Context(), sessionUser.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    var pw data.Password
    pw.Hash = user.PasswordHash
    match, err := pw.Matches(input.CurrentPassword)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    if err := pw.Set(input.NewPassword); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.UpdateUserPassword(r.Context(), db.UpdateUserPasswordParams{
        ID: user.ID, PasswordHash: pw.Hash,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    _ = app.q.DeleteTokensForUser(r.Context(), db.DeleteTokensForUserParams{
        UserID: user.ID, Scope: data.ScopeAuthentication,
    })
    app.writeJSON(w, http.StatusOK, envelope{
        "message": "password changed; please authenticate again",
    }, nil)
}

What this code says, line by line

  • sessionUser := app.contextGetUser(r) pulls the user the authenticate middleware stashed in the request context. It is the slim row — no password_hash — which is why the next lines re-fetch.
  • app.q.GetUserByID(r.Context(), sessionUser.ID) gets the full row, including the hash we are about to check against. This is why Step 2 added GetUserByID.
  • pw.Hash = user.PasswordHash then pw.Matches(input.CurrentPassword) — build a Password around the stored hash and ask bcrypt whether the candidate produces it. Matches returns two values with three meanings: (true, nil) match, (false, nil) a plain wrong password (normal life), (false, err) a genuinely broken hash (a real 500). The code handles the error case first, then the mismatch — because they deserve different status codes.
  • app.invalidCredentialsResponse is a 401 reading invalid authentication credentials. Note what it does not say: not “wrong current password”. Same wording as login, deliberately.
  • The final DeleteTokensForUser revokes all authentication tokens — including the one that made this request. The response says so in plain words.
Note

“Log out everywhere” is the simple, safe default. Sparing the current session means threading the requesting token’s hash down through the context and excluding it from the delete. That is a real feature; build it when a user asks for it, not before.

Step 7 — Change an email address (the two-branch handler)

The design argument was §4.3. Here it is as a picture:

                    PUT /v1/me/email  {new_email, password}
                                  │
                        password checked
                                  │
                  ┌───────────────┴────────────────┐
       user.Activated == false          user.Activated == true
                  │                                │
   UPDATE users SET email = new       SET pending_email = new
   (email was unverified anyway)      (live email UNTOUCHED)
                  │                                │
   resend ACTIVATION to new addr      send CHANGE token to new addr
                  │                                │
              202 Accepted                     202 Accepted
                                                   │
                                       PUT /v1/users/email {token}
                                                   │
                                       email ⇄ pending_email, swap
// cmd/api/accounts.go — add this handler
func (app *application) changeEmailHandler(w http.ResponseWriter, r *http.Request) {
    sessionUser := app.contextGetUser(r)

    var input struct {
        NewEmail string `json:"new_email"`
        Password string `json:"password"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }
    v := validator.New()
    data.ValidateEmail(v, input.NewEmail)
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user, err := app.q.GetUserByID(r.Context(), sessionUser.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    var pw data.Password
    pw.Hash = user.PasswordHash
    if match, err := pw.Matches(input.Password); err != nil || !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    if !user.Activated {
        // Signup-typo repair: current email is unverified anyway.
        updated, err := app.q.UpdateUserEmail(r.Context(), db.UpdateUserEmailParams{
            ID: user.ID, Email: input.NewEmail,
        })
        if err != nil {
            var pgErr *pgconn.PgError
            if errors.As(err, &pgErr) && pgErr.Code == "23505" {
                v.AddError("new_email", "already in use")
                app.failedValidationResponse(w, r, v.Errors)
                return
            }
            app.serverErrorResponse(w, r, err)
            return
        }
        app.sendActivationEmail(r, updated.ID, updated.Name, updated.Email)
        app.writeJSON(w, http.StatusAccepted,
            envelope{"message": "email updated; check the new address to activate"}, nil)
        return
    }

    // Verified account: park the address, confirm by token sent to it.
    err = app.q.SetPendingEmail(r.Context(), db.SetPendingEmailParams{
        ID: user.ID, PendingEmail: &input.NewEmail,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    token, err := data.GenerateToken(user.ID, 24*time.Hour, data.ScopeEmailChange)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
        Hash: token.Hash, UserID: user.ID,
        Expiry: token.Expiry, Scope: token.Scope,
    })
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    newAddr := input.NewEmail
    app.background(func() {
        payload := map[string]any{"changeToken": token.Plaintext}
        if err := app.mailer.Send(newAddr, "email-change.tmpl", payload); err != nil {
            app.logger.Error("sending email-change email", "error", err)
        }
    })
    app.writeJSON(w, http.StatusAccepted,
        envelope{"message": "confirmation sent to the new address"}, nil)
}

What this code says, line by line

  • if match, err := pw.Matches(input.Password); err != nil || !match packs three ideas into one line, so unpack it slowly. if x := f(); cond is Go’s if with an initialiser: run the statement, then test the condition, and the variables live only inside the if. Matches returns two values at once, which is how Go functions report a result and an error together. err != nil || !match means “either something broke, or the password was wrong” — and both outcomes get the same 401. Merging them is the point: a caller must not be able to tell a broken hash from a wrong password.
  • PendingEmail: &input.NewEmail — the & takes the address of the field, producing a *string. The generated struct wants a pointer because the column is nullable: nil would mean NULL, and a non-nil pointer means “this address, please”.
  • var pgErr *pgconn.PgError; errors.As(err, &pgErr) && pgErr.Code == "23505"errors.As asks “is this error, anywhere inside its wrapping, a *pgconn.PgError?” and fills the variable if so. 23505 is Postgres’s code for a violated UNIQUE constraint, so this arm means “somebody else already has that address”. Chapter 10 used the identical pattern on registration.
  • app.sendActivationEmail(r, updated.ID, updated.Name, updated.Email) is Chapter 21’s token-mint-and-background-send, extracted into a helper the moment a second caller appeared — which is exactly the right time to extract anything.
  • newAddr := input.NewEmail copies the address into a local before the closure. The closure then captures newAddr, a plain value, rather than reaching into the request-scoped input.
  • The confirmation goes to newAddr, not user.Email. Sending it to the current address would prove nothing about the new one.
Note

sendActivationEmail returns an error, and this call site discards it by calling it as a bare statement. The original edition writes it this way, so we keep it — but it means a failure to mint or store the activation token here is invisible. If you tighten one thing in this chapter later, tighten that: if err := app.sendActivationEmail(...); err != nil { ... }.

Common mistake

You’ll see: cannot use input.NewEmail (variable of type string) as *string value in struct literal It means: you passed the string where the generated params struct wants a pointer, because the column is nullable. Fix: PendingEmail: &input.NewEmail.

Step 8 — Confirm the change (printed here for the first time)

The original edition describes this handler in one sentence — “activateUserHandler’s shape with ScopeEmailChange, ConfirmPendingEmail, and one extra arm” — and never lists it. “It is the same as that other function with three substitutions” is precisely the instruction a beginner cannot carry out, and the description also omits a second error arm the handler genuinely needs. So here is the whole thing.

// cmd/api/accounts.go — add this handler
// confirmEmailChangeHandler is activateUserHandler's shape with
// ScopeEmailChange, ConfirmPendingEmail, and one extra arm: a 23505 on the
// swap means the address was claimed SINCE it was parked — the unique index
// stays the referee, and whoever confirms first wins.
func (app *application) confirmEmailChangeHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Token string `json:"token"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()
    if data.ValidateTokenPlaintext(v, input.Token); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    hash := sha256.Sum256([]byte(input.Token))
    user, err := app.q.GetUserForToken(r.Context(), db.GetUserForTokenParams{
        Hash: hash[:], Scope: data.ScopeEmailChange,
    })
    if err != nil {
        v.AddError("token", "invalid or expired email change token")
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    updated, err := app.q.ConfirmPendingEmail(r.Context(), user.ID)
    if err != nil {
        var pgErr *pgconn.PgError
        switch {
        case errors.As(err, &pgErr) && pgErr.Code == "23505": // claimed since parking
            app.errorResponse(w, r, http.StatusConflict,
                "that email address is now in use; start the change again")
        case errors.Is(err, pgx.ErrNoRows): // nothing parked
            v.AddError("token", "no pending email change for this account")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    _ = app.q.DeleteTokensForUser(r.Context(), db.DeleteTokensForUserParams{
        UserID: user.ID, Scope: data.ScopeEmailChange,
    })

    app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil)
}

What this code says, line by line

  • The first two-thirds is the activation handler with one word changed: ScopeEmailChange instead of ScopeActivation. That similarity is a feature — one token mechanism, four uses.
  • if data.ValidateTokenPlaintext(v, input.Token); !v.Valid() is the if-with-initialiser again, this time with a statement that returns nothing: run the validation, then test the validator.
  • The switch { case ... } with no value after switch is Go’s way of writing a chain of if/else if that reads as a list. Three arms, three different answers:
    • 23505 — while this change was parked, somebody else registered that address, or confirmed their own change to it. pending_email has no UNIQUE constraint by design, so two users may covet one address; users.email still does, so only the first to confirm gets it. That is a 409 Conflict: the state you assumed when you started is no longer true.
    • pgx.ErrNoRows — reachable because ConfirmPendingEmail has that AND pending_email IS NOT NULL guard. It means the token was valid but nothing is parked (the change was already confirmed, or superseded). A validation error, not a 500.
    • default — anything else really is a server fault.
  • app.errorResponse(w, r, http.StatusConflict, "...") calls the generic error funnel directly rather than editConflictResponse, because that helper’s message is about optimistic-locking edit conflicts and would confuse the user here.
  • The final DeleteTokensForUser burns the change tokens: single use, enforced, exactly as activation does.
Important

This handler needs pgx in the imports (github.com/jackc/pgx/v5) for pgx.ErrNoRows, alongside pgconn for the *PgError. They are two different packages from the same driver: pgconn is the wire-protocol layer that knows Postgres error codes, pgx is the layer above it.

Step 9 — The email-change template

Also never printed in the original edition, also required for the flow to send anything. Create internal/mailer/templates/email-change.tmpl:

{{define "subject"}}Confirm your new taskd email address{{end}}

{{define "plainBody"}}
Hi,

Send the request below within 24 hours to confirm this address for your taskd account:

PUT /v1/users/email
{"token": "{{.changeToken}}"}

If you did not request an email change, ignore this email — your current address keeps working.
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html><body>
<p>Hi,</p>
<p>Confirm this address within 24 hours using this token:</p>
<pre>PUT /v1/users/email
{"token": "{{.changeToken}}"}</pre>
<p>If you did not request an email change, ignore this email — your current address keeps working.</p>
</body></html>
{{end}}

The key is {{.changeToken}}, matching payload := map[string]any{"changeToken": token.Plaintext} in Step 7, and the stated deadline is 24 hours, matching the TTL in the same handler. The last sentence matters more than it looks: this email arrives at an address that may have been typed by mistake, so it must tell an innocent stranger that ignoring it is safe.

Part E — deletion

Step 10 — DELETE /v1/me, in the only correct order

   WRONG                              RIGHT
   ─────                              ─────
   DELETE FROM users   ✗              1. check the password
        │                             2. cancel the Stripe subscription
        ▼                             3. delete the Stripe customer
   subscription still live               (best-effort — log and continue)
        │                             4. DELETE FROM users
        ▼                                   │
   card charged next month                  ▼
        │                             cascades take tasks, tokens,
        ▼                             subscription row
   found on a bank statement
// cmd/api/accounts.go — add this handler
// imports needed: "github.com/stripe/stripe-go/v78/subscription"
//                 "github.com/stripe/stripe-go/v78/customer"
func (app *application) deleteAccountHandler(w http.ResponseWriter, r *http.Request) {
    sessionUser := app.contextGetUser(r)

    var input struct {
        Password string `json:"password"`
    }
    if err := app.readJSON(w, r, &input); err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    user, err := app.q.GetUserByID(r.Context(), sessionUser.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    var pw data.Password
    pw.Hash = user.PasswordHash
    if match, err := pw.Matches(input.Password); err != nil || !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    // Money first: never orphan a live subscription.
    if sub, err := app.q.GetSubscription(r.Context(), user.ID); err == nil {
        _, err := subscription.Cancel(sub.StripeSubscriptionID, nil)
        if err != nil && !isStripeMissing(err) {
            app.logger.Error("stripe cancel during deletion", "error", err)
            app.serverErrorResponse(w, r, err) // fail CLOSED: retryable, and must be
            return
        }
    }
    if user.StripeCustomerID != nil {
        if _, err := customer.Del(*user.StripeCustomerID, nil); err != nil {
            app.logger.Warn("stripe customer delete", "error", err) // best-effort
        }
    }

    if err := app.q.DeleteUser(r.Context(), user.ID); err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

What this code says, line by line

  • The password is required even though the caller is already authenticated. Deletion is irreversible; a borrowed session should not be able to perform it.
  • if sub, err := app.q.GetSubscription(...); err == nil { — if-with-initialiser again, and note the condition is err == nil, the unusual direction. It reads: “if this user has a subscription row at all, do the cancel.” No row means nothing to cancel, which is not an error.
  • subscription.Cancel(sub.StripeSubscriptionID, nil) is an HTTP call to Stripe’s servers, made to look like a function call by their library. It returns the canceled object and an error; _ discards the object.
  • if err != nil && !isStripeMissing(err) — a real failure, other than “already gone”, aborts the whole deletion with a 500. That is failing closed on purpose: better to leave the account intact and let the user retry than to delete the row while the subscription lives on. The comment says so in capitals for a reason.
  • if user.StripeCustomerID != nil — the column is nullable, so the Go field is *string. A user who never started checkout has no Stripe customer. *user.StripeCustomerID dereferences the pointer to get the string out, which is safe only inside the nil check.
  • customer.Del(...) failing is logged at Warn and then ignored. This one is best-effort: an undeleted Stripe customer is untidy, not dangerous, and it must not block a person’s right to leave.
  • w.WriteHeader(http.StatusNoContent) sends 204 and no body. There is no JSON here because there is nothing left to describe.
Remember this

Two failures, two policies, chosen individually: the subscription cancel fails closed (stop everything), the customer delete fails open (log and continue). “What does this failure cost?” is the only question that decides which.

Step 11 — isStripeMissing (printed here for the first time)

The original edition calls this “a five-line check for stripe.Error code resource_missing” and never shows the five lines. Here they are.

// cmd/api/accounts.go — add this function
// isStripeMissing reports whether err is Stripe's resource_missing — an
// already-canceled subscription or an already-deleted customer counts as
// done, which is what makes account deletion safely retryable.
func isStripeMissing(err error) bool {
    var serr *stripe.Error
    if errors.As(err, &serr) {
        return serr.Code == stripe.ErrorCodeResourceMissing
    }
    return false
}

What this code says, line by line

  • It takes the general error interface and answers one specific question, so the call site reads as English: if err != nil && !isStripeMissing(err).
  • var serr *stripe.Error declares an empty pointer of Stripe’s own error type.
  • errors.As(err, &serr) walks the chain of wrapped errors looking for one of that type, and fills serr if it finds one. Same tool as the *pgconn.PgError check two steps ago — one pattern, two libraries.
  • stripe.ErrorCodeResourceMissing is the library’s constant for "resource_missing", which Stripe returns for “that object does not exist”. Using the constant instead of the string means a typo is a compile error.
  • Anything that is not a *stripe.Error returns false — a network timeout is not “already gone”, and must not be treated as success.
Why this exists

Why does “already gone” count as success? Because the caller may retry. A DELETE /v1/me that times out after cancelling at Stripe, but before deleting the row, must be safe to send again — and on the second attempt Stripe answers resource_missing. Treating that as fatal would leave the account permanently undeletable. Treating it as done makes deletion idempotent: running it twice has the same effect as running it once.

This function needs "github.com/stripe/stripe-go/v78" in the imports for the stripe.Error type, on top of the two sub-packages named in Step 10.

Note

The original edition’s listing shows only the subscription import. The handler also calls customer.Del, and this helper needs the root stripe package, so three import lines are required, not one. Missing any of them gives you an undefined: error naming the package.

Step 12 — Wire the routes

Six routes, in two groups that already exist. Placement is a security decision, not filing.

// cmd/api/routes.go — add these lines inside the groups routes() already has
r.Route("/v1", func(r chi.Router) {

    // ch. 14 — the brute-force and mail-cannon surface: everything
    // registered inside this group sits behind the per-IP token bucket.
    // (The healthcheck and the Stripe webhook stay outside it, on purpose.)
    r.Group(func(r chi.Router) {
        r.Use(app.rateLimitIP)

        // ... ch. 10/11/21 routes stay exactly as they are ...
        r.Post("/tokens/password-reset", app.createPasswordResetTokenHandler) // ch. 22
        r.Put("/users/password", app.resetPasswordHandler)                    // ch. 22
        r.Put("/users/email", app.confirmEmailChangeHandler)                  // ch. 22
    })

    // ch. 21's middle ring — authenticated, activation NOT required
    r.Group(func(r chi.Router) {
        r.Use(app.requireAuthenticatedUser)

        r.Put("/me/password", app.changePasswordHandler) // ch. 22
        r.Put("/me/email", app.changeEmailHandler)       // ch. 22
        r.Delete("/me", app.deleteAccountHandler)        // ch. 22

        r.Group(func(r chi.Router) {
            r.Use(app.requireActivatedUser)
            // ... all existing /tasks and /billing routes stay here ...
        })
    })
})

What this says

  • The three public routes go inside the rateLimitIP group. They are unauthenticated, they send mail, and they run bcrypt — see the first pitfall below.
  • The three /me routes go in the middle ring, above requireActivatedUser. An unactivated user must be able to reach PUT /me/email.
  • PUT, not POST, for the two token-redemption routes: the request describes the desired end state (“this account’s password is now X”), and sending it twice with the same token is not a second change — the token is gone.

Step 13 — Make the webhook tolerance explicit

The deletion flow depends on Chapter 16’s webhook handler shrugging when a subscription-deleted event arrives for a user who no longer exists. That behaviour is already there — the handler looks the user up and only acts if err == nil. What is missing is a note saying the tolerance is now deliberate, so a future “hardening” pass does not turn it into a 500.

// cmd/api/webhooks.go — add this comment above the deleted-subscription arm
    // ch. 22: this lookup is ALLOWED to fail. Account deletion cancels at
    // Stripe and then deletes our row, so this event routinely arrives for
    // a customer we no longer have. Do not "harden" this into a 500 —
    // Stripe treats 5xx as "try again" and would redeliver for days,
    // and the deletion flow would break.
    case "customer.subscription.deleted":
Note

The original edition asserts that “a comment in webhooks.go now says exactly that”. It never prints one, so it is printed here. Also worth knowing precisely: the tolerance exists in the customer.subscription.deleted arm only. The created and updated arms still answer 500 for an unknown customer — an upgrade for a user we cannot find really is a fault — so this is a per-event decision, not a general rule about Stripe events.


7. Checkpoint: prove it works

Build and run first:

make sqlc
go build ./...
make run/api

go build ./... compiles every package and prints nothing when it succeeds. Then, in a second terminal, rehearse the reset drama end to end.

1. Register and activate a user. If you already registered this address in the You need before starting drill, skip the curl below and go straight to the activation email that drill produced — registering the same address twice answers 422 with {"error":{"email":"a user with this email address already exists"}}.

curl -s -X POST localhost:4000/v1/users \
  -d '{"name":"Sain","email":"sain@example.com","password":"pa55word123"}'

Open http://localhost:8025, open the activation email, copy the 26-character token, then:

curl -s -X PUT localhost:4000/v1/users/activated -d '{"token":"PASTE_TOKEN_HERE"}'

You should get a JSON body containing "activated":true.

2. Log in and keep the token.

OLD=$(curl -s -d '{"email":"sain@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" \
  localhost:4000/v1/tasks

You should see: 200. (-o /dev/null -w '%{http_code}\n' throws the body away and prints only the status code.)

3. Request a reset.

curl -s -X POST localhost:4000/v1/tokens/password-reset -d '{"email":"sain@example.com"}'

You should see, exactly:

{"message":"if that address has an account, a reset email is on its way"}

Now try it with an address that does not exist. The response must be byte-for-byte identical. That is the enumeration defence, verified rather than assumed.

4. Take the reset token from Mailpit and use it. Refresh http://localhost:8025; a message titled Reset your taskd password should be there, containing a 26-character token.

curl -s -X PUT localhost:4000/v1/users/password \
  -d '{"password":"newpa55word456","token":"PASTE_RESET_TOKEN"}'

You should see:

{"message":"your password was successfully reset"}

5. The payoff — the old session is dead.

curl -s -H "Authorization: Bearer $OLD" localhost:4000/v1/tasks

You should see:

{"error":"invalid or missing authentication token"}

That 200 in step 2 turning into this 401 is the chapter working. The token was valid for 24 hours; the reset killed it after about one minute.

If you got something else

You got Cause Fix
Mailpit shows the activation mail but never the reset mail password-reset.tmpl missing or misnamed; ParseFS fails inside the background goroutine, so the error is in the server log, not the HTTP response Check internal/mailer/templates/password-reset.tmpl exists, then restart — go:embed reads the folder at build time
{"error":{"token":"invalid or expired password reset token"}} The token is 26 characters but no live password-reset row matches it: you pasted the activation token, waited more than 45 minutes, or already used this one Request a new reset and use the newest email
{"error":{"token":"must be 26 bytes long"}} A truncated paste. ValidateTokenPlaintext checks the length before any lookup happens, so this comes back before the database is touched Copy all 26 characters
The old bearer token still returns 200 in step 5 The ScopeAuthentication half of the revocation loop is missing Re-read Step 5: the loop must range over both scopes
A 500 on the email-change routes, and a server log line mentioning column "pending_email" does not exist with SQLSTATE 42703 The Go code was generated from the migration files, but the migration never ran against the running database make db/migrations/up, then retry

8. Common mistakes (and the quick fix)

Symptom / error What it means in English Fix
undefined: isStripeMissing You wrote the delete handler but not the helper Add Step 11’s function to accounts.go
app.confirmEmailChangeHandler undefined (type *application has no field or method confirmEmailChangeHandler) The route is wired but the handler was never written Add Step 8’s handler
undefined: pgx / undefined: stripe Missing imports pgx for ErrNoRows, stripe for stripe.Error, plus subscription and customer
cannot use input.NewEmail ... as *string value Nullable column, so the generated field is a pointer PendingEmail: &input.NewEmail
undefined: db.SetPendingEmailParams You edited Go before regenerating make sqlc, then rebuild
The email change “worked” but the address never changed You called SetPendingEmail and never confirmed. Parking is not changing Redeem the token at PUT /v1/users/email
Confirm returns {"error":{"token":"no pending email change for this account"}} The token is valid but nothing is parked — already confirmed, or superseded by a later request Start the change again
DELETE /v1/me returns 500 for a paying user Stripe rejected the cancel for a reason other than resource_missing Read the stripe cancel during deletion log line; the account is intact, retry after fixing
Reset succeeds but the user is confused that they were logged out on their phone too Working as designed Say so in the response message, as the change-password handler does

9. Pitfalls

Reset endpoints without rate limits. Both new public POSTs are mail cannons and bcrypt burners: one costs an outbound email, the other costs a deliberately slow hash. They mount inside Chapter 14’s IP-limited group alongside login. Every new unauthenticated endpoint should make you ask: what does 10,000 of these per minute cost me?

Revealing which field failed. Login, reset and change-password all answer invalid credentials — never “wrong password” versus “no such user”. That difference is the classic oracle, and it is closed by discipline in error text rather than by any clever mechanism. Read your own error strings as an attacker would.

Timing oracles. The uniform-202 endpoints still do different work per branch: an existing activated user causes token generation, a database insert and an SMTP dial; a non-existent one causes a failed lookup and nothing else. The response time differs. We accept that theoretical signal at our threat model and say so out loud. Closing it fully means making every branch take the same time — constant-time responses with all work moved to the background. Noted for the day you are somebody’s specific target.

New word

timing oracle — learning a secret by measuring how long the answer takes rather than by reading it. If “no such user” comes back in 3 ms and “user exists” in 300 ms, the uniform message is decoration.

Cascade blindness. ON DELETE CASCADE made deletion one statement — and makes every future foreign key to users a silent data-destruction decision. The alternative, a soft delete with a deleted_at column, trades that risk for filtering tombstones out of every query forever, and one forgotten filter shows deleted users’ data to the living. We chose hard delete consciously. Re-choose consciously the day audit or recovery requirements arrive.

New word

soft delete / tombstone — marking a row dead (deleted_at = now()) instead of removing it. The dead row left behind is a tombstone, and every future SELECT must remember to skip it.

The webhook race, verified not assumed. After a deletion, watch the logs: the customer.subscription.deleted event arrives, finds no user, and returns 200. Do not take that on trust — delete a paying test account and read the log lines yourself. If somebody later “hardens” that handler into a 500 on unknown customers, this flow breaks and Stripe redelivers the event with growing gaps for days (Chapter 16’s retry table). Step 13’s comment exists to stop that.


10. Check yourself — quiz

  1. Why is the password-reset TTL 45 minutes when the activation TTL is 72 hours?
  2. A reset succeeds. Which rows disappear from the tokens table, and why does it matter that it is more than one scope?
  3. Both the resend endpoint and the reset-request endpoint answer 202 no matter what. What attack does that prevent, and what does it cost a real user?
  4. Why does the email-change flow need a pending_email column at all? What exactly could an attacker do without it?
  5. pending_email has no UNIQUE constraint but email does. What happens when two users request a change to the same address, and which HTTP status does the loser get?
  6. In deleteAccountHandler, the Stripe subscription cancel aborts the request on failure while the Stripe customer delete only logs. Explain both choices in one sentence each.
  7. What does isStripeMissing returning true allow the caller to do that it otherwise could not?
  8. Why are PUT /v1/me/password, PUT /v1/me/email and DELETE /v1/me mounted in the authenticated ring rather than the activated ring?
Answers
  1. Because the two tokens are worth different amounts. An activation token opens an empty, brand-new account; a reset token grants full control of a live account with data and a card on file. The shorter window shrinks how long a stolen or leaked inbox is dangerous, and the user is at the login screen right now anyway, so 45 minutes costs them nothing.

  2. Every row for that user with scope password-reset and every row with scope authentication. The reset-scope deletion enforces single use. The authentication-scope deletion is the actual security payoff: the usual reason for a reset is that somebody else has the password, and that somebody may already be logged in. Deleting only the reset token would leave the thief’s session alive.

  3. It prevents enumeration — using different answers to discover which email addresses have accounts, which is how customer lists get scraped. The cost is real: a user who typos their own address gets a cheerful 202 and then waits for mail that will never arrive.

  4. Without it, one password check would repoint the account at an address nobody has proved they can read. An attacker with thirty seconds at an unlocked laptop changes the email, then requests a password reset to their own address, and owns the account permanently — while the real owner has lost their recovery route. The pending column means the live, verified address keeps working until a token sent to the new address is redeemed.

  5. Both changes get parked; pending_email allows duplicates on purpose. Whoever confirms first wins, because users.email is still UNIQUE. The second confirmation makes Postgres raise 23505, and confirmEmailChangeHandler turns that into 409 Conflict with “that email address is now in use; start the change again”.

  6. The cancel fails closed: if we cannot be sure billing has stopped, we must not delete the only record that the customer exists, so we return 500 and let the caller retry. The customer delete fails open: an undeleted Stripe customer object is untidy but harmless, and it must never block a person’s right to leave.

  7. It allows the whole deletion to be retried safely. If the request dies after the cancel but before the row delete, a second attempt gets resource_missing from Stripe; treating that as success rather than failure means the retry completes instead of failing forever. That is idempotency: doing it twice has the same effect as doing it once.

  8. Because the unactivated user with a typo’d signup email is precisely the person who needs PUT /v1/me/email, and they can never become activated without it. Authentication answers “who are you”; activation answers “is your email real”. Conflating them strands the users who most need help.


11. Practice

Exercise 1 — Rehearse the reset drama as a script (easy)

Turn §7’s checkpoint into a repeatable shell script, scripts/reset-drill.sh, that registers a fresh user, prints the two moments that matter, and leaves the manual token-copying steps clearly marked. Prove the old token is refused.

Solution
# scripts/reset-drill.sh
set -eu
API=localhost:4000
EMAIL="drill$(date +%s)@example.com"

echo "1. register $EMAIL"
curl -s -X POST $API/v1/users \
  -d "{\"name\":\"Drill\",\"email\":\"$EMAIL\",\"password\":\"pa55word123\"}" | head -c 200
echo

echo "2. open http://localhost:8025, copy the ACTIVATION token, then:"
read -r -p "   activation token: " ACT
curl -s -X PUT $API/v1/users/activated -d "{\"token\":\"$ACT\"}" | head -c 200
echo

echo "3. log in"
OLD=$(curl -s -d "{\"email\":\"$EMAIL\",\"password\":\"pa55word123\"}" \
  $API/v1/tokens/authentication | jq -r .authentication_token.token)
echo -n "   old token works? status="
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" $API/v1/tasks

echo "4. request the reset"
curl -s -X POST $API/v1/tokens/password-reset -d "{\"email\":\"$EMAIL\"}"
echo

echo "5. copy the RESET token from Mailpit, then:"
read -r -p "   reset token: " RST
curl -s -X PUT $API/v1/users/password \
  -d "{\"password\":\"newpa55word456\",\"token\":\"$RST\"}"
echo

echo -n "6. old token now? status="
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" $API/v1/tasks

Run it with bash scripts/reset-drill.sh. Step 3 must print status=200 and step 6 must print status=401. If step 6 prints 200, the revocation loop in resetPasswordHandler is wrong.

set -eu makes the script stop on the first failing command and on any unset variable, so a mistake surfaces where it happens rather than three lines later.

Exercise 2 — Prove the live address survives an email change (medium)

As an activated user, request a change to a new address, then — before redeeming the token — prove that the old address still works for login and that users.email is untouched.

Solution
TOKEN=$(curl -s -d '{"email":"sain@example.com","password":"newpa55word456"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

curl -s -X PUT localhost:4000/v1/me/email \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"new_email":"sain2@example.com","password":"newpa55word456"}'

That should answer {"message":"confirmation sent to the new address"}. Now check the database:

make db/psql
-- type this at the psql prompt
SELECT email, pending_email FROM users WHERE email = 'sain@example.com';

The email column still reads sain@example.com and pending_email reads sain2@example.com. Log in again with the old address — it still works, because nothing about the live account has changed. Mailpit shows the confirmation at sain2@example.com and not at the old address.

Finally, redeem it:

curl -s -X PUT localhost:4000/v1/users/email -d '{"token":"PASTE_CHANGE_TOKEN"}'

The response contains "email":"sain2@example.com". Now query again — and note that the old address no longer matches anything, which is itself the proof that the swap happened:

-- type this at the psql prompt
SELECT email, pending_email FROM users WHERE email = 'sain2@example.com';

One row comes back, with pending_email empty: psql prints an empty cell for NULL. Type \q to leave.

Exercise 3 — Delete a paying account and audit the wreckage (harder)

This is the rehearsal that matters, and the one that catches ordering bugs. It is also Chapter 27 (Going live)'s habit of running drills — rehearsing a dangerous operation before production makes you do it for real — arriving early. Subscribe a test user with Stripe’s 4242 4242 4242 4242 test card (Chapter 15’s checkout flow), then delete the account and verify both systems.

Solution

Before deleting, note two things: the user’s id in Postgres, and the subscription in the Stripe dashboard (Test mode → Subscriptions), which should read active.

TOKEN=$(curl -s -d '{"email":"payer@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

curl -s -i -X DELETE localhost:4000/v1/me \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"password":"pa55word123"}'

-i prints the response headers. The first line should be HTTP/1.1 204 No Content, and there should be no body — that is what 204 means.

Now audit both sides.

Stripe: reload the dashboard. The subscription’s status should now read canceled, and the customer should be gone from the Customers list (deleted customers disappear from the default view; their past invoices remain, which is the trade-off §4.4 named).

Postgres: make db/psql, then, using the id you noted:

-- type this at the psql prompt, with your own id in place of 42
SELECT count(*) FROM users         WHERE id      = 42;
SELECT count(*) FROM tasks         WHERE user_id = 42;
SELECT count(*) FROM tokens        WHERE user_id = 42;
SELECT count(*) FROM subscriptions WHERE user_id = 42;

All four must return 0. Only the first DELETE was issued by our code; the other three rows went with it via ON DELETE CASCADE.

The logs: watch the API’s output for a few seconds. Stripe delivers customer.subscription.deleted after the cancel; the handler looks the customer up, finds nobody, and answers 200. No error line appears, and that silence is the Chapter 16 tolerance doing its job.


12. FAQ

Why can’t I just email people their password? Because you do not have it. Chapter 10 stored a bcrypt hash, which is a one-way scramble — there is no way back to the original text, by design. Any service that can email you your old password is storing it in a recoverable form, which means one database leak exposes every customer’s password on every other site where they reused it. “We cannot tell you your password” is a feature.

Why do I get logged out everywhere after changing my password? Because the most common reason to change a password is that somebody else may have it. Revoking every session is the only version of that action that actually helps. Keeping the current session alive is possible — you would thread the requesting token’s hash through the context and exclude it from the delete — but it is extra machinery in exchange for saving one login, so it waits until a user asks.

What if someone requests password resets for my email over and over? They can annoy you, and that is roughly the ceiling. Each request sends one email containing a token that only you can read, and nothing about your account changes until a token is redeemed. The per-IP rate limiter from Chapter 14 caps the flood, and if it becomes a real problem the next step is a per-address cooldown: refuse to mint a second reset token within, say, five minutes.

Why is deleting an account so much code for one DELETE statement? Because only one of those lines is about our database. The rest is about the other company that is charging the customer’s card, and about which failures may safely be ignored. The code is a faithful map of the actual risk: password re-check (irreversible action), cancel first (money), tolerate already-gone (retries), best-effort customer delete (tidiness), then one statement whose cascades do the rest.

What if the user’s inbox is compromised? Then the attacker can take the account, and no amount of code in this chapter prevents it. Reset is possession-based authentication; possession of the inbox is the credential. This is the honest limit of the design, and the real mitigations live above it: a second factor, or a notification sent to the old address on every sensitive change. Both are natural next features, and both assume this chapter’s machinery already exists.

Should deletion be soft instead of hard? It depends on obligations you may not have yet. Hard delete is simpler, honest about GDPR erasure, and impossible to get subtly wrong. Soft delete gives you undo and an audit trail, at the cost of filtering tombstones out of every query you will ever write — and the first forgotten filter is a data leak. And one caveat regardless of which you pick: a hard-deleted user still exists in last night’s database backup, so “deleted” always means “deleted, and gone from backups within our stated retention window”.


13. Where we are

The account is now a complete object with a whole life: created, verified, recovered, changed, closed. Combined with Chapter 21, taskd has every flow a real support inbox asks for, and the deletion path settles up with Stripe before it destroys anything.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go   server.go   config.go   db.go   background.go
│   ├── routes.go          # UPDATED: 6 new routes in two rings
│   ├── middleware.go   helpers.go   errors.go   context.go
│   ├── healthcheck.go   metrics.go
│   ├── accounts.go        # UPDATED: 6 handlers + isStripeMissing
│   ├── webhooks.go        # UPDATED: the ch. 22 tolerance comment
│   ├── users.go   tokens.go   tasks.go   billing.go   entitlements.go
├── internal/
│   ├── data/              # tokens.go carries all four scopes
│   ├── db/                # sqlc output — users.sql.go REGENERATED
│   ├── mailer/
│   │   └── templates/
│   │       ├── activation.tmpl
│   │       ├── password-reset.tmpl   # NEW
│   │       └── email-change.tmpl     # NEW
│   ├── cache/   validator/
├── migrations/            # NEW: 000007_pending_email up + down
├── sql/queries/
│   ├── users.sql          # UPDATED: 6 new queries
│   ├── tasks.sql   tokens.sql   billing.sql
├── scripts/               # NEW, if you did Exercise 1: reset-drill.sh
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: register, activate, log in, use the product, pay for it, forget your password and recover it, change your password and be logged out everywhere, move your account to a new email address without ever exposing it to hijack, and close the account with the billing stopped first.

What is still fake or missing:

  • No GET /v1/me. Nothing returns the current user, so a client has to remember what it registered with. One handler; a good exercise.
  • No second factor, and no notification to the old address when something sensitive changes.
  • No admin surface. When a customer emails “I cannot get in”, there is no tool but psql.
  • The edge is still bare. Chapter 23 (Hardening the edge) adds security headers, CORS and idempotency keys.
  • Nothing here is covered by an automated test. Chapter 20’s suite does not know these flows exist.

For your notes

Copy these into learnings/ch22.md, in your own words:

  1. A reset token is the account; an activation token gates an empty one. That single sentence sets the TTL (45 minutes versus 72 hours) and explains why unactivated accounts are refused a reset at all.
  2. A password reset that does not revoke sessions is theatre. Changing the lock without collecting the outstanding keys leaves the thief exactly where they were.
  3. Never point an account at an unproven address. Park it in pending_email, send the confirmation to it, and keep the verified address working until the token comes back. The one exception is the account whose current address was never verified either.
  4. When two systems must change, order them by what the failure costs. Cancel at Stripe, then delete the row — because a subscription billing a deleted customer is discovered on a bank statement.
  5. Choose fail-closed or fail-open per failure, not per file. In one handler, the subscription cancel fails closed and the customer delete fails open, and both are right.

Chapter 23 — Hardening the edge: headers, CORS, idempotency keys

Every request that reaches a taskd handler has already passed through a stack of middleware that identifies the caller, counts them, times them and logs them. What that stack does not yet do is say anything to the outside world about how the response should be treated, decide which websites are allowed to call the API from a browser, or cope with the one situation every network client eventually hits: a request that was sent, may have worked, and never came back. This chapter adds three pieces of armour at the outermost edge. Two of them are five lines each. The third is the subtle one, and it is worth the chapter on its own.

What you’ll be able to do by the end

  • Send three security headers on every response, and say in one sentence what attack each one refuses — rather than copying them off a blog post.
  • Explain why CORS is a browser rule and not a security control, and why curl never trips over it.
  • Turn CORS on for a named list of origins loaded from config, and off entirely by leaving the list empty, which is the correct default for a server-to-server API.
  • Let a client retry a create safely: the same Idempotency-Key header returns the same response, with the same status and the same body, and creates exactly one row.
  • Prove all of that with curl, and read the stored record inside DragonflyDB with your own eyes.

Time: ~45 minutes reading, ~40 minutes typing.

You need before starting: a working Chapter 22 (Password reset and account lifecycle) — but in practice everything from Chapter 21 (Background work and email) onward is enough. Three commands prove the ground is solid. Start the containers and the server, then in another terminal:

docker compose ps
TOKEN=$(curl -s -d '{"email":"alice@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  localhost:4000/v1/tasks

docker compose ps should list db, cache and mailpit as running, and the last command should print 200. Keep that terminal: $TOKEN is used throughout this chapter.

If the last command prints 403, the account exists but is not activated — Chapter 21 (Background work and email) shows the activation flow, and the shortcut is make db/psql then UPDATE users SET activated = true WHERE email = 'alice@example.com';. If the login itself fails, re-register with the POST /v1/users command from Chapter 10 (Users and passwords).


1. The problem, in plain words

What “the edge” means

The edge is the boundary where the outside world meets your code: the last thing that happens before a request becomes your handler’s problem, and the last thing that happens to a response on its way out. Every previous chapter has been about the inside — queries, business rules, money. This one is about the boundary itself.

Three things live out there that have nothing to do with each other except location. They are best met one at a time.

Danger one: a browser looking at your JSON

taskd returns JSON. JSON is data; nobody runs it. That statement is true right up until a browser disagrees with you about what it has received.

Suppose a user creates a task whose title is a piece of HTML containing a <script> tag. Your API stores it, because a title is text and that is text. Later something fetches /v1/tasks/42 and the browser looks at the bytes coming back. Some browsers, historically, would notice the response looks like HTML, decide the server’s declared type was a mistake, and render it as a page — running the script inside it, on your domain, with your user’s session.

New word

MIME sniffing — a browser guessing a response’s type from its contents rather than from the Content-Type header the server declared. Helpful in 1998, when servers lied constantly. Dangerous now.

New word

XSS (Cross-Site Scripting) — getting a victim’s browser to run attacker-supplied code in the context of your site. Once it runs, it can do anything the logged-in user can do, because as far as the browser is concerned it is your site.

That specific route — attacker-supplied text stored by an API and then re-interpreted as HTML by a browser — is common enough to have its own name: stored XSS via the API. One response header closes it: X-Content-Type-Options: nosniff tells the browser to believe the declared type or refuse the content. Nothing about it is expensive. It is one line, on every response, forever.

Two more headers of the same shape are worth having while you are there, both harmless for a pure JSON API and protective the day somebody points an actual browser at an actual endpoint.

New word

clickjacking — invisibly framing your page inside a hostile one so a victim clicks things they cannot see: an invisible sheet laid over the “confirm” button. X-Frame-Options: DENY tells the browser never to display your responses inside another page’s frame.

New word

Referrer-Policy — controls how much of the current URL a browser reveals when the user follows a link away from your page. Without it, a URL containing a token or an ID gets handed to whatever site they clicked through to.

Danger two: other websites’ JavaScript

Browsers have a rule you have never had to think about, because it is invisible when it works. Code running on https://evil.example may send a request to https://api.taskd.example — but it may not read the answer. That rule is the same-origin policy, and it is the reason a hostile page you visit cannot quietly read your webmail in the background.

New word

origin — scheme plus host plus port, together: https://app.example.com is one origin, http://app.example.com (different scheme) is another, and https://app.example.com:8443 (different port) is a third. The origin is the unit browsers allow or deny.

The rule is a problem the moment you have a legitimate front end. A single-page app served from http://localhost:3000 calling an API on http://localhost:4000 is cross-origin — different port, different origin — and the browser will block it from reading the response unless the API says otherwise. The mechanism for saying otherwise is CORS.

New word

CORS (Cross-Origin Resource Sharing) — a set of response headers telling browsers which other sites’ JavaScript may read your responses. It is a guest list that the venue’s own staff enforce; it does nothing about people arriving by helicopter.

The helicopter matters. curl is not a browser, has no same-origin policy, and ignores CORS headers entirely. So does every server-side HTTP client, every mobile app, and every script an attacker writes. Which gives the single most important sentence in this chapter, and the one people get backwards every year:

Remember this

CORS is not access control. It restricts what other websites’ JavaScript may do inside a user’s browser. If a resource must not be readable by strangers, the answer is authentication, not CORS.

Danger three: the request you cannot answer

This one is not an attack. It is arithmetic, and it happens to well-behaved clients on good networks every day.

A client sends POST /v1/tasks. Fifteen seconds pass. Nothing comes back — the connection times out, or the phone changed from wifi to mobile data, or a load balancer in between gave up. The client now faces a question it has no way to answer:

Did the task get created, or not?

Both are possible. The request may never have arrived. It may have arrived, created the task, and had its response eaten on the way home. From the client’s side those two look identical: silence.

  CASE A — the request never landed        CASE B — the response was lost
  ─────────────────────────────────        ─────────────────────────────
  client ──X                               client ──────────────▶ server
         (nothing arrives)                                      creates task #91
                                           client ◀──X───────────
                                                  (200 never arrives)

  What the client sees:  silence           What the client sees:  silence
  Correct action:        retry             Correct action:        do NOT retry

The client must pick an action while unable to tell the cases apart. Retry and case B becomes two identical tasks — or, on POST /v1/billing/checkout, two checkout sessions. Do not retry and case A loses the user’s work with no error to explain it. Neither choice is right, because the information needed to choose is on the other side of a broken wire.

The fix is to stop asking the client to guess. The client labels the attempt with a value it chooses, and sends that label with the original and with every retry. The server remembers what it answered the first time and, for a repeat of the same label, replays the recorded answer instead of doing the work again.

New word

idempotency key — a client-chosen value sent in the Idempotency-Key header. The server remembers its response per (user, endpoint, key) and replays it on retries rather than acting twice. A cloakroom ticket: same ticket, same coat, never a second coat.

Note the word replays. Nothing runs twice. The client receives the same status code and the same bytes it would have received the first time, and cannot tell the difference — except for one honest header we add on purpose.

Note

Chapter 16 (Stripe II) used the word idempotent for a property: doing an operation twice has the same effect as doing it once, like pressing a lift button that is already lit. That is a property of an operation. An idempotency key is a mechanism for giving that property to an operation which does not naturally have it. POST /v1/tasks creates a new row every time it runs — that is the opposite of idempotent — so we bolt the property on from outside.


2. New words in this chapter

  • the edge — the boundary between the internet and your handlers; the first and last code a request touches.
  • security header — a response header that instructs the browser to refuse a specific class of attack.
  • MIME sniffing — a browser guessing a response’s type from its bytes instead of its declared Content-Type.
  • XSS — Cross-Site Scripting: getting a victim’s browser to run attacker-supplied code in the context of your site.
  • clickjacking — invisibly framing your page inside a hostile one so victims click things they cannot see.
  • origin — scheme + host + port together, e.g. https://app.example.com. The unit CORS allows or denies.
  • same-origin policy — the browser rule that code from one origin may not read responses from another.
  • CORS — response headers telling browsers which other origins’ JavaScript may read your responses. Not access control: curl ignores it.
  • preflight — the OPTIONS request a browser sends before certain cross-origin calls, to ask permission. It carries no Authorization header, by specification.
  • credentialed request — in CORS terms, a request the browser attaches cookies or HTTP authentication to automatically. A fetch that sets Authorization itself is not one.
  • allow-list — an explicit list of permitted values; the opposite of a wildcard *.
  • idempotency key — a client-chosen header value; the server remembers the response per (user, endpoint, key) and replays it instead of acting twice.
  • replay — returning a previously recorded response verbatim, without running the handler.
  • principal — whoever a request acts as; here, the authenticated user. Idempotency stores must be per-principal or they leak.
  • SetNX — “set this key only if it does not exist”: an atomic way to claim a short-lived lock. Being first to sign the sheet.
  • lock — a claim on a name that only one holder can have at a time.
  • lock TTL — the expiry on a lock, so a holder that dies mid-flight does not block the key forever.
  • opt-in middleware — middleware that does nothing at all unless the client asks for it, here by sending a header.
  • per-route middleware — middleware attached to one route (r.With(...)) rather than to a whole group (r.Use(...)).
  • tee — to duplicate a stream of bytes as it flows past, so it reaches its destination and a copy you keep. Named after the plumbing fitting.
  • atomic (reminder, Chapter 13, Caching with DragonflyDB) — an operation that happens completely or not at all, with no observable half-done state, even when several clients act at once.
  • fail open (reminder, Chapter 14, Rate limiting) — when a dependency is unavailable, allow the request through rather than deny it.

3. The goal

Three pieces of armour between the internet and the handlers:

  1. a security-headers middleware;
  2. CORS with an explicit origin allow-list — config-driven, off by default;
  3. opt-in idempotency keys on the POSTs that create things, so a client retrying a timed-out create cannot double-create.

All implemented, not merely described.


4. The thinking

4.1 Headers: five lines, global, done

For a JSON API only a few headers matter, but they matter.

Header What it refuses
X-Content-Type-Options: nosniff The browser reinterpreting a JSON response as HTML and running the script inside it — the classic stored-XSS-via-API vector.
X-Frame-Options: DENY Your responses being displayed inside another site’s frame (clickjacking).
Referrer-Policy: strict-origin-when-cross-origin Leaking full URLs — with their IDs and query strings — to third-party sites the user navigates to.

The second and third are close to irrelevant for pure API use, and that is exactly why they go in now: they cost nothing, and the day someone points a browser at an endpoint — a documentation page, a redirect from Stripe, an error rendered in a tab — they are already there.

The HTML-era headers (Content-Security-Policy and friends) join when HTML does. A CSP for a service that never returns HTML is a string nobody will ever maintain correctly.

4.2 CORS: browser policy, made explicit

Two rules keep CORS both safe and sane.

Explicit origins from config, never *. The wildcard says “any website’s JavaScript may read this”. Combine that with an API whose responses are private and whose clients send Authorization headers, and you have built a way for a hostile page to read your customers’ data through their own browsers.

Default off. taskd is a server-to-server API until someone builds a front end for it. With zero configured origins, it should send no CORS headers at all — not permissive ones, not restrictive ones, none. That is what the len(...) > 0 check in the code buys: a deployment that never configured CORS behaves as if the feature does not exist.

Mechanically, one detail decides whether your first CORS setup works or produces a day of confusion: the CORS middleware mounts before authentication.

New word

preflight — before a cross-origin request that is not a plain form-style GET or POST, the browser sends an OPTIONS request to the same URL asking “may I send this method, with these headers, from this origin?”. The browser sends the preflight itself, and it does not attach the Authorization header — that is in the specification, not an accident.

So a preflight arrives with no token. Mount CORS after your authentication middleware and the preflight gets a 401 before CORS ever sees it, the browser reports a maddening error about a missing Access-Control-Allow-Origin, and the real request is never sent. Every first CORS setup has this bug once.

4.3 Idempotency keys: the design decisions, made out loud

The pattern was standardised by Stripe’s API, which is where most developers meet it: send an Idempotency-Key header, get the same answer for the same key. Building it means making four decisions, and this book makes them in the open.

Decision Choice Why
Who opts in? The client, by sending a header. No header, no machinery. GETs and PUTs are naturally idempotent; PATCH already has optimistic locking from Chapter 8 (CRUD done properly). Only creates need this, and only clients that retry care.
Where is the record kept? DragonflyDB, 24-hour TTL, failing open. This protects against duplicate tasks — an annoyance. It inherits the cache’s availability policy rather than inventing a stricter one.
What about real money? Nothing extra here. taskd’s payments already have Stripe’s own idempotency on the far side, which is why we get to be relaxed. A payment-grade store would live in Postgres, inside the same transaction as the work.
What if two arrive at once? The second gets 409 Conflict and a Retry-After. Told to wait, not raced. The first attempt is still in flight; there is no recorded answer to replay yet.
Why this exists

Read the second row again, because it is the reasoning pattern rather than the answer. “What does this protect, and what does it cost when the protection is unavailable?” A duplicate task is a user deleting a row. A duplicate charge is a chargeback, an email, and a refund. The same feature deserves a different storage engine depending on which of those it is standing in front of, and saying so out loud is how you avoid both over-engineering and negligence.

New word

fail open — when a dependency is unavailable, allow the request through. The opposite is fail closed: deny. Chapter 14 (Rate limiting) set the rule this book follows — the choice is made per component, on purpose, and written into the code. Here, if the cache is down, idempotency stops working and creates go through unprotected.


5. A picture of it

Where the armour sits

The middleware stack, outermost first. The two new global layers go on top; the idempotency layer is not global at all and attaches to two routes.

   incoming request
          │
   ┌──────▼───────────────────────────────────────────┐
   │ secureHeaders          (NEW — 3 response headers)│
   │ ┌────────────────────────────────────────────────┴─┐
   │ │ CORS                 (NEW — only if origins set) │
   │ │  └─ answers OPTIONS preflight and STOPS          │
   │ │ ┌────────────────────────────────────────────────┴─┐
   │ │ │ recoverPanic        (ch. 4)                      │
   │ │ │ metricsMiddleware   (ch. 18)                     │
   │ │ │ logRequest          (ch. 19)                     │
   │ │ │ authenticate        (ch. 11 — identifies)        │
   │ │ │ requireAuthenticatedUser / requireActivatedUser  │
   │ │ │ ┌────────────────────────────────────────────────┴─┐
   │ │ │ │ idempotent   (NEW — POST /tasks, POST /checkout) │
   │ │ │ │            handler                               │
   └─┴─┴─┴──────────────────────────────────────────────────┘
  1. secureHeaders sets three headers and calls the next layer. It cannot fail and cannot refuse.
  2. CORS looks at the Origin header. On a preflight OPTIONS it answers immediately and never calls the next layer — which is precisely why it must sit above authenticate.
  3. Everything from recoverPanic down is the stack you already had.
  4. idempotent is not in the global chain. It wraps two specific routes, inside the rings that have already established who the user is.
Note

Chapter 4 (A server that dies well) said, in bold, that recoverPanic must be outermost so it catches panics thrown by other middleware. Two layers now sit above it, so that rule needs an honest footnote. secureHeaders is four lines that set headers; the CORS handler is a well-used library that runs before any of our code. Neither can plausibly panic, and both must run before authentication to do their jobs at all. recoverPanic remains outermost of taskd’s own middleware, and the comment in routes.go says so. If you ever add a third global layer above it, make yourself justify it the same way.

The timed-out POST, solved

   WITHOUT A KEY                        WITH AN IDEMPOTENCY KEY
   ─────────────                        ───────────────────────
   POST /v1/tasks ─────▶ creates #91    POST + Key: k1 ────▶ creates #91
                                                             record: "201 {...}"
   (response lost)                      (response lost)

   client retries ─────▶ creates #92    client retries POST + Key: k1
                                                       ────▶ record found
   result: TWO tasks, one intended            replays "201 {...}"

                                        result: ONE task, and the client
                                                got its 201 after all

The middleware, as a state machine

Five outcomes. Follow one line at a time.

   request arrives at `idempotent`
             │
      no key, key > 200 chars, or cache is nil ──▶ pass through, do nothing
             │
      key present
             │
      ┌──────▼────────────────┐
      │ cache.Get(storeKey)   │
      └──────┬────────────────┘
             │ found ──────────────────▶ REPLAY: same status, same body,
             │                           + Idempotency-Replayed: true
             │ not found
      ┌──────▼───────────────────────┐
      │ cache.SetNX(storeKey+":lock")│  claim the lock, 30-second TTL
      └──────┬───────────────────────┘
             │ lock refused ──────────▶ 409 + Retry-After: 2
             │ lock taken
      ┌──────▼──────────────────────────────┐
      │ run the handler, tee the body       │
      └──────┬──────────────────────────────┘
             │ status < 500 ──▶ store "STATUS body" for 24 h
             │ status ≥ 500 ──▶ store nothing
             ▼
        release the lock

The CORS preflight, and the bug

  CORRECT — CORS above auth            WRONG — CORS below auth
  ─────────────────────────            ───────────────────────
  browser: OPTIONS /v1/tasks           browser: OPTIONS /v1/tasks
    Origin: http://localhost:3000        (still no Authorization header)
    Access-Control-Request-Method: POST         │
    (no Authorization — by spec)                ▼
            │                            authenticate → no token
            ▼                            requireAuthenticatedUser → 401
  CORS: origin allowed → 200 + headers          │
            │                                   ▼
            ▼                            browser console:
  browser: POST /v1/tasks                "Response to preflight request
    Authorization: Bearer ...             doesn't pass access control check:
    Idempotency-Key: k1                   No 'Access-Control-Allow-Origin'
            │                             header is present..."
            ▼                                   │
        201 Created                             ▼
                                         the real POST is never sent

Which requests need which armour

Method Naturally safe to repeat? What protects it
GET /v1/tasks Yes — reading changes nothing Nothing needed
PUT /v1/users/activated Yes — sets a flag to a known value Nothing needed
PATCH /v1/tasks/{id} Yes, and a stale repeat is caught Optimistic locking, Chapter 8 (CRUD done properly)
DELETE /v1/tasks/{id} Yes — second delete finds nothing 404 on the repeat, which is honest
POST /v1/tasks No — every call creates a row An idempotency key
POST /v1/billing/checkout No — every call creates a session An idempotency key

6. The steps

Three parts, in dependency order. Part A is ten minutes, Part B is fifteen, Part C is the rest.

Part A — Security headers

Step 1 — Write the middleware

Chapter 4 (A server that dies well) established the shape of every middleware in this codebase: a function that takes the next handler and returns a new handler that does something, then calls the next one. This is the smallest possible example of that shape.

// cmd/api/middleware.go — add this function
// secureHeaders is the ch. 23 armor: five lines, global, unconditional.
// The HTML-era headers (CSP and friends) join when HTML does.
func secureHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
        next.ServeHTTP(w, r)
    })
}

What this code says, line by line

  1. func secureHeaders(next http.Handler) http.Handler — no (app *application) receiver. Every other middleware in this file hangs off app because it needs the logger, the cache or the database. This one needs nothing, so it is a plain function. Go lets you notice that.
  2. http.HandlerFunc(func(w, r) {...}) — an ordinary function converted into something that satisfies the http.Handler interface. The conversion is what makes a function usable as a handler.
  3. w.Header().Set(...)w.Header() is the map of response headers, and Set writes one. These must happen before anything writes a body or a status code, because headers are sent first on the wire. Doing it here, before next.ServeHTTP, guarantees that.
  4. next.ServeHTTP(w, r) — hand the request onward. A middleware that omits this line stops the request dead, which is exactly what an authentication gate wants and exactly what this one does not.
New word

security header — a response header whose only job is to instruct the browser to refuse a class of attack. The server is not defending itself with these; it is telling the browser how to defend the user.

Step 2 — Mount it first

// cmd/api/routes.go — add at the top of routes(), above r.Use(app.recoverPanic)
    // Registration order = wrapping order, outermost first.
    //
    // secureHeaders and CORS come first (ch. 23): the headers are
    // unconditional, and CORS must answer preflight OPTIONS — which
    // carry no bearer token, by spec — before anything tries to
    // authenticate them.
    r.Use(secureHeaders)

r.Use registers middleware for everything the router serves, and chi wraps in registration order, so the first registered is the outermost. Putting secureHeaders first means the headers are set on every response taskd can produce — including 404s, 500s, and rate-limit refusals, which are exactly the responses a hand-rolled solution forgets.

What you should see. Restart with make run/api and ask for anything:

curl -D- -s -o /dev/null localhost:4000/v1/healthcheck

-D- dumps the response headers to standard output; -o /dev/null throws the body away. Among the lines you should see:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin

Header order is not guaranteed and there will be others (Content-Type, X-Request-ID, Vary), so read for those three lines rather than expecting an exact block.


Part B — CORS

Step 3 — Teach the config about trusted origins

The original book mentions the [cors] block in half a sentence and then reads app.config.cors.trustedOrigins from Go, which leaves a beginner with a field that does not exist. Here are all three pieces — the same TOML-block / struct-field / koanf-line trio that Chapter 3 (Configuration and logging) promised every later chapter would show in full, and that Chapter 21 (Background work and email) did for [smtp].

First the file:

# config.toml — add this block at the end
[cors]
trusted_origins = []   # e.g. ["http://localhost:3000"]; empty → no CORS headers

Then the struct field, next to the smtp block Chapter 21 added:

// cmd/api/config.go — add this field inside the config struct
    // ch. 23 — the [cors] block: explicit origin allow-list, empty = CORS off.
    cors struct{ trustedOrigins []string }

Then the loader line, at the end of loadConfig:

// cmd/api/config.go — add this line at the end of loadConfig, before `return cfg, nil`
    cfg.cors.trustedOrigins = k.Strings("cors.trusted_origins")

What this code says, line by line

  1. trusted_origins = [] — a TOML array, written empty. In TOML, square brackets hold a list; an empty pair is a list with nothing in it, which is different from the key being absent.
  2. cors struct{ trustedOrigins []string } — a nested anonymous struct written on one line because it has one field. []string is a slice of strings: a growable list. The name trustedOrigins is Go-style camel case while the TOML key is trusted_origins; nothing automatic connects them, which is what the next line is for.
  3. k.Strings("cors.trusted_origins") — the first time this book uses k.Strings. Chapter 3’s loader gave you k.String, k.Int, k.Bool and k.Duration, each fetching one key and converting it. k.Strings (plural) fetches a key whose value is a list and returns []string. A key that is missing or empty gives an empty slice, never nil trouble.
Warning

This is the one config key in taskd whose TASKD_* environment override does not work. Chapter 3’s loader turns TASKD_CORS__TRUSTED_ORIGINS into the key cors.trusted_origins with a plain string value, and k.Strings returns an empty slice for anything that is not already a list — so setting that variable silently turns CORS off rather than on. Configure origins in the TOML file (mounted into the container in Chapter 25, Docker), and if you ever need the env route, it needs a value transform that splits on commas. Chapter 3’s promise that “every key can be overridden by an environment variable” has exactly this one exception, and finding it in production would cost you an afternoon.

Step 4 — Install the CORS library

go get github.com/go-chi/cors

go get downloads a package and records it in go.mod, the file listing every dependency and its version. This is the eleventh and last runtime dependency taskd acquires; Appendix D (dependency ledger) bills them all. The library is by the authors of chi and is roughly 300 lines: it reads the request’s Origin, compares it with a list, and writes the correct response headers. Nothing about CORS requires a library — it is header arithmetic — but the specification has enough corners (preflight caching, Vary, header echoing) that using the well-worn one is the boring choice.

Step 5 — Mount it, conditionally

// cmd/api/routes.go — add immediately after r.Use(secureHeaders)
    // CORS is off by default: a server-to-server API with zero
    // configured origins sends no CORS headers at all.
    if len(app.config.cors.trustedOrigins) > 0 {
        r.Use(cors.Handler(cors.Options{
            AllowedOrigins: app.config.cors.trustedOrigins,
            AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
            AllowedHeaders: []string{"Authorization", "Content-Type", "Idempotency-Key"},
            ExposedHeaders: []string{"X-Request-ID", "X-Cache"},
            MaxAge:         300,
        }))
    }

Add the import while you are there: "github.com/go-chi/cors".

What this code says, line by line

  1. if len(...) > 0len on a slice gives its number of elements. An empty allow-list means the middleware is never registered, so not one CORS header is emitted. That is a stronger statement than “configured to allow nothing”, and it is deliberate.
  2. cors.Handler(cors.Options{...}) — builds the middleware from a struct of options. The struct-literal-as-arguments style is common in Go libraries: every field is named at the call site, so you can read the configuration without checking the documentation for argument order.
  3. AllowedOrigins — the allow-list, straight from config. Never * (see Pitfalls).
  4. AllowedMethods — the HTTP verbs a browser may use cross-origin. OPTIONS is in the list because the preflight is itself an OPTIONS request.
  5. AllowedHeaders — the headers a browser may send. Authorization for the bearer token, Content-Type because sending JSON requires declaring it, and Idempotency-Key for Part C. A header missing from this list is refused at the preflight and the real request never happens — which is how forgetting to add Idempotency-Key here becomes a mystery in Part C.
  6. ExposedHeaders — the forgettable one. By default browser JavaScript may read only a short safelist of response headers. Everything else is received by the browser and then hidden from the page’s code. Without this line, a front end can be handed X-Request-ID and be unable to read it — so your support tooling, whose whole job is to quote that ID back to you, goes mysteriously blind. X-Cache is Chapter 13’s cache hit/miss marker, exposed for the same reason.
  7. MaxAge: 300 — how many seconds a browser may remember a preflight answer. Five minutes means one OPTIONS instead of one per request, and a config change takes at most five minutes to be noticed.
Note

AllowCredentials is deliberately absent, which means false. In CORS vocabulary a credentialed request is one where the browser attaches cookies or HTTP authentication automatically. taskd’s clients set the Authorization header themselves in the fetch call, so they are not credentialed requests in that sense and do not need the flag. If taskd ever authenticated with cookies, that flag would have to be turned on — and turning it on with a wildcard origin is a combination browsers refuse outright.

Step 6 — Prove it, without a browser

Set an origin in config.toml:

# config.toml — for this experiment only
[cors]
trusted_origins = ["http://localhost:3000"]

Restart the server. Now imitate the browser’s preflight by hand — this is the request you would otherwise never see:

curl -s -D- -o /dev/null -X OPTIONS localhost:4000/v1/tasks \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization,content-type,idempotency-key"

What this command does. -X OPTIONS sets the method. The three headers are exactly what a browser sends when it asks permission: who is asking, what method it wants to use, and which headers it wants to send. Note the absence of any Authorization header — that absence is the whole point.

What you should see — a 200, and among the headers:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type, Idempotency-Key
Access-Control-Max-Age: 300
Vary: Origin

Access-Control-Allow-Methods names only the method you asked about rather than the whole configured list; the specification permits that, and the library takes it.

Now the same preflight from an origin that is not on the list:

curl -s -D- -o /dev/null -X OPTIONS localhost:4000/v1/tasks \
  -H "Origin: http://evil.example" \
  -H "Access-Control-Request-Method: POST"

What you should see: still 200, and no Access-Control-* headers at all.

That surprises people, so sit with it. The server does not refuse. It stays silent, and the browser — seeing no permission — refuses on its behalf. If the caller is curl, which enforces nothing, the request would go through and be handled normally. That is the same-origin policy’s whole architecture in one experiment: the enforcement lives in the browser, and CORS is the server politely declining to relax it.

Finally, a real request with an Origin:

curl -s -D- -o /dev/null -H "Origin: http://localhost:3000" localhost:4000/v1/healthcheck

You should see Access-Control-Allow-Origin: http://localhost:3000 and Access-Control-Expose-Headers naming X-Request-Id and X-Cache. Drop the -H "Origin: ..." and those two headers vanish — because with no Origin there is no cross-origin question to answer.

Step 6b — The same proof, in a real browser

curl shows you the headers; only a browser shows you the enforcement. This takes five minutes and is worth doing once, because the error message you are about to produce is the one you will meet again for real.

You need a page served from http://localhost:3000. Any empty directory will do:

mkdir -p /tmp/origin-demo && cd /tmp/origin-demo && python3 -m http.server 3000

That serves an empty directory listing on port 3000 — enough to give the browser an origin. Open http://localhost:3000 in Chrome or Firefox, open the developer tools (F12), go to the Console tab, and paste this, substituting your token:

fetch("http://localhost:4000/v1/tasks", {
  headers: { "Authorization": "Bearer PASTE_YOUR_TOKEN_HERE" }
}).then(r => r.json()).then(console.log)

What you should see: your task list printed in the console. The Authorization header made this a request the browser preflights, the preflight was answered because http://localhost:3000 is on the allow-list, and the response was allowed through to the page’s JavaScript.

Now do it from an origin that is not on the list. Mailpit’s web interface is already running on a different port, which makes it a different origin: open http://localhost:8025, open its console, and paste the identical snippet.

What you should see: no task list. Instead, a red console message. Chrome words it like this:

Access to fetch at 'http://localhost:4000/v1/tasks' from origin
'http://localhost:8025' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

Firefox words it differently, and the exact text varies by browser version, but the phrase Access-Control-Allow-Origin is always in it. Note who is speaking: the browser is telling the page it will not hand over the response. taskd answered both preflights with a 200. Stop the python3 -m http.server with Ctrl-C when you are done.

Checkpoint

Set trusted_origins = [] again and restart. The preflight now returns no CORS headers from any origin, because the middleware is not mounted at all. That is the shipping default, and you should leave it that way unless you are serving a browser front end.


Part C — Idempotency keys

Step 7 — Give the cache a lock

The middleware needs one capability the cache package does not have: claiming a name, atomically, so that exactly one of two simultaneous requests gets it.

New word

atomic — an operation that either happens completely or not at all, with no observable half-done state, even when several clients act at the same instant. Chapter 13 (Caching with DragonflyDB) introduced the word for INCR; it is the same guarantee here.

Redis and Dragonfly have a command for exactly this: SETNX, “set if not exists”. It stores the value only when the key is absent, and reports whether it did. Two clients racing for the same key, and precisely one gets true.

// internal/cache/cache.go — add this method, after Delete
// SetNX takes a short lock: true means we claimed the key, false means
// somebody else already holds it. Used by the idempotency middleware. (ch. 23)
func (c *Cache) SetNX(ctx context.Context, key string, ttl time.Duration) (bool, error) {
    return c.rdb.SetNX(ctx, key, 1, ttl).Result()
}
Note

This method is printed here for the first time. The original chapter described it in prose as SetNX(key, ttl) (bool, error) and then called it with three arguments — the ctx was left out of the description, not out of the code. Every method in internal/cache takes a context.Context first, for the reason Chapter 6 (pgx) gave: the caller decides how long it is willing to wait, and a request that has been abandoned should not keep the cache busy. If you did Exercise 2 of Chapter 13 (Caching with DragonflyDB), this method is already in your file and you can move on. Delete — used here to release the lock — came from Chapter 17 (Entitlements and quotas).

What this code says, line by line

  1. c.rdb.SetNX(ctx, key, 1, ttl) — go-redis’s wrapper for the SET key value NX PX ttl command. The value stored is the number 1: nothing ever reads it, so any small value would do. The ttl becomes the key’s expiry.
  2. .Result() returns (bool, error) — the boolean is “did I create it?”, the error is “did the conversation with Dragonfly work?”. Note carefully that these are different questions: false, nil means the server answered and said no.
  3. This method returns its error, unlike Set and Delete which swallow theirs. Here the caller genuinely needs to distinguish “Dragonfly said somebody else holds the lock” from “Dragonfly did not answer”, because those deserve opposite behaviour — refuse in the first case, carry on in the second.
New word

lock TTL — the expiry on a lock. Without one, a server that takes a lock and then crashes leaves the key claimed forever, and that Idempotency-Key is permanently unusable. Thirty seconds is longer than any handler this API has and short enough that a crash is forgiven quickly. A lock with no expiry is a bug waiting for an outage.

Step 8 — The middleware

This is the long one. Read it once for shape — the state machine diagram in section 5 is its map — then read the decode below.

// cmd/api/middleware.go (addition)
// idempotent replays the recorded response for a repeated
// (user, endpoint, Idempotency-Key) triple, so a client retrying a
// timed-out create can't double-create (ch. 23).
func (app *application) idempotent(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Header.Get("Idempotency-Key")
        if key == "" || len(key) > 200 || app.cache == nil {
            next.ServeHTTP(w, r) // opt-in, and fail-open by policy
            return
        }
        user := app.contextGetUser(r)
        storeKey := fmt.Sprintf("idem:%d:%s:%s", user.ID, r.URL.Path, key)

        // Seen before? Replay the recorded response verbatim — the
        // client can't tell a replay from the original (except for the
        // honest Idempotency-Replayed header), and nothing runs twice.
        // Storage format is dead simple: "201 {json...}"; Cut splits
        // on the first space.
        if b, ok := app.cache.Get(r.Context(), storeKey); ok {
            status, body, _ := bytes.Cut(b, []byte(" "))
            code, _ := strconv.Atoi(string(status))
            w.Header().Set("Content-Type", "application/json")
            w.Header().Set("Idempotency-Replayed", "true")
            w.WriteHeader(code)
            w.Write(body)
            return
        }

        // First sight: take a short lock so a concurrent twin can't race us.
        locked, err := app.cache.SetNX(r.Context(), storeKey+":lock", 30*time.Second)
        if err == nil && !locked {
            w.Header().Set("Retry-After", "2")
            app.errorResponse(w, r, http.StatusConflict,
                "a request with this Idempotency-Key is already in flight")
            return
        }

        ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
        var buf bytes.Buffer
        ww.Tee(&buf)

        next.ServeHTTP(ww, r)

        if ww.Status() < 500 { // don't memorize our own outages
            rec := append([]byte(strconv.Itoa(ww.Status())+" "), buf.Bytes()...)
            app.cache.Set(r.Context(), storeKey, rec, 24*time.Hour)
        }
        app.cache.Delete(r.Context(), storeKey+":lock")
    })
}

The imports this needs, some of which are already there from earlier chapters:

// cmd/api/middleware.go — the import block must contain these
    "bytes"
    "fmt"
    "net/http"
    "strconv"
    "time"

    chimw "github.com/go-chi/chi/v5/middleware"

What this code says, block by block

The opt-in gate.

key := r.Header.Get("Idempotency-Key")
if key == "" || len(key) > 200 || app.cache == nil {
    next.ServeHTTP(w, r)
    return
}

Three ways to do nothing at all. No header means the client did not ask for this, so the request proceeds exactly as it did before this chapter existed — that is what opt-in means. A key longer than 200 characters is refused politely by ignoring it rather than by erroring, since the header is attacker-controlled text and an unbounded one would let anybody write arbitrarily large keys into your cache. A nil cache means Dragonfly was unreachable at start-up; the policy is fail open, so the create still happens, unprotected.

New word

opt-in middleware — middleware that returns immediately unless the client asks for its service. The cost to clients that do not use it is one map lookup per request.

The store key.

user := app.contextGetUser(r)
storeKey := fmt.Sprintf("idem:%d:%s:%s", user.ID, r.URL.Path, key)

app.contextGetUser retrieves the user that Chapter 11 (Tokens) attached to the request with its authenticate middleware. fmt.Sprintf builds a string from a template: %d takes a number and %s takes a string. For user 7 posting to /v1/tasks with key k1, the result is idem:7:/v1/tasks:k1.

Every part of that key is load-bearing:

  • user.ID — without it, a client that guessed another client’s key would be handed their response. That is not a bug, it is an information leak. This is the principal in “idempotency stores are per-principal”.
  • r.URL.Path — the same key on POST /v1/tasks and POST /v1/billing/checkout must be two separate records. A client reusing a key across endpoints is being sloppy, not malicious, and the path in the key makes sloppiness harmless.
  • the key itself — attacker-supplied text, used only as a piece of a composed cache key and never interpolated into SQL or logged raw. Chapter 7 (sqlc) established the SQL rule (values travel as parameters, never as string concatenation); the rule does not pause for headers.

The replay branch.

if b, ok := app.cache.Get(r.Context(), storeKey); ok {
    status, body, _ := bytes.Cut(b, []byte(" "))
    code, _ := strconv.Atoi(string(status))
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Idempotency-Replayed", "true")
    w.WriteHeader(code)
    w.Write(body)
    return
}
  1. if b, ok := ...; ok — the if-with-initialiser form from Chapter 8: run the call, keep both results, then test one of them. Get returns bytes and a found/not-found boolean.
  2. The storage format is a status code, one space, then the body: 201 {"task":{...}}. Human readable, no encoding library, and inspectable from redis-cli.
  3. bytes.Cut(b, []byte(" ")) splits at the first space and returns what was before it, what was after it, and whether a separator was found. The body may contain any number of further spaces; only the first one is the delimiter. The third result is discarded with _ because a record we wrote always contains a space.
  4. strconv.Atoi converts a string to an integer (“ASCII to integer”). Its error is ignored with _, which is defensible in this exact spot: the string was produced by our own strconv.Itoa three dozen lines below, so the only way it is not a number is if someone edited the cache by hand. If it somehow were not, code would be 0 and w.WriteHeader(0) would panic — which recoverPanic turns into a logged 500. Ignoring an error is a decision, and the test is always “can I state why this cannot happen, or what happens if I am wrong?”
  5. w.WriteHeader(code) then w.Write(body) — status first, then bytes. The handler is never called, so no row is created, no Stripe session is opened, no email is sent.
  6. Idempotency-Replayed: true — the one honest difference between a replay and the original. A client cannot detect a replay from the status or the body, and does not need to. Support engineers do.
Note

A replay reproduces the status and the body, not the original headers. The first response to POST /v1/tasks carries a Location: /v1/tasks/91 header; the replay does not, because only the body is recorded. For a client that reads the ID out of the JSON — which is every client this API has — that is invisible. It is a real limitation of a deliberately small implementation, and if it ever mattered, the fix would be to record chosen headers alongside the body.

The lock.

locked, err := app.cache.SetNX(r.Context(), storeKey+":lock", 30*time.Second)
if err == nil && !locked {
    w.Header().Set("Retry-After", "2")
    app.errorResponse(w, r, http.StatusConflict,
        "a request with this Idempotency-Key is already in flight")
    return
}

Two requests with the same key can arrive at the same instant — a mobile client on a flaky connection firing a retry while the original is still travelling. Neither will find a stored record, because the first one has not finished yet. Without the lock they both run, and you get the exact duplicate the feature exists to prevent.

SetNX decides the race inside Dragonfly, where there is one copy of the truth, so the answer holds even with three copies of taskd running.

Read the condition carefully: err == nil && !locked. The refusal happens only when Dragonfly answered and said no. If err is non-nil, Dragonfly did not answer, and the policy from section 4.3 applies — fail open, carry on, run the handler. A single expression carries both the concurrency rule and the availability rule.

Retry-After: 2 tells the client to come back in two seconds, by which time the first attempt has almost certainly finished and its response is waiting to be replayed. Chapter 14 (Rate limiting) made the case: a refusal without instructions produces an instant-retry loop.

Recording the response.

ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
var buf bytes.Buffer
ww.Tee(&buf)

next.ServeHTTP(ww, r)

Here is the problem this solves. To store the response, you need a copy of it — but the handler writes straight to the network through w, and once bytes are gone they are gone. You cannot ask http.ResponseWriter what was written; it has no such method.

So we hand the handler a different writer that passes everything through and keeps a copy.

   handler ──writes──▶ ww ──────▶ w (the real network connection)
                        │
                        └──────▶ buf (bytes.Buffer, in memory)
  • chimw.NewWrapResponseWriter(w, r.ProtoMajor) — chi’s wrapper, already used by logRequest in Chapter 19 (Logging that pays rent) to learn the status code. r.ProtoMajor is the HTTP major version (1 or 2); the wrapper needs it to expose the right extra abilities.
  • var buf bytes.Buffer — a growable in-memory byte store that satisfies the “somewhere to write” interface. Declared with var, so it starts empty and usable; no constructor needed.
  • ww.Tee(&buf) — from here on, every write is duplicated into buf. The & passes the buffer’s address rather than a copy, so the writes land in this buffer.
  • next.ServeHTTP(ww, r) — the handler runs, receiving ww instead of w. It has no idea, and the client gets its response at the normal time, not after we finish bookkeeping.
New word

tee — duplicating a stream as it flows past, so it reaches its destination and a copy you keep. From the T-shaped pipe fitting; the Unix tee command is the same idea.

Storing, and the 5xx guard.

if ww.Status() < 500 { // don't memorize our own outages
    rec := append([]byte(strconv.Itoa(ww.Status())+" "), buf.Bytes()...)
    app.cache.Set(r.Context(), storeKey, rec, 24*time.Hour)
}
app.cache.Delete(r.Context(), storeKey+":lock")
  1. ww.Status() — the status code the handler used, which the wrapper recorded on the way past.
  2. < 500 — the most important comparison in the chapter. A 5xx is our failure and is probably temporary: the database blinked, a dependency timed out. Memorise it, and the client’s dutiful retry replays that failure for twenty-four hours — the precise opposite of the feature. 4xx responses are stored, deliberately: an invalid payload is the client’s fault and deserves the same verdict on retry.
  3. strconv.Itoa(ww.Status()) + " " — the integer as text, plus the delimiter space.
  4. append(a, b...) — Go’s way of concatenating slices. The ... spreads the second slice’s elements as individual arguments. The result is 201 {"task":...} as raw bytes.
  5. 24*time.Hour — the TTL. Long enough to cover any client’s retry policy, short enough that the cache does not accumulate every key forever.
  6. Delete(... + ":lock") — release the lock, whether or not we stored anything. If this line never runs because the process died, the 30-second TTL cleans up instead. Both paths matter.
Warning

The < 500 test also passes for a status of 0, which is what the wrapper reports when a handler writes nothing at all. Every handler taskd puts behind this middleware writes a JSON body, so it does not arise here — but if you ever wrap a handler that returns 204 No Content with no body, check what gets stored before trusting it.

Step 9 — Mount it on exactly the creates that hurt to duplicate

The original book writes the mounting like this:

r.With(app.idempotent).Post("/tasks", app.createTaskHandler)
r.With(app.idempotent).Post("/billing/checkout", app.createCheckoutHandler)

That shows the technique — but typed as-is into taskd’s routes.go it registers the wrong URLs.

Note

Since Chapter 11, tasks live in a sub-router: r.Route("/tasks", func(r chi.Router) {...}), inside which r.Post("/", ...) means POST /v1/tasks. Registering r.Post("/tasks", ...) inside that sub-router would produce /v1/tasks/tasks, and putting it outside collides with the sub-router’s mount. The fix is placement only — the middleware, the handler and the behaviour are identical. Replace the existing create lines in the two sub-routers:

// cmd/api/routes.go — replace the existing POST registrations in place
    r.Route("/tasks", func(r chi.Router) {
        // Idempotency keys on exactly the creates that hurt
        // to duplicate (ch. 23).
        r.With(app.idempotent).Post("/", app.createTaskHandler) // ch. 8
        r.Get("/", app.listTasksHandler)                        // ch. 9
        r.Get("/{id}", app.showTaskHandler)                     // ch. 8
        r.Patch("/{id}", app.updateTaskHandler)                 // ch. 8
        r.Delete("/{id}", app.deleteTaskHandler)                // ch. 8
    })

    r.Route("/billing", func(r chi.Router) {
        r.With(app.idempotent).Post("/checkout", app.createCheckoutHandler) // ch. 15/23
        r.Post("/portal", app.createPortalHandler)
        r.Get("/plan", app.showPlanHandler) // ch. 17
    })

What this code says

  1. r.With(mw) returns a router that applies mw to the routes registered on it, and r.With chained onto a single .Post(...) applies it to that one route only — per-route middleware. Contrast with r.Use(mw), which applies to everything registered on the router afterwards. Here we want two routes out of fifteen, so With is the tool.
  2. The listing and read routes are untouched. A GET is naturally repeatable; wrapping it would spend a cache round trip to protect nothing.
  3. Both routes are inside the activated ring from Chapter 21, below authenticate — which is what makes app.contextGetUser(r) inside the middleware safe. Mounted anywhere above authenticate, it would find no user in the context.
Common mistake

You’ll see: {"error":"the requested resource could not be found"} on a POST that worked five minutes ago. It means: the create route moved. Typing the original’s flat form inside the /tasks sub-router registers /v1/tasks/tasks, and /v1/tasks no longer has a POST handler. Fix: the pattern inside r.Route("/tasks", ...) is "/", not "/tasks".

Step 10 — The money demo

Restart the server, and make sure the cache is running (docker compose ps). Then:

curl -s -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: k1" \
  -d '{"title":"once only"}' localhost:4000/v1/tasks
curl -sD- -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: k1" \
  -d '{"title":"once only"}' localhost:4000/v1/tasks | grep -i replayed
# Idempotency-Replayed: true      ← same 201, same body, ONE task in the DB

What these commands do. The first creates a task with the key k1 and prints the JSON body. The second sends a byte-identical request — same token, same key, same payload — and grep -i replayed filters the headers for the replay marker, case-insensitively.

What you should see. The first command prints a task envelope whose id is some number, say 91. The second prints the line Idempotency-Replayed: true. Drop the | grep from the second command and compare the two bodies: identical, id included, down to the timestamps — because the second one is a recording of the first.

Now prove the database agrees. make db/psql, then:

SELECT count(*) FROM tasks WHERE title = 'once only';

You should get 1.

And look at what is stored, with Dragonfly’s command line client:

docker compose exec cache redis-cli KEYS 'idem:*'

You should get one key of the shape idem:<your-user-id>:/v1/tasks:k1. Read it:

docker compose exec cache redis-cli GET 'idem:1:/v1/tasks:k1'

Substitute your own user ID from the KEYS output. The value is the status code, a space, and the JSON body — the format from Step 8, sitting there in plain text. TTL on the same key reports the seconds remaining, counting down from 86400.


7. Checkpoint: prove it works

Five drills. Server running, containers up, $TOKEN set.

Drill 1 — The headers are on everything

curl -s -D- -o /dev/null localhost:4000/v1/healthcheck | grep -Ei 'nosniff|Frame|Referrer'

Three lines: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin. Try it against a URL that does not exist — localhost:4000/v1/nope — and you should get the same three lines with a 404 body, because the middleware is outermost.

Drill 2 — CORS is off by default

With trusted_origins = []:

curl -s -D- -o /dev/null -H "Origin: http://localhost:3000" localhost:4000/v1/healthcheck \
  | grep -i 'access-control'

No output at all. grep finding nothing is the pass condition here.

Drill 3 — A key with no repeat behaves like no key

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: drill-3-$RANDOM" -d '{"title":"drill three"}' \
  localhost:4000/v1/tasks

201. $RANDOM is the shell’s random number, so the key is new every run and every run creates a task.

Drill 4 — The same key replays

K="drill-4-$RANDOM"
curl -s -o /dev/null -w "first:  %{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $K" -d '{"title":"drill four"}' localhost:4000/v1/tasks
curl -s -o /dev/null -w "second: %{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $K" -d '{"title":"drill four"}' localhost:4000/v1/tasks

Two lines, both 201. Then confirm the row count:

docker compose exec db psql -U taskd -d taskd -c \
  "SELECT count(*) FROM tasks WHERE title = 'drill four';"

1.

Drill 5 — Idempotency fails open

docker compose stop cache
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: drill-5" -d '{"title":"cache down"}' localhost:4000/v1/tasks
docker compose start cache

201. With Dragonfly stopped, app.cache is either nil (if the server started without it) or present but unreachable; either way the create goes through, unprotected, which is the documented policy.

Checkpoint

Drills 1, 2 and 4 are the three that matter. Headers on every response, no CORS headers when no origins are configured, and one row for two identical keyed requests.

If you got something else

You got Cause Fix
Drill 4’s second call returns 201 but the count is 2 The middleware is not on the route, or the key differed between the two calls Check r.With(app.idempotent) is on the POST inside r.Route("/tasks", ...); check $K is quoted in both commands
Drill 4 returns 409 on the second call The first call’s lock was never released — the server was restarted mid-request, or Delete is missing Wait 30 seconds for the lock TTL, then retry; check the final app.cache.Delete line is present
Drill 1 shows the headers on 200 but not on 404 r.Use(secureHeaders) was registered inside the /v1 sub-router instead of at the top of routes() Move it to the first line after r := chi.NewRouter()
Drill 2 prints Access-Control-Allow-Origin trusted_origins is not empty — probably still set from Step 6 Set it back to [] and restart
{"error":"your account must be activated..."} on any create The user is not activated; the create routes are in Chapter 21’s activated ring Activate the account, or use the psql shortcut from the prerequisites

8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
./cmd/api/middleware.go:NN:4: undefined: bytes bytes.Cut and bytes.Buffer used without the import. Go will not guess Add "bytes" to the import block
./cmd/api/middleware.go:NN:11: undefined: strconv Same, for Atoi and Itoa Add "strconv"
./cmd/api/middleware.go:NN:9: undefined: chimw The chi middleware package is imported under an alias; without the alias line the name does not exist Add chimw "github.com/go-chi/chi/v5/middleware"
app.cache.SetNX undefined (type *cache.Cache has no field or method SetNX) Step 7 was skipped — the method is described in the middleware but never written Add SetNX to internal/cache/cache.go
cfg.cors undefined (type config has no field or method cors) The [cors] block is in the TOML file but the Go struct never gained a matching field Step 3: the struct field and the k.Strings line, not one of them
no required module provides package github.com/go-chi/cors The import was written before the download go get github.com/go-chi/cors
CORS configured, but the browser console says Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. The preflight was answered by something other than the CORS middleware — usually a 401, because CORS is mounted below authentication Register cors.Handler at the top of routes(), above recoverPanic and authenticate
Browser JS reads the body fine but response.headers.get('X-Request-ID') is null The header is received and hidden: it is not on the CORS safelist and not in ExposedHeaders Add it to ExposedHeaders
The browser sends the request without Idempotency-Key and never errors The header is missing from AllowedHeaders, so the preflight refused it Add "Idempotency-Key" to AllowedHeaders
Every keyed retry returns 409 forever Locks are being taken and never released, or the clock ran out mid-handler Check the Delete at the end; check no return path skips it
The replay has no Location header Expected: only status and body are recorded Read the ID from the JSON body, which is identical
panic: missing user value in request context, logged as a 500 idempotent is mounted somewhere authenticate never ran It belongs inside /v1, in the authenticated rings

9. Pitfalls

  • Replaying across users. The user ID in the store key is not decoration. Without it, guessing another client’s key replays their response to you — including whatever their task or checkout session contained. Idempotency stores are per-principal or they are an information leak. This is the same instinct as Chapter 12 (Ownership): every lookup carries the owner, always.

  • Storing 5xx. Memorise a transient failure and the client’s dutiful retry replays that failure for the whole TTL — the exact opposite of the feature. Hence the < 500 guard. 4xx are stored on purpose: an invalid payload deserves the same verdict on retry, and a client that fixes its payload should also change its key, because it is a different attempt.

  • Wildcard origins with credentials. Browsers already forbid * together with credentialed requests, so nobody ships that bug directly. The failure mode is the “fix” people reach for next: reading the request’s Origin header and reflecting it straight back as Access-Control-Allow-Origin. That allows every origin that asks, which is * with extra steps and a false sense of having thought about it. The allow-list comes from config and review, full stop.

  • Trusting the key’s contents. It is attacker-supplied text. Ours is length-capped at 200 and only ever used inside a composed cache key. Never log it raw and never let it near SQL — concatenating client text into a query is how SQL injection happens (Chapter 7, sqlc, made parameters the only way values reach Postgres), and the usual input rules do not pause because the text arrived in a header rather than a body.

  • CORS as access control. If a resource must not be readable cross-site, the answer is authentication, not CORS — non-browser clients never saw the fence. This is repeated because every year someone ships it, usually as “we locked it down with CORS” in a security review.

  • The 24-hour TTL is a promise to clients. A client whose retry policy spans days will retry after the record expires and create a duplicate. Twenty-four hours comfortably covers every sensible retry schedule, and if you document a window at all, document that one.


10. Check yourself — quiz

  1. What does X-Content-Type-Options: nosniff prevent, and why does it matter for an API that only ever returns JSON?
  2. A colleague says “we don’t need auth on this endpoint, CORS stops other sites reading it”. In two sentences, say why that is wrong and what the correct fix is.
  3. Why does a CORS preflight carry no Authorization header, and what breaks if the CORS middleware is mounted below the authentication middleware?
  4. The idempotency middleware returns immediately if the header is absent. Name two things that design buys, and one thing it gives up.
  5. storeKey is idem:<userID>:<path>:<key>. For each of the three parts, say what goes wrong if you remove it.
  6. Why are 5xx responses not stored while 4xx responses are? Answer from the client’s point of view.
  7. Two requests with the same key arrive one millisecond apart. Trace what each one receives, and name the mechanism that decides which is which.
  8. SetNX returns (false, nil) and (false, someError) in two different situations. What does each mean, and why does the code treat them differently?
Answers
  1. It forbids MIME sniffing — the browser guessing that a response is really HTML and rendering it. It matters for a JSON API because the JSON contains user-supplied text: a task title made of HTML with a <script> tag, served as JSON but sniffed as HTML, runs that script on your domain. That is stored XSS delivered through an API that never intended to serve a page.

  2. CORS is enforced by browsers only, so it stops nothing that is not a browser — curl, a script, a mobile app and an attacker’s server all ignore it completely. The correct fix is authentication: require a valid token and check ownership, which every client must satisfy regardless of how it was written.

  3. The specification defines the preflight as a request the browser generates on its own, and it deliberately excludes authentication so that the permission question can be answered before any credentials are exposed to a possibly-hostile endpoint. Mounted below authentication, the preflight hits requireAuthenticatedUser with no token and gets a 401 with no Access-Control-* headers; the browser then reports “Response to preflight request doesn’t pass access control check” and never sends the real request.

  4. It buys zero cost for clients that do not use it (one header lookup, no cache round trip) and no behaviour change for every existing client, so the feature can ship without a migration. What it gives up is any guarantee that clients actually use it — a client that retries without a key still double-creates, and the server cannot make it stop.

  5. Remove userID and one user can replay another user’s response by guessing their key: an information leak. Remove path and a client reusing one key for two different endpoints gets the first endpoint’s response from the second — a task envelope in answer to a checkout request. Remove key and every request from that user to that path collapses onto one record, so the second create of the day replays the first: the API stops working.

  6. A 5xx says “we failed, this is probably temporary, please retry” — and retrying is exactly what a client with a key will do. If the failure were recorded, the retry would replay the failure instead of getting a fresh attempt, and the client would be permanently stuck for the TTL. A 4xx says “your request is wrong”; it will be equally wrong on retry, so replaying it is honest and saves the work.

  7. The first to reach SetNX claims the lock and gets true, runs the handler, and receives the real 201. The second finds no stored record yet (the first has not finished) and gets false from SetNX, so it receives 409 Conflict with Retry-After: 2 and the message “a request with this Idempotency-Key is already in flight”. The deciding mechanism is SETNX’s atomicity inside Dragonfly: one shared copy of the truth, so exactly one caller can win, no matter how many copies of taskd are running.

  8. (false, nil) means Dragonfly answered and the key already existed — a genuine concurrent twin, which deserves the 409. (false, someError) means Dragonfly did not answer at all, so nothing is known about concurrency; the policy is fail open, and the request proceeds to the handler unprotected. The condition err == nil && !locked encodes precisely that distinction: refuse only on a real answer of “no”.


11. Practice

Exercise 1 — Break the mounting, read the error, fix it (easy)

In routes.go, change the create registration inside the /tasks sub-router to the original book’s flat form:

r.With(app.idempotent).Post("/tasks", app.createTaskHandler)

Restart, then run:

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"broken"}' localhost:4000/v1/tasks

Predict the status code before you run it. Then find the URL that does work, and repair the route.

Answer

You get 404. The pattern "/tasks" registered inside r.Route("/tasks", ...) produces the full path /v1/tasks/tasks, and POST /v1/tasks no longer has a handler, so chi falls through to app.notFoundResponse. Confirm the doubled path directly:

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"broken"}' localhost:4000/v1/tasks/tasks

That prints 201.

The repair is the pattern "/":

// cmd/api/routes.go — inside r.Route("/tasks", ...)
r.With(app.idempotent).Post("/", app.createTaskHandler)

The transferable lesson: inside a chi sub-router, patterns are relative to the mount point, and the route that means “the collection itself” is "/". The same mistake with r.Route("/billing", ...) would give you /v1/billing/billing/checkout.

Exercise 2 — Catch the 409 with two concurrent requests (medium)

Drill 4 sent its two requests one after another, so the first had finished before the second started. Send them at the same time instead, and capture the 409 the lock produces.

The awkward part is that POST /v1/tasks is fast — fast enough that two shell commands rarely overlap. Use a handler that takes longer, or make the window bigger. Write a script that fires both in the background with & and prints both status codes.

Answer
# scripts/idem-race.sh — new file
#!/usr/bin/env bash
set -euo pipefail

: "${TOKEN:?set TOKEN first}"
K="race-$RANDOM"

# Fire both requests without waiting for the first, then wait for both.
for n in 1 2; do
  curl -s -o /dev/null -w "attempt $n: %{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Idempotency-Key: $K" \
    -d '{"title":"race"}' \
    localhost:4000/v1/tasks &
done
wait

docker compose exec -T db psql -U taskd -d taskd -tAc \
  "SELECT count(*) FROM tasks WHERE title = 'race';"

Run it with chmod +x scripts/idem-race.sh && ./scripts/idem-race.sh.

There are two acceptable outcomes, and both prove the feature:

  • 201 and 409 — the second request found the lock held and was told to wait. Task count: 1.
  • 201 and 201 where the second carries Idempotency-Replayed — the first finished within the gap and the second replayed it. Task count: 1.

What must never happen is a task count of 2. If you see that, the middleware is not on the route or the two requests used different keys.

If you only ever see the replay outcome, widen the window: the race branch needs the first request to still be in flight. The most reliable way is to make the handler slower on purpose — add time.Sleep(3 * time.Second) as the first line of createTaskHandler, run the script, and remove it again. Run the script a second time to confirm you removed it.

Notes on the shell. set -euo pipefail stops on the first error rather than continuing with garbage. : "${TOKEN:?...}" fails with a clear message if $TOKEN is unset instead of sending an anonymous request. The & puts each curl in the background; wait blocks until both finish. psql -tAc prints the count with no table borders or header.

Exercise 3 — Prove the per-user isolation (harder)

The pitfall says an idempotency store without the user ID is an information leak. Demonstrate that the taskd version does not have that leak: two different users using the same Idempotency-Key must each get their own task, not each other’s.

Answer

Register and activate a second user, then use the same key from both accounts.

# A second activated user. Registration returns 202; activation is done
# directly in the database here to keep the exercise short.
curl -s -o /dev/null -d '{"name":"Bob","email":"bob@example.com","password":"pa55word123"}' \
  localhost:4000/v1/users
docker compose exec -T db psql -U taskd -d taskd -c \
  "UPDATE users SET activated = true WHERE email = 'bob@example.com';"

TOKEN_B=$(curl -s -d '{"email":"bob@example.com","password":"pa55word123"}' \
  localhost:4000/v1/tokens/authentication | jq -r .authentication_token.token)

K="shared-$RANDOM"
curl -s -H "Authorization: Bearer $TOKEN"   -H "Idempotency-Key: $K" \
  -d '{"title":"alice task"}' localhost:4000/v1/tasks | jq -c '.task | {id, title}'
curl -s -H "Authorization: Bearer $TOKEN_B" -H "Idempotency-Key: $K" \
  -d '{"title":"bob task"}'   localhost:4000/v1/tasks | jq -c '.task | {id, title}'

You should see two different objects: one titled alice task and one titled bob task, with different id values. If the user ID were missing from the store key, the second call would replay the first and Bob would receive Alice’s task — the leak, demonstrated.

Confirm the separation in the store:

docker compose exec cache redis-cli KEYS 'idem:*'

Two keys, differing only in the user ID segment, sharing the same path and the same key text. That segment is the entire defence.


12. FAQ

What is CORS actually for, if it is not security? It exists to let a browser relax the same-origin policy safely. Without CORS, a browser would never let app.example.com read from api.example.com, and every web app would have to be served from the same host as its API. CORS is the API’s way of saying “these specific other origins are part of my system, let their code read my answers”. It protects users of other sites from your API being read on their behalf — it does not protect your API.

Why doesn’t curl hit CORS errors? Because CORS is enforced entirely inside browsers. The server sends some extra headers; a browser reads them and decides whether to let the page’s JavaScript see the response. curl has no page, no JavaScript and no origin, so there is nothing to enforce. This is not a loophole — it is the design. The rule protects a user browsing a hostile site, and there is no user in a curl session.

Do I need idempotency keys if I already have optimistic locking? Yes, because they solve different problems. Optimistic locking (Chapter 8, CRUD done properly) stops two people overwriting each other’s edit to an existing row: the version number in your update no longer matches, so you are told to re-read and retry. Idempotency keys stop one person’s retry creating a second row. There is no version to check on a create, because the thing does not exist yet.

What should a client use as the key? Something unique per logical attempt, generated once and reused across every retry of that attempt. A UUID (a 36-character random identifier) is the usual answer. Two rules: do not generate a new key for the retry, which defeats the whole mechanism; and do not reuse a key for a genuinely new operation, which will replay the old answer. A useful mental model is that the key names the user’s intent — “the task I am creating right now” — not the network request.

Why 24 hours? It is long enough to cover any sane client retry policy, including a mobile app that queues a failed create and retries when the phone next has a signal. It is short enough that the store does not accumulate keys forever, and short enough that a client which comes back after a week is treated as a new attempt rather than being replayed something stale. Stripe uses the same window.

Are these three headers enough security? No, and nothing claims they are. They close three specific browser-side attacks at a cost of five lines. The load-bearing security in taskd is elsewhere: hashed passwords (Chapter 10), hashed tokens with expiry (Chapter 11), ownership checks on every query (Chapter 12), signature verification on webhooks (Chapter 16), TLS at the edge (Chapter 27, Going live). Security headers are the cheap layer you add because they are cheap, not because they are sufficient.


13. Where we are

The edge is no longer bare. Every response tells the browser how to treat it; browser clients from named origins can call the API and read the headers they need, while an unconfigured deployment sends no CORS headers at all; and a client that retries a create gets its original answer back instead of a duplicate.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go   server.go   db.go   background.go
│   ├── config.go          # UPDATED: cfg.cors struct + k.Strings line
│   ├── routes.go          # UPDATED: secureHeaders, CORS, two r.With mounts
│   ├── middleware.go      # UPDATED: secureHeaders + idempotent
│   ├── helpers.go   errors.go   context.go
│   ├── healthcheck.go   metrics.go
│   ├── tasks.go   users.go   tokens.go   accounts.go
│   ├── billing.go   entitlements.go   webhooks.go
├── internal/
│   ├── cache/
│   │   ├── cache.go       # UPDATED: SetNX
│   │   └── ratelimit.go
│   ├── data/   db/   mailer/   validator/
├── migrations/            # unchanged this chapter
├── sql/queries/           # unchanged this chapter
├── scripts/               # + idem-race.sh, if you did Exercise 2
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum        # + github.com/go-chi/cors

What works end to end: register, activate, log in, and run the whole product — tasks, plans, payments, account recovery — behind three security headers, an optional origin allow-list, and retry-safe creates.

What is still fake or missing:

  • CORS is off in every deployment, because trusted_origins is empty and its environment override does not work. The day a front end exists, that value goes in the config file the container mounts (Chapter 25, Docker).
  • Idempotency covers two routes. Any future POST that creates something needs the same r.With(app.idempotent) treatment, and nothing enforces that but review.
  • The replay drops response headers, Location included. Fine for taskd’s clients, worth knowing before you copy this into a system where it is not.
  • Nothing here is documented. A client cannot discover that Idempotency-Key exists. Chapter 24 (Documenting the API) writes the contract down, and the Idempotency-Key header is one of the things it names.

For your notes

Copy these into learnings/ch23.md, in your own words:

  1. CORS is browser policy, not access control. It tells browsers which origins’ JavaScript may read your responses. curl never cared. If a resource must not be readable by strangers, the answer is authentication.
  2. Preflight OPTIONS requests carry no Authorization header, by specification — so the CORS middleware must mount above the authentication middleware, or your preflights get 401 and the browser reports a missing Access-Control-Allow-Origin.
  3. A timed-out POST is unanswerable from the client’s side. The client labels the attempt with an Idempotency-Key; the server records its response per (user, endpoint, key) and replays it. Nothing runs twice.
  4. The user ID belongs in the store key. Without it, guessing another client’s key replays their response to you. Idempotency stores are per-principal or they are an information leak.
  5. Never memorise a 5xx. Store 4xx (the request will be equally wrong next time) and never 5xx (the retry deserves a fresh attempt). One < 500 comparison is the difference between a feature and a trap.
  6. SETNX is a lock with a deadline. “Set if not exists” decides a race inside the shared store, where there is one copy of the truth — and the TTL means a crashed holder is forgiven in thirty seconds instead of never.

Chapter 24 — Documenting the API: OpenAPI without the machinery

Twenty-one routes exist in taskd now, and the only way anyone can learn them is to read your Go source or send you a message. This chapter fixes that with one hand-written file — a machine-readable description of everything the API accepts and returns — baked into the binary, served at /v1/openapi.yaml, rendered as a browsable reference page at /docs, and guarded by a test that turns your build red the day you add a route and forget to write it down.

What you’ll be able to do by the end

  • Read and write an OpenAPI file: paths, operations, parameters, request bodies, responses, and reusable components.
  • Explain why this book writes the contract by hand instead of generating it from code comments — and name the one thing hand-writing costs you.
  • Serve a documentation page from a single binary with no build step, no npm, and no static file directory.
  • Make a test walk your own router and fail when a route is undocumented, and say precisely what that test does not check.
  • Look at a spec line and name the chapter whose design decision it publishes to customers.

Time: ~45 minutes reading, ~50 minutes typing.

You need before starting: a working Chapter 23 (Hardening the edge: headers, CORS, idempotency keys). Two things must be true — the server runs, and the test suite is green. Prove both:

make test

You should see a ok line for each package that has tests, and no FAIL anywhere. Then, in a second terminal with the server running (make run/api), confirm you can still reach the product with a token:

curl -sH "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks

A JSON body containing a "metadata" key. If $TOKEN has expired — they last 24 hours since Chapter 11 (Stateful tokens) — log in again to get a fresh one before continuing.


1. The problem, in plain words

Imagine someone at another company has been told to integrate with taskd. They have your base URL and nothing else. What do they do?

They guess. They try GET /tasks and get a 404, then GET /v1/tasks and get a 401. They guess that the token goes in a header, guess the header’s name, guess the word Bearer. Eventually something works and they build against it. Every guess they made is now load-bearing in their code, and none of it was ever a promise you made.

Then you change something small — a field name, a status code — and their integration breaks. They open a support ticket. You explain that the thing they relied on was never documented. That is a correct answer and a useless one, because you are the one who is now on a call.

Why this exists

An API without a written contract is not a product; it is a rumour. Every undocumented detail is something a customer will discover, depend on, and be hurt by. Writing the contract down is not paperwork — it is the difference between “we changed our internals” and “we broke your app”.

The obvious fix is a README with a table of endpoints. That is better than nothing and it rots within a month, because nothing checks it. Prose cannot be validated, cannot generate a client library, cannot power a “try it” button, and cannot fail a build.

So we write the contract in a format that machines read too. That format has a name — OpenAPI — and once your API is described in it, three things become possible that were not possible before:

  1. A documentation website renders itself from your file.
  2. Other people generate typed client code in their language from your file.
  3. A test can compare your file against your actual router and complain when they disagree.

The third one is the reason this chapter exists at all. Documentation that nothing enforces is documentation that will lie to you, politely, for months.

Think of it like

An OpenAPI file is a restaurant’s published menu: every dish, every price, every allergen. The kitchen can reorganise itself however it likes, but what is printed on the menu is what the customer ordered. A menu that lists a dish the kitchen stopped making is worse than no menu — and that is the failure the test in Step 10 exists to prevent.


2. New words in this chapter

  • OpenAPI — a standard way of describing what an HTTP API accepts and returns, written as YAML or JSON. The machine-readable contract. Formerly called Swagger, a name still used for some of the tooling.
  • contract — the promises your API makes to callers, as opposed to how it happens to be implemented today. The two are different, and confusing them is expensive.
  • spec — short for specification: the file that states the contract. Ours is openapi.yaml.
  • spec-first — you write the contract by hand and check the code against it.
  • code-first — you annotate the code and generate the contract from those annotations.
  • DSL (domain-specific language) — a small made-up language for one job. Code-first generators use one: structured comments above each handler that a tool parses.
  • drift — documentation and reality silently diverging over time.
  • YAML — an indentation-based text format for configuration and specs. You have used it already for docker-compose.yml and sqlc.yaml; this is the same format, larger.
  • path template — a URL pattern with a placeholder in braces, like /v1/tasks/{id}.
  • operation — one method on one path. GET /v1/tasks and POST /v1/tasks are two operations on the same path.
  • schema (OpenAPI sense) — the described shape of a JSON value: its type, its fields, their types. Unrelated to a database schema, which is the shape of your tables (Chapter 5).
  • $ref — a pointer inside the spec. Define a shape once under components, refer to it from everywhere else.
  • security scheme — the spec’s description of how a caller proves who they are. Ours says “HTTP bearer token”.
  • CDN (content delivery network) — a network of servers around the world that hand out copies of public files, usually JavaScript libraries, quickly. Loading from one means you do not host the file yourself.
  • air-gapped — a machine with no internet access at all, by policy. Common in banks, hospitals and defence.
  • Scalar — the JavaScript renderer we load from a CDN to turn our YAML into a browsable page.
  • route walk — asking the router to list every route it has registered, one at a time.
  • type assertion — Go’s way of asking “the value in this interface variable, is it really a chi.Router?” and getting it back as that type if so.
  • callback — a function you hand to another function so it can call yours back, once per thing it finds.
  • variadic parameter — a Go parameter written ...T that accepts any number of arguments, including none.
  • contract testing — validating real responses against the spec at runtime. We do not build this; we name it as the honest next step.

3. The goal

A hand-written openapi.yaml — the API’s public contract — embedded in the binary, served at /v1/openapi.yaml, rendered as a browsable reference at /docs, and defended against rot by a test that fails CI when a route exists that the spec doesn’t mention.

Three files appear and one changes:

File State What it is
cmd/api/docs/openapi.yaml new the contract: 364 lines, 16 paths
cmd/api/docs.go new embeds the spec, serves it, serves the docs page
cmd/api/docs_test.go new walks the router, fails on any undocumented route
cmd/api/routes.go updated two new routes

4. The thinking

4.1 The build-vs-generate decision

There are two ways to end up with an OpenAPI file, and they lead to different places.

Code-first generators — most of them still branded Swagger, the format’s old name — sweep annotation comments out of your source and assemble a spec from them. You write something like // @Success 200 {object} Task above each handler, run a tool, and a YAML file appears. The promise is sync for free: the docs live next to the code, so they cannot fall behind.

The price is three things. A comment DSL smeared over every handler — a second, weaker language living in your source, with its own syntax errors and its own version upgrades. Generated YAML that nobody reads, because nobody reads generated files. And, most subtly, docs that describe what the code is rather than what the API promises.

That last one sounds like hair-splitting. It is the whole argument. A generator looking at deleteTaskHandler can tell you it returns a struct. It cannot tell you that a task belonging to another user answers 404 and not 403, because that is not a fact about the code’s types — it is a decision made in Chapter 12 (Ownership and multi-tenancy) for a security reason. Generated docs describe an implementation. A contract describes an intent. Customers integrate against intent.

So the contract should be authored, like the database schema was: spec-first, by hand.

New word

spec-first vs code-first — spec-first means you write the contract by hand and check the code against it. Code-first means you annotate the code and generate the contract from it. Spec-first risks drift; code-first risks documenting your implementation instead of your promises.

Our surface is roughly twenty routes. The YAML is an afternoon, and writing it is an audit. Ours surfaced that error envelopes weren’t uniformly documented anywhere — there was no single place that said “a validation failure looks like this”. Now there is: one ValidationError component that every operation points at.

Approach How the docs appear What you get What it costs
Nothing (source + README) you answer emails zero work today every integrator guesses; nothing is checkable; the README rots
Code-first generation a tool reads comment annotations routes cannot be forgotten a comment DSL in every handler; a build step; a file nobody reads; describes the implementation
Spec-first by hand (ours) you write the YAML the contract says what you promise; writing it audits your design drift — you can add a route and forget the spec

We chose the option whose cost is drift. So we do not leave honesty to discipline.

4.2 Mechanizing the honesty

The known cost of hand-writing is drift, and drift is invisible: nothing hurts on the day it starts. It hurts three months later, when a customer follows the docs into a 404.

The fix is small and mechanical. Chi — the router from Chapter 2 (The skeleton) — can enumerate its own routing table. So a test asks the router for every route it has registered, and for each one checks that the spec mentions it. A route with no entry in the contract is a failed test.

Remember this

Drift becomes a red build, not a support ticket. Any documentation practice that depends on remembering will eventually depend on the person who left.

4.3 Serving it

The spec embeds into the binary — single binary, as ever, the same decision as the email templates in Chapter 21 (Background work and transactional email). Deploying taskd stays “copy one file”.

The /docs page is one static HTML page that loads a renderer (Scalar) from a CDN — zero build step, no npm, no bundler, no node_modules. Seven lines of HTML give you a searchable reference with a try-it-out panel.

Note

Honest footnote: that page needs internet, because the browser fetches the renderer from cdn.jsdelivr.net. The API never does. The day an air-gapped consumer appears — a customer whose machines have no internet by policy — vendor the renderer’s JavaScript into the embed alongside the YAML. Until then, this is one line instead of a front-end build.


5. A picture of it

The wall between implementation and contract

Everything on the left is yours to change whenever you like. Everything on the right is a promise strangers have written code against. The spec is the wall.

     implementation vocabulary       │        the public promise
   ──────────────────────────────    │   ──────────────────────────────
   bigint identity primary key       │   id: {type: integer, format: int64}
   sqlc-generated db.Task struct     │   Task: {id, title, notes, status,
   pgx v5 connection pool            │          priority, due_at, version}
   WHERE id=$1 AND user_id=$2        │   "404": Not found (including tasks
   UPDATE ... AND version=$8         │          you don't own).
   pgx.ErrNoRows                     │   "409": Edit conflict.
                                     │
        yours to change              │        theirs to rely on
                                     │
                          the wall = openapi.yaml

What the spec carries that prose loses

Each of these lines is a design decision made earlier in the book, now published where a customer can read it. A README would have lost every one of them.

   line in openapi.yaml                     the decision it publishes
   ───────────────────────────────────────  ────────────────────────────────
   token: {type: http, scheme: bearer}      ch. 11 — stateful bearer tokens
   "404": Not found (including tasks        ch. 12 — a foreign task is
          you don't own).                          invisible, not forbidden
   "409": Edit conflict.                    ch.  8 — optimistic locking
   "402": Plan limit reached                ch. 17 — quotas and the upsell
          (code `upgrade_required`).
   Idempotency-Key header                   ch. 23 — retries that don't
                                                    double-create

How one YAML file becomes a website

   build time                         run time
   ──────────                         ────────
   cmd/api/docs/openapi.yaml
          │
          │ //go:embed                    GET /v1/openapi.yaml
          ▼                                       │
   ┌──────────────┐   go build   ┌────────────────▼──────────────┐
   │  docsFS      │─────────────▶│  taskd binary — one file      │
   │  (embed.FS)  │              │  openapiHandler  → the YAML   │
   └──────────────┘              │  docsPageHandler → 7 lines of │
                                 │                    HTML       │
                                 └────────────────┬──────────────┘
                                                  │ GET /docs
                                                  ▼
                                       ┌────────────────────┐
                                       │  browser           │
                                       │  loads Scalar  ────┼──▶ a CDN
                                       │  fetches the YAML  │
                                       └────────────────────┘

Walking it: the YAML is a file on disk while you edit it; go build copies its bytes into the binary; at run time openapiHandler hands those bytes to anyone who asks; docsPageHandler hands out a tiny HTML page; the browser runs that page, downloads Scalar from the CDN, then fetches the YAML from your server and draws the reference.

The route-walk test

   app.routes() ──▶ chi.Router ──▶ chi.Walk visits every (method, path)
                                            │
                                            ▼
                                 ┌────────────────────────┐
                                 │ trim a trailing "/"    │
                                 └───────────┬────────────┘
                                             ▼
                                 ┌────────────────────────┐  yes
                                 │ in the exempt map?     │──────▶ skip
                                 └───────────┬────────────┘
                                             │ no
                                             ▼
                              ┌──────────────────────────────┐  no
                              │ spec contains "\n  <path>:"? │─────▶ t.Errorf
                              └──────────────┬───────────────┘       red build
                                             │ yes
                                             ▼
                                        next route

6. The steps

The work splits into three parts: write the contract, ship it, then defend it.

Part A — writing the contract

Step 1 — Create the file and write its head

Make the folder and start the file. The folder name matters: the //go:embed directive in Step 7 looks for exactly docs/openapi.yaml relative to cmd/api/.

mkdir -p cmd/api/docs

Now the head of the spec — everything before the first path.

# cmd/api/docs/openapi.yaml — new file, the opening sections
openapi: 3.1.0
info:
  title: taskd API
  version: "1.0"
  description: Task management with plans, quotas, and idempotent creates.
servers:
  - url: https://api.yourdomain.com
security:
  - token: []

paths:

What this says, line by line

  • openapi: 3.1.0 — which version of the OpenAPI standard this file follows. Tools read this first and adjust. 3.1 is the version that aligns with modern JSON Schema, which is why we can write type: [string, "null"] later.
  • info: — human-facing metadata. title and version are required by the standard. version is your API’s version, not OpenAPI’s; it is quoted so YAML reads it as the text 1.0 and not the number 1.0, which would print as 1.
  • servers: — the base URLs this contract applies to. The leading - makes it a list item; there can be several (production, sandbox). Replace api.yourdomain.com with your real host when you have one.
  • security: — the default security requirement for every operation in the file: the scheme named token, which we define at the bottom under components. The empty [] after it means “this scheme needs no particular scopes”. Individual operations override this with security: [] (Step 2) when they are public.
  • paths: — everything below this is one entry per URL path. It is empty for now.
New word

YAML — a text format where indentation is structure. Two spaces deeper means “belongs to the line above”. A key: value pair is a field; a line starting with - is a list item. {a: 1, b: 2} is the compact form of a nested block, used in this spec to keep short definitions on one line.

Common mistake

You’ll see: a validator or the docs page complaining about a line number, or a section that silently does not appear in the rendered page. It means: the indentation is wrong, or you used a tab character. Fix: two spaces per level, never tabs. YAML forbids tabs for indentation entirely. If your editor inserts tabs, set it to spaces for .yaml files before you go further — this is the single most common source of pain in this chapter.

Step 2 — The first path: /v1/healthcheck

Start with the smallest endpoint in the service, because it introduces four ideas at once with nothing else in the way.

# cmd/api/docs/openapi.yaml — append, indented two spaces under `paths:`
  /v1/healthcheck:
    get:
      summary: Liveness and database status
      security: []
      responses:
        "200":
          description: Service status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: {type: string}
                  database: {type: string}
                  environment: {type: string}
                  version: {type: string}
Note

This path is printed here for the first time. The original edition’s chapter showed only /v1/tasks and /v1/tasks/{id} and left the other fourteen paths to a repository the book never published — which is why its own route-walk test could not pass. Step 6 closes that gap.

What this says, line by line

  • /v1/healthcheck: — the path, exactly as a client would type it after the server URL. Indented two spaces, so it belongs to paths:.
  • get: — an operation: one HTTP method on this path. Lowercase, always.
  • summary: — one short line, shown as the title of this operation in any rendered documentation.
  • security: [] — an empty list overrides the file-wide default from Step 1. It means: this operation needs no authentication. Healthcheck is public (Chapter 2), so we say so.
  • responses: — one entry per status code this operation can return.
  • "200": — quoted, because YAML would otherwise read 200 as a number and the standard wants a string key.
  • content:application/json:schema: — three nested levels answering “what does the body look like?”: which media type, then the shape. application/json is the MIME type, the same string the server puts in its Content-Type header.
  • type: object with properties: — a JSON object with these four fields, each a string.

That is the whole grammar of an OpenAPI operation. Everything else in the file is more of this.

Step 3 — /v1/tasks: query parameters, a header, and a body

The list-and-create path. This one carries the interesting parts: parameters that arrive in the query string, a parameter that arrives in a header, a request body, and three possible responses.

# cmd/api/docs/openapi.yaml — append, still under `paths:`
  /v1/tasks:
    get:
      summary: List your tasks
      parameters:
        - {name: status,    in: query, schema: {type: string, enum: [open, done, archived]}}
        - {name: priority, in: query, schema: {type: string}}
        - {name: search,    in: query, schema: {type: string}, description: Pro plan and above.}
        - {name: sort,      in: query, schema: {type: string, example: -created_at}}
        - {name: page,      in: query, schema: {type: integer, minimum: 1}}
        - {name: page_size, in: query, schema: {type: integer, maximum: 100}}
      responses:
        "200":
          description: A page of tasks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks: {type: array, items: {$ref: "#/components/schemas/Task"}}
                  metadata: {$ref: "#/components/schemas/Metadata"}
        "422": {$ref: "#/components/responses/ValidationError"}
    post:
      summary: Create a task
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: {type: string, maxLength: 200}
          description: Repeats with the same key replay the original response.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TaskInput"}
      responses:
        "201": {description: Created., content: {application/json: {schema:
                  {type: object, properties: {task: {$ref: "#/components/schemas/Task"}}}}}}
        "402": {description: Plan limit reached (code `upgrade_required`).}
        "422": {$ref: "#/components/responses/ValidationError"}

What this says, line by line

  • parameters: is a list — each - is one parameter. Every parameter needs a name and an in, which says where it travels: query (after the ?), header, path, or cookie.
  • The six query parameters are exactly the ones listTasksHandler reads in Chapter 9 (Listing: filters, sorting, pagination). enum: lists the only legal values for status; minimum/maximum publish the bounds the validator enforces.
  • description: Pro plan and above. on search — a plan restriction from Chapter 17 (Entitlements), stated where the customer will see it rather than discovered via a 402.
  • example: -created_at shows a value; renderers pre-fill the try-it-out panel with it. The leading minus is our descending-sort convention.
  • $ref: "#/components/schemas/Task" — a pointer. The # means “this same file”; the rest is a path through the document to a definition we write in Step 5. Change Task in one place and every reference updates.
  • tasks: {type: array, items: {...}} — a JSON array whose elements have the Task shape. This matches the envelope from Chapter 8 (CRUD done properly): {"tasks": [...], "metadata": {...}}.
  • "422": {$ref: "#/components/responses/ValidationError"} — a $ref can point at a whole response, not only a schema. Every operation that can fail validation points here, so the error envelope is described once.
  • In post:, the Idempotency-Key parameter is written in the long form (one field per line) rather than the compact {...} form, because it carries a description. Both forms mean the same thing to a parser; pick whichever reads better.
  • requestBody: with required: true — this operation will not accept an empty body.
  • "402" is Payment Required — the quota response from Chapter 17. The backtick-quoted upgrade_required is the machine-readable code field in the error body, so a client can branch on it instead of matching English.
New word

$ref — OpenAPI’s pointer. $ref: "#/components/schemas/Task" means “the definition living at componentsschemasTask in this file”. It is the spec’s version of not repeating yourself.

Step 4 — /v1/tasks/{id}: path templates and shared parameters

# cmd/api/docs/openapi.yaml — append, still under `paths:`
  /v1/tasks/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer, format: int64}}
    get:
      summary: Fetch one task
      responses:
        "200": {description: The task.}
        "404": {description: Not found (including tasks you don't own).}
    patch:
      summary: Partially update a task
      description: Send `version` for optimistic locking; a stale version yields 409.
      responses:
        "200": {description: Updated.}
        "409": {description: Edit conflict.}
    delete:
      summary: Delete a task
      responses:
        "204": {description: Deleted.}

What this says, line by line

  • {id} inside the path is a path template: a placeholder standing for a value in the URL. The matching parameter must be declared with in: path and required: true — a path placeholder is never optional.
  • The parameters: block sits directly under the path, not under an operation. Declared there, it applies to all three operations. Writing it once is the point.
  • format: int64 is a hint, not a constraint: “this integer is a 64-bit one”. It matters to code generators, which pick long or int64 in the target language.
  • "404": Not found (including tasks you don't own). — the parenthesis is the contract. Chapter 12 decided that another user’s task must be invisible rather than forbidden, because 403 confirms the row exists and lets an attacker map your database by ID. That decision now faces the customer.
  • "409": Edit conflict. — optimistic locking from Chapter 8: two people editing the same task, the second one loses and is told so rather than silently overwriting.
Note

Two lines in this block do not match the code you wrote earlier, and both are kept exactly as the original edition wrote them. delete promises 204 No Content, but deleteTaskHandler in Chapter 8 returns 200 with {"message": "task successfully deleted"}. And patch says to send version, but the update handler’s input struct has no version field, so readJSON’s DisallowUnknownFields() would answer 400 body contains unknown key "version". Section 9 uses both as the chapter’s central lesson: the route-walk test cannot catch either one. Fixing them is Exercise 3’s territory and a deliberate decision, not a typo to paper over.

Notice what this file is accumulating. The spec carries the behavioural contracts that prose usually loses — 404-for-foreign, 409 semantics, the 402 upsell, the idempotency header. Docs are where your design decisions face the customer.

Step 5 — components: say each shape once

Everything the $refs have been pointing at.

# cmd/api/docs/openapi.yaml — append at the end, at column zero
components:
  securitySchemes:
    token: {type: http, scheme: bearer, description: From /v1/tokens/authentication.}
  schemas:
    Task:
      type: object
      properties:
        id: {type: integer, format: int64}
        title: {type: string}
        notes: {type: string}
        status: {type: string, enum: [open, done, archived]}
        priority: {type: string, enum: [none, low, medium, high]}
        due_at: {type: [string, "null"], format: date-time}
        version: {type: integer}
    TaskInput:
      type: object
      required: [title]
      properties:
        title: {type: string, maxLength: 500}
        notes: {type: string}
        priority: {type: string, description: Pro plan and above for non-none values.}
        due_at: {type: [string, "null"], format: date-time}
    Metadata:
      type: object
      properties:
        current_page: {type: integer}
        page_size: {type: integer}
        last_page: {type: integer}
        total_records: {type: integer}
  responses:
    ValidationError:
      description: Field-level validation failures.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: {type: object, additionalProperties: {type: string}}

What this says, line by line

  • components: is a top-level key — column zero, a sibling of paths:. Nothing in it is active on its own; it is a library of definitions that $refs point into.
  • securitySchemes:token: — the security scheme that Step 1’s security: block referred to by name. type: http, scheme: bearer means the standard Authorization: Bearer <value> header from Chapter 11. The description tells the reader where to get one.
  • Task and TaskInput are deliberately different shapes. Task is what we return; TaskInput is what we accept. A caller cannot set id or version, so those do not appear in the input — the spec’s way of saying “these are ours”.
  • required: [title] on TaskInput — a list of field names that must be present. Everything else is optional.
  • type: [string, "null"] — this field is either a string or JSON null. That two-type syntax is OpenAPI 3.1; the older 3.0 spelled it nullable: true. "null" is quoted because unquoted null in YAML is the null value, not the word.
  • format: date-time — an RFC 3339 timestamp, 2026-08-16T09:30:00Z. Same hint role as int64.
  • additionalProperties: {type: string} on the error object — “any field name, but every value is a string”. That is exactly the error envelope from Chapter 8: {"error": {"title": "must be provided"}}, where the keys are whichever fields failed.
Remember this

Two shapes per resource, not one: what you accept and what you return are different contracts. Merging them is how id becomes settable by a client.

Step 6 — The other thirteen paths

Sixteen paths are needed in total; you have written three. The remaining thirteen — the auth, account, and billing paths — repeat the identical patterns from Steps 2 to 5, with no new grammar at all: /v1/users, /v1/users/activated, /v1/users/password, /v1/users/email, /v1/tokens/authentication, /v1/tokens/activation, /v1/tokens/password-reset, /v1/me, /v1/me/password, /v1/me/email, /v1/billing/checkout, /v1/billing/portal, /v1/billing/plan.

Appendix F (The complete routes.go and OpenAPI spec) prints the whole file. Copy the thirteen remaining path blocks from there into your paths: section, keeping them in the order shown, and copy the three extra schema definitions they reference — Token, TokenInput and Entitlements — into components: schemas:. That is a five-minute paste and it is a fair one: reading thirteen near-identical blocks teaches nothing that Steps 2 through 5 did not.

Important

Do this before Step 11. The test you are about to write checks all sixteen paths. With only the three from this chapter it reports thirteen failures — which is the test working correctly, and confusing if you were not expecting it.

Warning

The original edition printed two paths, said “the full file in the repo continues the identical patterns”, and shipped a test asserting all sixteen. There was no repo. A reader following it exactly got fourteen failing assertions and a red pipeline in Chapter 26. A chapter whose own acceptance test cannot pass teaches the worst possible lesson about tests, which is why the full spec now has a home in Appendix F.

Part B — shipping it

Step 7 — Embed the spec and serve two things

Now the Go. Two handlers and one compiler directive.

// cmd/api/docs.go — new file
package main

import (
    "embed"
    "net/http"
)

//go:embed docs/openapi.yaml
var docsFS embed.FS

const docsPage = `<!doctype html>
<html><head><title>taskd API reference</title>
<meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body>
<script id="api-reference" data-url="/v1/openapi.yaml"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>`

func (app *application) openapiHandler(w http.ResponseWriter, r *http.Request) {
    spec, _ := docsFS.ReadFile("docs/openapi.yaml")
    w.Header().Set("Content-Type", "application/yaml")
    w.Write(spec)
}

func (app *application) docsPageHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Write([]byte(docsPage))
}

What this code says, line by line

  • //go:embed docs/openapi.yaml — a compiler directive. It looks like a comment and is not one: it tells go build to copy that file’s bytes into the compiled program. It must sit on the line immediately above the variable it fills, with no blank line between, and it only works when the embed package is imported. You met this in Chapter 21 for the email templates; this is the same mechanism with one file.
  • var docsFS embed.FS — an embed.FS is a read-only file system that lives inside your binary. Paths inside it are relative to the source file’s folder, which is why the argument to ReadFile is "docs/openapi.yaml" and not a path on your disk.
  • The backticks around docsPage make a raw string literal: everything between them is taken literally, including newlines and the double quotes in the HTML. Without raw strings we would be escaping every quote in that block.
  • The HTML is a web page, and you do not need to understand it to use it. It does two things: an empty <script id="api-reference" data-url="/v1/openapi.yaml"> tag that tells Scalar where the spec lives, and a second <script src=...> that downloads Scalar itself from the CDN. Scalar finds the first tag, fetches the URL, renders the reference.
  • spec, _ := docsFS.ReadFile(...) — the second return value is an error, and _ throws it away. This is defensible here in a way it usually is not: the file is guaranteed present by the compiler, so the only way to fail is to misspell the name inside the string, which shows up the first time you open the page and see an empty response. A stricter version would call app.serverErrorResponse.
  • w.Header().Set("Content-Type", ...) — set headers before writing the body. Once any byte of body goes out, the headers are already on the wire and later changes are ignored.
  • w.Write(spec) — no explicit status code, so Go sends 200 OK.
Important

Setting Content-Type explicitly is not decoration. Chapter 23’s secureHeaders sends X-Content-Type-Options: nosniff, which tells the browser to treat the declared type as final and never re-interpret the bytes — so if the declared type is wrong, the browser refuses to render rather than “helping”. Go will guess a type from the first bytes when you set none; stating it is how you stop depending on a guess.

Step 8 — Wire the two routes

Two lines in routes.go. /docs is a page, not a versioned API resource, so it lives at the root beside /metrics; /v1/openapi.yaml belongs inside the /v1 sub-router.

// cmd/api/routes.go — add these two lines (existing code trimmed to ...)
    // ch. 24 — the browsable reference page. Public by choice.
    r.Get("/docs", app.docsPageHandler)

    r.Route("/v1", func(r chi.Router) {
        // --- public ring ---
        r.Get("/healthcheck", app.healthcheckHandler)

        // ch. 24 — the contract itself. Registered here so the route
        // pattern reads /v1/openapi.yaml.
        r.Get("/openapi.yaml", app.openapiHandler)

        // ... every other /v1 route, unchanged
    })

What this says

  • Both routes are in the public ring — outside requireAuthenticatedUser. Documentation that requires a token to read is documentation nobody reads.
  • Registering /openapi.yaml inside the r.Route("/v1", ...) block produces the URL /v1/openapi.yaml, exactly as if you had written the full path at the root.
Note

The original edition wrote the flat form at the router root: r.Get("/v1/openapi.yaml", app.openapiHandler). Both forms work in chi v5 and both produce the same URL and the same walked pattern. The nested form is used here because mixing a mounted sub-router (r.Route("/v1", ...)) with sibling patterns under the same prefix is the kind of arrangement that behaves differently across router versions — and because keeping one prefix in one place is how a reader finds things. Appendix F prints the whole file in this form.

Step 9 — Look at what you built

Start the server and fetch the spec:

make run/api

In another terminal:

curl -i localhost:4000/v1/openapi.yaml

What you should see: a status line HTTP/1.1 200 OK, then several headers — among them Content-Type: application/yaml, plus the X-Content-Type-Options, X-Frame-Options and Referrer-Policy headers that Chapter 23 added and the X-Request-ID that Chapter 19 added — then the body, starting with openapi: 3.1.0.

Now open http://localhost:4000/docs in a browser. You get a full, browsable, try-it-out reference, from one YAML file and eleven lines of Go — two short handlers, the embed directive and the variable it fills — plus a seven-line HTML string.

Click into POST /v1/tasks. The reference shows the request body shape, the Idempotency-Key header, and all three response codes — and offers an authorization box for your bearer token so you can call the endpoint from the page.

Checkpoint

curl -s localhost:4000/v1/openapi.yaml | head -1 should print exactly openapi: 3.1.0. If it prints nothing, the ReadFile path inside docs.go does not match the embedded name.

Part C — defending it

Step 10 — The anti-rot test

This is the piece that makes hand-writing safe.

// cmd/api/docs_test.go — new file
package main

import (
    "fmt"
    "net/http"
    "strings"
    "testing"

    "github.com/go-chi/chi/v5"
)

var undocumented = map[string]bool{ // internal surface, exempt by decision
    "/metrics": true, "/docs": true, "/v1/openapi.yaml": true,
    "/v1/stripe/webhook": true, // partner-facing, documented by Stripe's dashboard
}

func TestEveryRouteIsDocumented(t *testing.T) {
    spec, err := docsFS.ReadFile("docs/openapi.yaml")
    if err != nil {
        t.Fatal(err)
    }
    app := &application{config: config{}} // routes() needs no live deps
    router, ok := app.routes().(chi.Router)
    if !ok {
        t.Fatal("routes() is not a chi.Router")
    }

    err = chi.Walk(router,
        func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
            route = strings.TrimSuffix(route, "/")
            if undocumented[route] || route == "" {
                return nil
            }
            needle := fmt.Sprintf("\n  %s:", route) // a path key under `paths:`
            if !strings.Contains(string(spec), needle) {
                t.Errorf("route %s %s missing from openapi.yaml", method, route)
            }
            return nil
        })
    if err != nil {
        t.Fatal(err)
    }
}

What this code says, line by line

  • var undocumented = map[string]bool{...} — the exemption list, four routes, each with the reason written next to it. /metrics, /docs and /v1/openapi.yaml are internal surface: they are not part of the product’s contract. /v1/stripe/webhook is partner-facing — Stripe’s own dashboard documents what it sends. Exemptions are a decision, and a decision written down is one a reviewer can argue with.
  • docsFS.ReadFile(...) — the test reads the embedded spec, the same bytes the server serves. It cannot pass against a file you edited but did not rebuild.
  • app := &application{config: config{}} — a zero-value application. routes() reads no database, no cache, no mailer; it only touches app.config.cors.trustedOrigins, which is an empty list here, so the CORS middleware is skipped. Building a router needs no live dependencies, which is why this test runs in milliseconds with nothing else installed.
  • router, ok := app.routes().(chi.Router) — a type assertion. routes() is declared as returning http.Handler, an interface that only promises ServeHTTP. Walking needs the richer chi.Router interface, so we ask: “is the value inside really a chi.Router?” The two-value form gives ok instead of panicking on a no. If someone swaps the router one day, this test fails loudly with a clear message rather than crashing.
  • chi.Walk(router, func(...) error {...})Walk visits every registered method-and-path pair and calls your function once per visit. The function you pass in is a callback.
  • The callback’s four parameters are fixed by chi: method, route, the handler, and ...func(http.Handler) http.Handler — a variadic parameter holding however many middleware wrap that route. We name neither of the last two, writing _ for both, because we only care about the path.
  • strings.TrimSuffix(route, "/") — chi reports routes registered inside r.Route("/tasks", ...) as /v1/tasks/ with a trailing slash. OpenAPI writes /v1/tasks. One line reconciles them.
  • route == "" — a route registered at the root would trim to the empty string and match anything; skip it.
  • fmt.Sprintf("\n %s:", route) — build the text to search for. \n is an escape sequence meaning a newline character; %s is replaced by the route. So for /v1/tasks the needle is a newline, two spaces, /v1/tasks, and a colon — which is precisely how a path key is written under paths: and nowhere else. Requiring the newline and the exact two-space indent stops a mention inside a description: from counting, and the trailing colon stops /v1/tasks from matching /v1/tasks/{id}.
  • t.Errorf(...) records a failure and keeps going; t.Fatal(...) stops the test immediately. We want the full list of missing routes in one run, not the first one.
  • return nil at the end of the callback — chi’s Walk aborts as soon as your callback returns a non-nil error. Returning nil every time means “visit them all”.

Crude by design — a substring check, no YAML parser, no new dependency — and it catches the only failure that matters: shipping a route the contract never mentions. It runs in plain go test, so Chapter 26 (CI/CD)'s audit workflow enforces it from the day it exists, with zero pipeline changes.

Tip

Chi’s route patterns and OpenAPI’s path templates agree on {id} syntax — a small mercy we gratefully exploit. Had chi used Gorilla’s {id:[0-9]+} or Echo’s :id, this test would need a translation step before it could compare anything.

Step 11 — Run it, then break it on purpose

go test ./cmd/api -run TestEveryRouteIsDocumented -v

What you should see:

=== RUN   TestEveryRouteIsDocumented
--- PASS: TestEveryRouteIsDocumented (0.00s)
PASS
ok  	github.com/yourname/taskd/cmd/api	0.011s

The elapsed seconds will differ; everything else should match.

A green test you have never seen fail is a green light you cannot trust. Cut the whole /v1/billing/plan block out of your spec (Exercise 1 prints it, so you can paste it back), save, and run the test again:

=== RUN   TestEveryRouteIsDocumented
    docs_test.go:37: route GET /v1/billing/plan missing from openapi.yaml
--- FAIL: TestEveryRouteIsDocumented (0.00s)
FAIL
FAIL	github.com/yourname/taskd/cmd/api	0.011s
FAIL

(The line number is wherever t.Errorf landed in your copy.) Put the block back and confirm green again. That failure message is the whole feature: a person who adds a route in six months gets that line, in CI, before a customer gets a 404.


7. Checkpoint: prove it works

Four commands. Run them in order.

# 1. The spec is embedded and served.
curl -s localhost:4000/v1/openapi.yaml | head -1

# 2. The docs page is served as HTML. (-D - prints the headers,
#    -o /dev/null throws the HTML body away.)
curl -s -o /dev/null -D - localhost:4000/docs | grep -i '^content-type'

# 3. Every route is documented.
go test ./cmd/api -run TestEveryRouteIsDocumented

# 4. Nothing else broke.
make test

What you should see

  1. openapi: 3.1.0
  2. a line containing text/html; charset=utf-8
  3. an ok line for github.com/yourname/taskd/cmd/api
  4. ok for every package with tests, no FAIL

Then open http://localhost:4000/docs in a browser: a reference page listing your paths on the left, with each operation expandable.

If you got something else

What happened Cause Fix
go build fails with pattern docs/openapi.yaml: no matching files found the YAML is not at cmd/api/docs/openapi.yaml mkdir -p cmd/api/docs and move the file there; the path in the directive is relative to docs.go
Command 1 prints nothing, status 200 the string inside ReadFile does not match the embedded name it must be "docs/openapi.yaml", exactly
Command 3 prints route ... missing from openapi.yaml lines those paths are absent from the spec you have not finished Step 6 — copy the remaining paths from Appendix F
/docs shows a blank white page the browser could not download Scalar from the CDN check your internet connection; the API works regardless, and the raw spec is still at /v1/openapi.yaml
/docs returns the JSON {"error":"the requested resource could not be found"} the route is registered inside the /v1 block, so the real URL is /v1/docs move r.Get("/docs", ...) to the router root, next to /metrics

8. Common mistakes (and the quick fix)

Common mistake

You’ll see: cmd/api/docs.go:9:12: pattern docs/openapi.yaml: no matching files found It means: at compile time, go build looked for that file next to docs.go and there was nothing there. The embed happens during the build, so this is a compile error, not a runtime one. Fix: the file must be cmd/api/docs/openapi.yaml. Check for a typo in the folder name and for an editor that saved it as openapi.yaml.txt.

Common mistake

You’ll see: the docs page loads but a whole section of your spec is missing, or a validator names a line and says it did not find an expected key. It means: YAML indentation. A path indented three spaces instead of two, or a tab character. Fix: two spaces per level, no tabs. Paths sit at two spaces under paths:; operations at four; components: at zero.

The third one looks like a failure and is a success. Read it that way when it appears.

Common mistake

You’ll see: docs_test.go:37: route POST /v1/users missing from openapi.yaml It means: the test is working. Your router has a route your contract does not mention. Fix: add the path to the spec — or, if it genuinely is not part of the public contract, add it to the undocumented map with a comment saying why. Deleting the test is not on the list.

Three more, in table form:

Symptom What it means Fix
The test passes but a client gets a 404 on a documented path you documented a path the router does not have. The test checks router → spec, never spec → router remove the stale path, or add the route
The test passes though the spec only documents get for a path you also POST to the check is per path, not per operation Exercise 3 strengthens it
The docs page renders but the try-it-out button hits the wrong host servers: still says https://api.yourdomain.com edit servers: for local use, or pick the correct server in the renderer’s dropdown
curl -I localhost:4000/docs answers 405 Method Not Allowed -I sends a HEAD request, and chi only has a GET registered for that path use curl -s -o /dev/null -D - <url> to see headers without switching method

9. Pitfalls

Documenting the implementation. The spec says a task has a version; it does not say “bigint identity PK” or mention sqlc. The moment internal vocabulary leaks into the contract, you’ve promised your schema to strangers. A useful test when reviewing a spec line: could you replace Postgres with something else tomorrow and keep this promise? If not, the line describes your implementation.

The example drift nobody tests. Our walk-test catches missing routes, not stale examples or wrong schemas. This is not hypothetical, and the honest way to teach it is with our own file. Every row below is a real disagreement between the spec you wrote in Part A and the code you wrote in earlier chapters — and the test is green anyway:

The spec says The code does Decided in
delete: "204": Deleted. 200 with {"message":"task successfully deleted"} ch. 8
TaskInput.title: {maxLength: 500} the validator rejects anything over 200 characters ch. 8
patch: Send version for optimistic locking version is an unknown field, so 400 ch. 8
Task has 7 fields the handler returns the raw row, so also created_at, updated_at and user_id ch. 8/12
Metadata has 4 fields data.Metadata also emits first_page ch. 9

Five drifts, zero failures. That is the exact shape of the limitation: a substring test buys you coverage, not accuracy. Full contract testing — validating real responses against the spec at runtime, with middleware such as kin-openapi — exists and is the honest next step if the API gets external paying consumers; noted, not built.

Warning

The fourth row is the dangerous one. user_id reaches the client in every task response and appears nowhere in the contract. It is not a secret, but it is a fact about your data model that customers can now build on and you never promised. Undocumented output is undocumented liability.

Forgetting the docs are public. /docs ships unauthenticated by choice — an API courting integrators wants that. If yours shouldn’t, the route moves inside the authenticated ring; make it a decision, not a default. Consider also that a public spec is a map of your attack surface: every path, every parameter, every plan restriction, handed over. That is normally a fair trade for integrator adoption, and it should be a trade you made knowingly.

json:"-" versus the spec. Fields hidden from JSON — token hashes, internal columns — must also be absent from schemas. The struct tag json:"-" from Chapter 11 tells Go’s encoder “never emit this field”; it says nothing to the spec, which is a separate document written by hand. The spec review is a data-exposure review; treat a spec diff with the same eyes as a query diff.

Remember this

A spec change is a customer-facing change. Reviewing one is not proofreading — it is deciding what your company will still owe strangers in two years.


10. Check yourself — quiz

  1. In one sentence, what is an OpenAPI file?
  2. Give one genuine advantage of code-first generation, and one genuine advantage of spec-first authoring.
  3. TestEveryRouteIsDocumented is green. Name two kinds of documentation error it cannot detect.
  4. Why does the test read the spec through docsFS rather than opening cmd/api/docs/openapi.yaml from disk?
  5. Four routes are in the undocumented map. Which are they, and what do the reasons have in common?
  6. What does $ref: "#/components/schemas/Task" mean, and what would break if you deleted the Task entry under components?
  7. The needle is fmt.Sprintf("\n %s:", route). Why the leading newline and two spaces, and why the trailing colon?
  8. security: [] appears on /v1/healthcheck’s get. What does an empty list do there, given that the top of the file already says security: [{token: []}]?
Answers
  1. A standard, machine-readable description of every path, parameter, request body and response an API supports — the contract, written so tools can read it too.

  2. Code-first cannot forget a route, because the docs are derived from the code that serves it. Spec-first describes what you promise rather than what you currently return, so behavioural contracts (404-for-foreign, 409-on-stale-version, the 402 upsell) can be stated at all — a generator has no way to know them.

  3. Any two of: a documented path whose method is wrong or missing (the check is per path, not per operation); a schema that lists the wrong fields; a status code that never occurs; a stale example; a path in the spec that no longer exists in the router. Section 9 lists five real ones in our own file.

  4. Because the embedded copy is what the server serves. A disk-based test could pass against a file that was edited but never rebuilt into the binary — and passing while the deployed artifact is wrong is worse than failing.

  5. /metrics, /docs, /v1/openapi.yaml and /v1/stripe/webhook. The first three are internal or meta surface — machinery, not product. The fourth is partner-facing and documented by Stripe. What they share: none of them is a promise made to a taskd customer.

  6. “The definition at componentsschemasTask in this same file.” Deleting the Task entry leaves the $ref pointing at nothing: a validator reports an unresolved reference and a renderer shows an empty or broken schema. The route-walk test would still pass, since it never parses the YAML.

  7. The newline plus two spaces anchor the match to a path key at the correct indentation, so the route being mentioned inside a description: does not count as documented. The trailing colon stops /v1/tasks from being satisfied by /v1/tasks/{id}:.

  8. It overrides the file-wide default for that one operation, declaring it public. Without it, the default applies and the rendered docs would tell readers that the healthcheck needs a bearer token — which is false, and the kind of small lie that generates support tickets.


11. Practice

Exercise 1 — Watch the guard fire (easy)

Prove the test detects the failure it exists for, and read the message it produces.

Remove /v1/billing/plan from your spec, run the test, restore it, run it again.

Solution

Delete this block from cmd/api/docs/openapi.yaml (keep a copy — you are putting it back):

# cmd/api/docs/openapi.yaml — cut this block out, then paste it back
  /v1/billing/plan:
    get:
      summary: Your current tier and entitlements
      responses:
        "200":
          description: What you are allowed to do.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tier: {type: string, enum: [free, pro, business]}
                  entitlements: {$ref: "#/components/schemas/Entitlements"}

Then:

go test ./cmd/api -run TestEveryRouteIsDocumented -v

You should see one failure line naming that route:

=== RUN   TestEveryRouteIsDocumented
    docs_test.go:37: route GET /v1/billing/plan missing from openapi.yaml
--- FAIL: TestEveryRouteIsDocumented (0.00s)
FAIL

Restore the block and re-run; the test returns to PASS. Note what did not happen: the build still succeeded, the server would still have started, and /v1/billing/plan would still have worked. The only thing that broke is the promise — which is exactly the failure this test was built to make visible.

Exercise 2 — Print the routing table the test walks (medium)

The test looks at a list of routes you have never seen. Print it.

Write a throwaway test in cmd/api that walks the router and prints each visit, run it, then delete the file.

Solution
// cmd/api/walkdump_test.go — temporary; delete after running
package main

import (
    "fmt"
    "net/http"
    "testing"

    "github.com/go-chi/chi/v5"
)

func TestDumpWalk(t *testing.T) {
    app := &application{config: config{}}
    router, _ := app.routes().(chi.Router)
    chi.Walk(router, func(method, route string, _ http.Handler,
        _ ...func(http.Handler) http.Handler) error {
        fmt.Printf("%-7s %s\n", method, route)
        return nil
    })
}
go test ./cmd/api -run TestDumpWalk -v

You should see twenty-three lines, chi’s own ordering:

GET     /docs
GET     /metrics
POST    /v1/billing/checkout
GET     /v1/billing/plan
POST    /v1/billing/portal
GET     /v1/healthcheck
DELETE  /v1/me
PUT     /v1/me/email
PUT     /v1/me/password
GET     /v1/openapi.yaml
POST    /v1/stripe/webhook
GET     /v1/tasks/
POST    /v1/tasks/
DELETE  /v1/tasks/{id}
GET     /v1/tasks/{id}
PATCH   /v1/tasks/{id}
POST    /v1/tokens/activation
POST    /v1/tokens/authentication
POST    /v1/tokens/password-reset
POST    /v1/users
PUT     /v1/users/activated
PUT     /v1/users/email
PUT     /v1/users/password

Three things to notice. /v1/tasks/ carries a trailing slash — that is the line TrimSuffix exists for. Twenty-three visits collapse to twenty distinct paths, because /v1/tasks/{id} appears three times and /v1/tasks/ twice. Four of those twenty are in the exemption map, leaving sixteen checked paths — exactly the number of paths in the spec. Delete the file when you are done; a debug printer left in a test suite is noise every future reader has to step over.

Exercise 3 — Make the test check the method too (harder)

The current test is satisfied by a path key. Document only get: for /v1/tasks and the POST route still passes. Close that hole without adding a YAML parser or any dependency.

Solution

Slice out the block of spec text belonging to a path, then look for the lowercased method as an operation key inside it.

// cmd/api/docs_method_test.go — new file
package main

import (
    "fmt"
    "net/http"
    "strings"
    "testing"

    "github.com/go-chi/chi/v5"
)

// specBlockFor returns the chunk of the spec belonging to one path key:
// everything after "\n  /the/path:" up to the next path key ("  /...")
// or the next top-level key such as "components:".
func specBlockFor(spec, route string) (string, bool) {
    head := fmt.Sprintf("\n  %s:", route)
    start := strings.Index(spec, head)
    if start < 0 {
        return "", false
    }
    var block []string
    for _, line := range strings.Split(spec[start+len(head):], "\n") {
        if line == "" { // blank separator lines belong to the block
            block = append(block, line)
            continue
        }
        if strings.HasPrefix(line, "  /") || line[0] != ' ' {
            break
        }
        block = append(block, line)
    }
    return "\n" + strings.Join(block, "\n"), true
}

func TestEveryRouteDocumentsItsMethod(t *testing.T) {
    specBytes, err := docsFS.ReadFile("docs/openapi.yaml")
    if err != nil {
        t.Fatal(err)
    }
    spec := string(specBytes)

    app := &application{config: config{}}
    router, ok := app.routes().(chi.Router)
    if !ok {
        t.Fatal("routes() is not a chi.Router")
    }

    err = chi.Walk(router,
        func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
            route = strings.TrimSuffix(route, "/")
            if undocumented[route] || route == "" { // reuse the same exemptions
                return nil
            }
            block, found := specBlockFor(spec, route)
            if !found {
                t.Errorf("route %s %s missing from openapi.yaml", method, route)
                return nil
            }
            verb := fmt.Sprintf("\n    %s:", strings.ToLower(method))
            if !strings.Contains(block, verb) {
                t.Errorf("path %s is in the spec but %s is not documented", route, method)
            }
            return nil
        })
    if err != nil {
        t.Fatal(err)
    }
}

Verify both directions:

go test ./cmd/api -run TestEveryRouteDocumentsItsMethod -v

Green against the complete spec. Now delete the four delete: lines from /v1/tasks/{id} and run it again — you should get a line of the form path /v1/tasks/{id} is in the spec but DELETE is not documented, then restore them.

Two notes on the technique. The operation keys sit at four spaces, which is why the needle is "\n get:" and not "get:" — otherwise the word get inside a description: would satisfy it. And undocumented is a package-level variable in docs_test.go, so this second test file reuses the same exemption list; the two tests can never disagree about what is exempt.


12. FAQ

Why write this by hand? Surely a tool can do it. A tool can produce a description of your code. It cannot produce a description of your promises, and those are what customers integrate against. Nothing in your Go source says “another user’s task answers 404 rather than 403, deliberately, so IDs cannot be probed.” The spec says it because a person decided to say it. Twenty routes is an afternoon; the afternoon is also a design review.

Who actually reads an OpenAPI file? Rarely a human, directly. Renderers read it and become your documentation site. Code generators read it and produce typed clients. Postman and Insomnia read it and populate a request collection. Gateways read it and validate traffic against it. Your customers’ AI coding assistants read it. You write one file; the ecosystem does the rest, which is the entire return on writing it.

Can I generate a client library from it? Yes — openapi-generator and oapi-codegen will emit a typed client in most languages from this file, and Appendix F names both. That capability is the payoff for the format being data rather than prose. It is also the strongest argument for keeping the spec accurate: someone’s compiler now depends on it.

Does the test check that my responses actually match the spec? No, and this is worth being blunt about. It checks that every route in your router has a path key in your spec. It does not check methods, status codes, field names, or types — Section 9 lists five real mismatches in this very file that it lets through. The honest next step, when you have external paying consumers, is contract testing: middleware such as kin-openapi validating real requests and responses against the spec at runtime. We name it; we do not build it.

Should the docs be public? Ours are, by choice, because an API courting integrators wants a URL it can put in an email. The cost is that your full path list and parameter set are public. For an internal API, move /docs inside the authenticated ring — one line, since the route is already registered like any other. The point is that it should be a decision either way; the wrong version of this is a company that never noticed.

What happens when my API changes? Additive changes — a new optional field, a new endpoint — go into the spec in the same commit as the code, and the route-walk test enforces the endpoint half of that. Breaking changes need the /v2 prefix that Chapter 2 reserved for exactly this: old clients keep the contract they built against, new clients get the new one, and both are documented simultaneously. Silently changing what /v1 returns is the thing the whole chapter exists to make socially difficult.


13. Where we are

The gap list is closed: accounts verify, recover, and leave cleanly; retries are safe; the edge sends the right headers to the right origins; and the API carries its own contract. Nothing between here and production is a feature anymore — it’s packaging. Onward to the container.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go   server.go   config.go   db.go   background.go
│   ├── routes.go          # UPDATED: /docs and /v1/openapi.yaml
│   ├── docs.go            # NEW: embed + two handlers
│   ├── docs_test.go       # NEW: the route-walk guard
│   ├── docs_method_test.go  # NEW, if you did Exercise 3
│   ├── docs/
│   │   └── openapi.yaml   # NEW: the contract, 16 paths
│   ├── middleware.go   helpers.go   errors.go   context.go
│   ├── healthcheck.go   metrics.go   accounts.go   webhooks.go
│   ├── users.go   tokens.go   tasks.go   billing.go   entitlements.go
│   ├── tasks_test.go   testutils_test.go
├── internal/
│   ├── data/   db/   cache/   validator/
│   ├── mailer/
│   │   └── templates/
├── migrations/            # 000001..000007 up + down
├── sql/queries/
├── Makefile   sqlc.yaml   config.toml   docker-compose.yml
└── go.mod   go.sum

What works end to end: everything the product does — register, activate, log in, create and list and update and delete tasks, pay, be limited by your plan, recover your password, close your account — plus a published contract at /v1/openapi.yaml, a reference site at /docs, and a test that refuses to let a new route ship undocumented.

What is still fake or missing:

  • The contract is not enforced at runtime. Five known mismatches between spec and behaviour are listed in Section 9 and nothing catches them automatically.
  • /docs needs the internet, because the renderer comes from a CDN. Vendoring Scalar into the embed is a one-file change, unbuilt until someone needs it.
  • The service is still go run on your laptop. Chapter 25 (Docker: a 15 MB production image) packs it into a container, and Chapter 26 (CI/CD) makes make audit — which includes this chapter’s test — run on every push.

For your notes

Copy these into learnings/ch24.md in your own words:

  1. Generated docs describe what the code is; a contract describes what the API promises. The difference is where customers get hurt, and only a person can write the second one.
  2. Hand-written documentation drifts. The answer is not more discipline; it is a test that fails the build. Drift becomes a red build, not a support ticket.
  3. A crude check that runs is worth more than an exhaustive one that doesn’t. A substring search with no dependencies caught the only failure that matters — and be equally clear-eyed about the five it lets through.
  4. Two shapes per resource: what you accept (TaskInput) and what you return (Task). Merging them is how a client ends up able to set an id.
  5. Reviewing a spec diff is a data-exposure review. Anything your handler returns and your spec omits is a liability you never agreed to.

Chapter 25 — Docker: a 15 MB production image

Every dependency taskd has already runs in a box: Postgres, DragonflyDB, Mailpit, Prometheus. taskd itself does not. You start it with make run/api, on your laptop, using your Go toolchain, your config.toml, your operating system’s certificates. This chapter puts the program into the same kind of box as everything it talks to — one sealed file you can hand to a server, to a colleague, or to the robot we build in Chapter 26 (CI/CD: the robot that says no), and get identical behaviour. Then it starts the whole system — database, cache, migrations, API, metrics — with one command, in the right order, every time.

What you’ll be able to do by the end

  • Build a Docker image that contains your compiled program and almost nothing else, and read its size with docker images.
  • Explain why that image has no shell, no package manager and no Go compiler inside it, and what each absence buys you.
  • Stamp the exact git commit into the binary, so GET /v1/healthcheck answers “which code is this?” without anybody guessing.
  • Bring up Postgres, DragonflyDB, the migrations, taskd, Prometheus and a fake inbox with docker compose up, with the migrations guaranteed to finish before the API starts.
  • Stop the stack without cutting an in-flight request in half.

Time: ~55 minutes reading, ~30 minutes typing. The first image build also downloads a few hundred megabytes of base images; that part is unattended.

You need before starting: everything through Chapter 24 (Documenting the API: OpenAPI without the machinery). Prove it still works:

docker compose up -d
make run/api

then, in a second terminal:

curl -s localhost:4000/v1/healthcheck

You should see one line of JSON:

{"database":"up","environment":"development","status":"available","version":"0.1.0"}

That "version":"0.1.0" is a lie we are about to stop telling.


1. The problem, in plain words

You have a program that runs on your machine. Deploying it means making it run on a machine that is not yours. Those two sentences hide an enormous amount of work, because your laptop is quietly providing a great many things the program never asks for out loud:

  • a Go toolchain, at a specific version;
  • the source code, and every module it imports, already downloaded;
  • a config.toml sitting in the current directory;
  • a set of CA certificates — the list of authorities your machine trusts — without which every HTTPS call to Stripe fails;
  • a time-zone database;
  • a user account with a home directory and a $PATH;
  • something listening on localhost:5432 and localhost:6379.

Take the program to a bare server and every one of those becomes a question. The traditional answers are all unsatisfying.

“Install Go on the server and run git pull && go build.” Now your production machine is also a development machine, its Go version is a variable you have to manage, and a build failure at 2 a.m. happens in production, on the box that is meant to be serving customers.

“Build the binary here and scp it over.” Better — the compiler stays home. But a Go binary built the default way still expects the system’s C library to be present and at a compatible version, and the server still needs the certificates, the config file, a user to run as, and the right directory layout. You have moved the problem, not solved it.

“Put it in a container.” A container is the box: the program plus everything it needs to run, sealed together, so that “it works on my machine” becomes a statement about the box rather than about your machine. This is the answer the industry settled on, and it is the answer we take.

Why this exists

A deployable artefact should be one file whose contents cannot drift. The moment deployment involves “and then install X on the server”, you have a machine whose exact state nobody knows, and that machine will eventually differ from every other machine in a way that takes a day to find. The container exists to make the answer to “what is running?” short enough to fit in a sentence.

There are two further problems this chapter has to solve, and they are both about order.

Startup ordering. The stack has five moving parts. Postgres starting and Postgres accepting connections are seconds apart, and a program that connects into that gap dies at boot. Worse, somebody has to apply the migrations, and that somebody must run after the database is up and before the API serves its first request.

Identity. When a customer reports something strange, the first question is always “which version is running?” If the answer is a hardcoded "0.1.0" that nobody has changed since Chapter 2, the question has no answer and the investigation starts by guessing.


2. New words in this chapter

  • container — a running, isolated copy of a packaged program, with its own filesystem and its own view of the network.
  • image — the frozen template a container is started from. The mould; the container is the casting.
  • layer — an image is built as a stack of steps, each one a recorded set of filesystem changes. Layers are how images are cached and shared.
  • build cache — Docker reuses a layer instead of re-running its instruction when the instruction and its inputs are unchanged.
  • base image — the image your build starts FROM. Everything you do is stacked on top of it.
  • multi-stage build — a Dockerfile with a build stage containing the compiler and a final stage containing only the finished binary.
  • distroless — a minimal base image with certificates and time-zone data but no shell and no package manager, so a compromised container has nothing to work with.
  • scratch — a completely empty base image. Too empty for us, because HTTPS calls to Stripe need CA certificates.
  • CA certificate — the list of certificate authorities a program trusts. Without it every HTTPS connection fails, whatever the other end does.
  • tzdata — the world’s time-zone database, needed for correct local-time handling.
  • static linking / CGO_ENABLED=0 — building a binary with no external system-library dependencies, which is what allows a nearly-empty base image.
  • libc / musl vs glibc — the two common C standard libraries on Linux. Mixing them is a classic Alpine build failure, avoided entirely by pure-Go dependencies.
  • linker — the last stage of compilation, which stitches the compiled pieces into one executable file.
  • -ldflags / -s -w / -X — build-time flags that strip debug tables and stamp a value (the git commit) into a variable.
  • build arg — a variable supplied to a build with --build-arg, readable inside the Dockerfile via ARG.
  • build context — the directory sent to Docker when a build starts. COPY can only see files inside it.
  • .dockerignore — a list of files never sent to the build, keeping builds fast and secrets out of images.
  • ENTRYPOINT — the command a container runs when it starts.
  • non-root / UID 65532 — running the container as an unprivileged user, so a break-in gains little. A UID is the numeric identity Linux uses for a user.
  • registry — a server that stores images, the way GitHub stores repositories.
  • tag — the label after the colon in postgres:17-alpine. An immutable tag (a commit SHA) means “what is running?” has exactly one answer.
  • one-shot migration container — a container whose whole job is to run the migrations once and exit before the app starts.
  • service dependency — a Compose rule saying this service starts only after that one reaches a named condition.
  • grace period — how long Docker waits after asking a container to stop before killing it.
  • docker history — a command that shows every layer of an image, which is why build-time secrets are exposed forever.

3. The goal

A multi-stage Dockerfile producing a distroless, non-root image; a Compose file that runs the entire system — Postgres, DragonflyDB, migrations, taskd, Prometheus — with correct startup ordering via healthchecks; and version information baked into the binary at build time.

Stated as something you can observe: at the end of this chapter, docker compose up --build on a clean machine gives you a working taskd, and curl -s localhost:4000/v1/healthcheck reports the git commit that produced it.


4. The thinking

4.1 What a container actually is

You have been using containers since Chapter 5 (PostgreSQL and migrations) without anyone defining one. Time to fix that.

A container is a normal process on a normal Linux machine, running with a set of blinkers on. The kernel gives it its own view of the filesystem, its own network interface, its own list of visible processes, and a cap on how much CPU and memory it may use. Inside the blinkers, the program believes it has a machine to itself. Outside them, it is one process among many.

That is the important difference from a virtual machine, which simulates a whole computer and boots a whole second operating system inside it. A container boots nothing. It starts as fast as a program starts, because it is a program starting.

Think of it like

A shipping container does not change what is inside it. It standardises the outside: the same corner fittings, the same crane, the same lorry, whatever the cargo. Docker standardises the outside of your program so that every machine can pick it up the same way.

Two words that people use interchangeably and should not:

  • An image is a file on disk (really a stack of files). It is inert. postgres:17-alpine is an image.
  • A container is an image that has been started. You can start twenty containers from one image; they share the image and each gets its own writable scratch space on top.

The image is a stack of layers. Each instruction in a Dockerfile produces one layer holding the files that instruction changed. Layers matter for two reasons: they are shared (ten images built FROM golang:1.24-alpine store that base once), and they are cached, which is the subject of the next section.

Images live in a registry — a server that stores them, the way GitHub stores git repositories. postgres:17-alpine means “the image called postgres, tagged 17-alpine, from the default registry, Docker Hub”. gcr.io/distroless/static-debian12:nonroot names its registry explicitly.

4.2 Decision 1 — two kitchens, one dish

Building a Go program needs the Go toolchain: the compiler, the linker, the standard library source, and every module the program imports. The official golang:1.24-alpine image carries all of that, and it weighs roughly 250 MB.

Running a Go program needs none of it. Go compiles to a single self-contained executable; once the build is done, the compiler is dead weight — dead weight you would otherwise download onto every server, on every deploy, forever.

A multi-stage build solves this in the bluntest possible way: write two builds in one file. The first stage has the toolchain and produces the binary. The second stage starts FROM a different, nearly-empty image, and copies exactly one file across. Everything else from the first stage is thrown away when the build finishes.

Remember this

In a multi-stage build, nothing crosses from one stage to the next unless you name it in a COPY --from=. The compiler, the source code and the module cache stay behind because you never asked for them.

The layer-ordering decision inside stage 1. Docker keeps a build cache: each layer is recorded against the instruction that made it and that instruction’s inputs. If neither the instruction nor its inputs changed, Docker reuses the cached layer and skips the work. Once one layer misses, every layer after it is rebuilt too — the cache is a prefix, not a set.

That single fact turns the order of two COPY lines into a performance decision:

# Dockerfile — the four lines of stage 1 that matter, in order
COPY go.mod go.sum ./     # changes rarely: only when you add a dependency
RUN go mod download       # the slow part — cached until the line above changes
COPY . .                  # changes constantly: every edit you make
RUN go build ...          # re-runs on every edit, which is unavoidable

Edit one line of Go and the first two layers are cache hits; only the copy and the compile re-run. Put COPY . . first and every code edit invalidates the module download too. In the original author’s words, this ordering “is the difference between 4 s and 90 s rebuilds, i.e., between a CI you like and one you resent”. Exercise 1 has you measure both on your own machine.

4.3 Decision 2 — the base image, chosen by elimination

Stage 2 has to start from something. Three candidates, and the argument against each is concrete.

Base image Size What you get Why not
scratch 0 bytes Absolutely nothing No CA certificates, so every HTTPS call to Stripe fails; no tzdata; no non-root user to run as
alpine ~8 MB A small but complete Linux: shell, package manager, coreutils The shell is convenient for you and equally convenient for an attacker who gets a foothold
gcr.io/distroless/static-debian12:nonroot ~2 MB CA certificates, tzdata, a nonroot user at UID 65532 Nothing to debug with inside the container — answered below

Chosen: distroless. It is the smallest image that contains the things a Go network service actually needs and none of the things an intruder needs.

New word

CA certificate — when your program connects to https://api.stripe.com, Stripe presents a certificate signed by a certificate authority. Your program checks that signature against a local list of authorities it trusts. scratch has no such list, so the check cannot pass, and the failure looks like a Stripe problem when it is a packaging problem.

The obvious objection to distroless is “there is no shell, so I cannot docker exec into it when something goes wrong”. Two answers. The practical one: docker debug, and Kubernetes’ ephemeral containers, attach a toolbox to a running container without the toolbox living in your image. The honest one: you debug production with the telemetry built in Chapter 18 (Prometheus: metrics that answer questions) and Chapter 19 (Logging that pays rent), not by shelling into a box. If your only debugging tool is a shell in production, the metrics and logs are the thing to fix.

Warning

A shell inside a running container is a gift to anyone who finds a way to run code in your process. With no shell, no curl, no wget and no package manager, an intruder who lands inside a distroless container has one binary to work with: yours.

4.4 Decision 3 — static linking

Linking is the last step of compilation: the compiler produces pieces, the linker stitches them into one executable. It can do that in two ways.

Dynamic linking leaves holes: “call getaddrinfo, which will be provided by the system’s C library when I run”. The binary is smaller, and it stops working the moment it lands on a machine whose C library is missing or incompatible.

Static linking copies everything the program needs into the executable. Bigger file, zero expectations of its surroundings.

Go builds statically by default unless your program uses cgo — Go’s bridge for calling C code. The standard library uses cgo on some platforms for DNS lookups and user lookups. Setting CGO_ENABLED=0 turns that bridge off and forces the pure-Go implementations, which produces a fully self-contained binary. That is precisely what makes a distroless or scratch base possible at all: there is no C library in there to link against.

This costs us nothing, because every dependency in taskd is pure Go. The pgx driver from Chapter 6 (Connecting with pgx/v5) is native Go rather than a wrapper around the C client library — a decision made three chapters before anyone mentioned Docker, quietly paying off here.

New word

libc, musl and glibc — “libc” is the C standard library, the layer between a program and the kernel. Most Linux distributions ship glibc; Alpine ships the smaller musl. A binary dynamically linked against one does not run against the other. CGO_ENABLED=0 sidesteps the entire argument by needing neither.

4.5 Who runs the migrations, and when

The schema has to be up to date before the code that assumes it serves a request. Three ways to arrange that:

Path How it works Verdict
(a) The app migrates at boot main() applies pending migrations before serving Couples “start the app” to “change the schema”. Run three replicas and they race each other into the same ALTER TABLE
(b) A human runs them Someone SSHes in and types migrate up Works until 2 a.m., a deploy under pressure, or a holiday
© A one-shot migration container A container whose only job is to run the migrations and exit Chosen. In Compose it is a service the API waits on; in Chapter 26 it becomes a pipeline step

Path © keeps “change the schema” and “run the app” as two separate operations, each of which can be retried on its own. That separation is worth very little on the day everything works, and worth everything on the day a migration fails: the API never starts at all, the old version keeps serving, and you have a failed job to look at rather than a crash loop to decode.

Think of it like

The migration container is the stagehand who sets the scene and walks off before the curtain goes up. It is not part of the performance; the performance cannot begin without it.

4.6 Config in containers: the interface was built in Chapter 3

No configuration file is mounted into the running container. The image ships the committed config.toml as documented defaults, and every production value arrives as an environment variable, through the TASKD_* override path built in Chapter 3 (Configuration and logging, the Nadh way) — the one where TASKD_DB__DSN becomes the key db.dsn, double underscore standing in for the dot that shells will not allow in a variable name.

That path is the container’s interface. Which means the Compose file we write in Step 5 becomes living documentation of every knob the service has: read it and you know what can be changed without a rebuild. This is what the 12-factor rule — “configuration lives in the environment, not in the code” — buys you, collected in one place at last.


5. A picture of it

The two stages, and the single file that crosses between them

The left box exists only while the build runs. The right box is what you deploy.

  STAGE 1  "build"                        STAGE 2  "ship"
  FROM golang:1.24-alpine                 FROM distroless/static:nonroot
  ┌────────────────────────────┐          ┌──────────────────────────────┐
  │ Go compiler + linker       │          │ CA certificates              │
  │ downloaded modules         │          │ time-zone data               │
  │ your whole source tree     │          │ /etc/passwd with "nonroot"   │
  │ build cache                │          │                              │
  │                            │  COPY    │                              │
  │  /bin/api  ────────────────┼──────────▶  /bin/api      ~20 MB        │
  │            (the one file)  │ --from=  │   /config.toml  ~1 KB        │
  └────────────────────────────┘  build   └──────────────────────────────┘
   all of this is discarded                  this is the whole image
   when the build finishes
  1. Stage 1 downloads modules and compiles. It is heavy and it is temporary.
  2. COPY --from=build names the only thing allowed to cross the gap.
  3. Stage 2 starts from an image that has never heard of Go.
  4. The result is a binary, a config file, and the certificates needed to speak HTTPS.

Why the order of two COPY lines is a decision

Same Dockerfile, same edit — one line changed in cmd/api/tasks.go — and two different rebuild costs, depending only on where the dependency copy sits.

  ORDER WE USE                           NAIVE ORDER
  ┌───────────────────────────┐          ┌───────────────────────────┐
  │ FROM golang:1.24-alpine   │ cached   │ FROM golang:1.24-alpine   │ cached
  │ WORKDIR /src              │ cached   │ WORKDIR /src              │ cached
  │ COPY go.mod go.sum ./     │ cached   │ COPY . .                  │ MISS
  │ RUN go mod download       │ cached   │ RUN go mod download       │ MISS
  │ COPY . .                  │ MISS     │ RUN go build ...          │ MISS
  │ RUN go build ...          │ MISS     │                           │
  └───────────────────────────┘          └───────────────────────────┘
   re-runs: the compile only              re-runs: the whole internet,
                                          then the compile

Once a layer misses, everything below it misses too. That is why the rarely-changing input goes above the constantly-changing one.

What has to be true before the API starts

The Compose file encodes this graph. Nothing here is a sleep 5 and a hope.

      ┌────────────┐                      ┌────────────┐
      │     db     │                      │   cache    │
      │ postgres17 │                      │ dragonfly  │
      └─────┬──────┘                      └─────┬──────┘
            │ healthy:                          │ healthy:
            │ pg_isready answers                │ redis-cli ping answers
            ▼                                   │
      ┌────────────┐                            │
      │  migrate   │ applies every pending      │
      │ (one-shot) │ .up.sql, then exits 0      │
      └─────┬──────┘                            │
            │ completed successfully            │
            ▼                                   │
      ┌─────────────────────────────────────────┴───┐
      │                    api                      │
      │   starts only when all three conditions     │
      │   above are satisfied                       │
      └─────────────────────────────────────────────┘
  1. db is not “started”, it is healthy — Postgres has answered pg_isready.
  2. migrate runs only then, applies the migrations, and exits.
  3. api waits for db healthy, cache healthy, and migrate exited with status 0.
  4. Remove any one of those conditions and you get the classic Compose bug: a container that dies at boot roughly one start in five, on a fast machine, and never on yours.

6. The steps

There are three parts: the binary learns its own version, the image gets built, and the system gets wired together.

Part A — the binary knows which commit it is

Step 1 — Stamp the version in at build time

Since Chapter 2 (The skeleton: a server that answers) main.go has carried a hardcoded version constant. It has never once been updated, which is what always happens to hardcoded version constants. We replace it with a variable the linker fills in.

Find this line in cmd/api/main.go and delete it:

// cmd/api/main.go — DELETE this line
const version = "0.1.0"

Put this in its place:

// cmd/api/main.go — replaces the const version line
var version = "dev" // overridden by -ldflags at build time

What this code says, line by line

  • const became var, and that change is load-bearing. A Go constant is baked into the machine code wherever it is used; there is no variable in the finished binary to overwrite. A package-level variable does occupy a slot in the binary, and the linker can write into that slot. The stamping technique below only works on a var of type string.
  • "dev" is the value you get when nobody stamps anything — which is what go run ./cmd/api does. An unstamped build honestly announcing itself as dev is the correct behaviour.
Common mistake

You’ll see: version redeclared in this block, with the compiler pointing at both lines. It means: you added the var without deleting the const. A package cannot have two things with the same name. Fix: delete the const version = "0.1.0" line. If instead the healthcheck keeps reporting "0.1.0" after a stamped build, the const is still there and the linker had no variable to write into; if it keeps reporting "dev", check the flag reads -X main.version=... — the full package path, with no spaces around the =.

Now teach the Makefile to build a production binary. Add these lines to the MakefileGIT_DESC goes near the top with the other variables, the target goes at the bottom with the others.

# Makefile — add the variable near the top and the target at the bottom
GIT_DESC = $(shell git describe --always --dirty --tags 2>/dev/null || echo dev)

## build/api: build a static production binary with version stamp
.PHONY: build/api
build/api:
	CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=${GIT_DESC}' \
		-o bin/api ./cmd/api
Warning

Every command line inside a Make target must begin with a real TAB, not spaces. If you paste from a PDF you will get spaces, and make will answer with a missing separator error naming the line. This is the third time the book has warned you, and it will not be the last time the warning catches somebody.

What this code says, line by line

  • GIT_DESC = $(shell ...)$(shell X) runs X in a shell and substitutes its output. So GIT_DESC becomes whatever git describe prints.
  • git describe --always --dirty --tags — asks git “where am I?”. With a tag on the current commit you get the tag, e.g. v1.2.0. Without one, --always falls back to an abbreviated commit hash like a1b2c3d. --dirty appends -dirty when your working tree has uncommitted changes — so a binary built from unsaved work says so out loud.
  • 2>/dev/null || echo dev — if git fails (no repository yet, no commits yet) its complaint is discarded and the value becomes dev. Without this, a fresh clone with no commits produces an empty version string.
  • CGO_ENABLED=0 — the static-linking switch from section 4.4, set for this one command.
  • -ldflags='...' — flags passed to the linker, the tool that assembles the final executable.
  • -s -w-s drops the symbol table, -w drops the DWARF debugging information. The program behaves identically; the file gets meaningfully smaller. On this codebase the stripped linux/amd64 binary measures about 20 MB against about 29 MB unstripped — roughly nine megabytes you would otherwise copy to every server on every deploy.
  • -X main.version=${GIT_DESC}-X importpath.name=value writes value into the string variable name in package importpath, at link time. This is the whole trick.
  • -o bin/api ./cmd/api — compile the package in ./cmd/api, write the executable to bin/api. (bin/ is already in .gitignore from Chapter 2.)

What you should see

make build/api
ls -l bin/api

A file appears under bin/, tens of megabytes in size. Run it against the stack you already have up:

./bin/api -config config.toml

The logs are the same ones make run/api prints, ending in msg="starting server". In another terminal:

curl -s localhost:4000/v1/healthcheck

The version field is no longer 0.1.0; it is whatever git describe said — a tag, or a short commit hash, possibly with -dirty on the end. Stop it with Ctrl-C.

Remember this

The first question in every incident is “which version is running?” You have now pre-answered it, in the one endpoint every monitor already polls.


Part B — the image

Step 2 — Write the Dockerfile

Create a file called Dockerfile (no extension) in the project root, next to go.mod. This is the recipe Docker follows to build the image.

# Dockerfile

# ---- STAGE 1: build. Has the whole Go toolchain; none of it ships. ----
FROM golang:1.24-alpine AS build
WORKDIR /src

# Dependency layer FIRST: this layer is cached until go.mod/go.sum
# change, so routine code edits skip the slow module download entirely.
COPY go.mod go.sum ./
RUN go mod download

# Now the source (invalidated on every edit — but the layer above isn't).
COPY . .
ARG VERSION=dev
# CGO_ENABLED=0 -> fully static binary (no libc needed, distroless-ready)
# -s -w         -> strip symbol tables: smaller, same behavior
# -X            -> stamp the git SHA into main.version at link time
RUN CGO_ENABLED=0 go build \
    -ldflags="-s -w -X main.version=${VERSION}" \
    -o /bin/api ./cmd/api

# ---- STAGE 2: ship. Starts FROM a different, nearly-empty image; ----
# ---- only what we explicitly COPY crosses over from the build.    ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /bin/api /bin/api
COPY config.toml /config.toml       # dev defaults; real values arrive via env
EXPOSE 4000                          # documentation for humans and tooling
USER nonroot                         # uid 65532 — never root in prod
ENTRYPOINT ["/bin/api", "-config", "/config.toml"]   # the ch. 3 flag, repaid

What stage 1 says, line by line

  • FROM golang:1.24-alpine AS build — every stage begins by naming the image it builds on. golang is the official Go image, 1.24 pins the Go version so an upstream release cannot change your build silently, and alpine is the small-Linux variant. AS build gives the stage a name so a later stage can refer to it.
  • WORKDIR /src — sets the directory that the following instructions run in, creating it if needed. Like cd, except it persists for the rest of the stage.
  • COPY go.mod go.sum ./ — copies two files from your project into /src. The source of a COPY is always relative to the build context — the directory you point docker build at. Docker cannot see files outside it, which is why there is no way to COPY /etc/passwd from your laptop.
  • RUN go mod download — runs a command inside the half-built image; whatever it changes becomes a new layer. Here it downloads every module listed in go.sum into the image’s module cache. This is the slow instruction, and it is deliberately above the source copy.
  • COPY . . — copies everything else in the build context into /src, minus whatever .dockerignore excludes (Step 3).
  • ARG VERSION=dev — declares a variable that can be supplied at build time with --build-arg VERSION=..., defaulting to dev. ARG values exist only during the build; they are not environment variables in the final container.
  • RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o /bin/api ./cmd/api — the same command the Makefile target runs, with ${VERSION} coming from the ARG. The output path /bin/api is inside this stage’s filesystem.

What stage 2 says, line by line

  • FROM gcr.io/distroless/static-debian12:nonroot — a second FROM starts a new stage from scratch. Everything stage 1 built is gone unless copied. gcr.io is Google’s registry; distroless/static-debian12 is a Debian-derived image containing CA certificates, tzdata and a user database, with no shell and no package manager; :nonroot is the variant whose default user is not root.
  • COPY --from=build /bin/api /bin/api — the one bridge between the stages. --from=build says “take this from the stage named build, not from my laptop”.
  • COPY config.toml /config.toml — the committed development defaults, shipped as documentation of every setting. It contains nothing sensitive, by the Chapter 3 rule that secrets never enter the file. Production values arrive as TASKD_* environment variables and win.
  • EXPOSE 4000 — pure documentation. It does not publish anything; it records the port this image listens on, for humans and for tools that read image metadata. Publishing happens at run time with -p or Compose’s ports:.
  • USER nonroot — every process in the container runs as UID 65532 instead of root. If someone finds a way to execute code inside the container, they arrive as a user who owns nothing and can install nothing.
  • ENTRYPOINT ["/bin/api", "-config", "/config.toml"] — what runs when a container starts. The -config flag was added in Chapter 3 with the note that Docker would need it; here it is, being needed.
New word

ENTRYPOINT exec form — writing it as a JSON array runs the binary directly. Writing it as a bare string would run it through a shell, and there is no shell in this image. The array form has a second benefit that matters enormously here: your program becomes process number 1 in the container and receives SIGTERM directly. Through a shell it would not, and the graceful shutdown built in Chapter 4 (A server that dies well) would never fire.

Note

Notice what we did not copy: no email templates, no OpenAPI spec. Chapter 21 embedded the mail templates into the binary with //go:embed, and Chapter 24 did the same with openapi.yaml. They are already inside /bin/api. Two chapters of “why bother embedding it?” collect their payment on this line.


Step 3 — Write .dockerignore

When you run docker build ., Docker first packs up the entire directory and sends it to the Docker engine. That package is the build context. Anything in it can end up in an image; all of it costs time to transfer.

Create .dockerignore in the project root:

bin/
.git/
*.md
.envrc

What each line does

  • bin/ — your locally built binaries. Without this line, COPY . . would haul a twenty-megabyte file into the build that nothing uses — and worse, a binary you built for macOS is a confusing thing to find sitting inside a Linux image.
  • .git/ — the entire history of the repository, often the largest thing in the folder. The build does not need it: the version arrives as a build argument, not from git.
  • *.md — Markdown files at the top of the project (README and friends). Note that * does not cross directory separators, so nested Markdown would need **/*.md.
  • .envrc — the machine-local file from Chapter 5 holding your database URL. This is the line that matters most: it is gitignored precisely because it holds environment-specific values, and without this line COPY . . would place it inside an image you might push to a public registry.
Warning

“Images leak” means this: every file you copy into an image is readable by anyone who can pull that image, forever, even if a later instruction deletes it — because the deletion is a new layer on top, not an erasure of the old one. docker history and a few commands recover it.


Step 4 — Build the image and run it

Three commands. Read them before running them.

docker build -t taskd:dev --build-arg VERSION=$(git describe --always) .
docker images taskd:dev   # ~15–20 MB
docker run --rm -p 4000:4000 \
  -e TASKD_DB__DSN='postgres://taskd:pa55word@host.docker.internal:5432/taskd?sslmode=disable' \
  -e TASKD_CACHE__ADDR='host.docker.internal:6379' \
  taskd:dev

What these commands say

  • docker build — run the Dockerfile.
  • -t taskd:devtag the resulting image taskd, version dev. Without a tag you get an image identified only by a hash, which you then have to copy and paste.
  • --build-arg VERSION=$(git describe --always) — supplies the ARG VERSION in the Dockerfile. $(...) runs the command inside and substitutes its output, the same substitution the Makefile does with $(shell ...).
  • The trailing . is not punctuation. It is the build context: the directory Docker packs up and sends. . means “this directory”. It is the most commonly misread character in Docker.
  • docker images taskd:dev — list matching images. Columns: REPOSITORY, TAG, IMAGE ID, CREATED, SIZE.
  • docker run --rm — start a container and delete it when it exits, so experiments do not accumulate.
  • -p 4000:4000 — publish container port 4000 as port 4000 on your machine. Left is your machine, right is inside the container, exactly as in the Compose files since Chapter 5.
  • -e TASKD_DB__DSN='...' — set an environment variable inside the container. This is the Chapter 3 override path being used for real, for the first time.
  • host.docker.internal — inside a container, localhost means the container itself. Your Postgres is not in there. This special hostname means “the machine running Docker”, which is where your docker compose Postgres publishes its port.

What you should see

The build prints one step per instruction. The first run downloads two base images and every Go module, and takes minutes; the second run is dramatically faster, because of the layer cache.

docker images taskd:dev prints one row. The SIZE column is what this chapter’s title is about.

Note

The original’s figure is 15–20 MB. Your number depends on the Go version and the dependency list: with taskd’s current dependencies a stripped linux/amd64 binary measures about 20 MB, and the distroless base adds roughly 2 MB, so expect a number in the low twenties. The point is the order of magnitude — compare it with the ~250 MB of golang:1.24-alpine you would have shipped without stage 2, and with a typical ubuntu-based image at 80 MB before your program is even added.

The docker run command prints taskd’s own startup logs in text form (the image ships env = "development" in config.toml, and we have not overridden it here), ending in a line whose msg is starting server. Then, from another terminal:

curl -s localhost:4000/v1/healthcheck

You get the healthcheck JSON with "database":"up" and the version field carrying whatever git describe --always printed. Stop the container with Ctrl-C.

Common mistake

You’ll see: exec /bin/api: no such file or directory — and the file is right there. It means: the binary is dynamically linked and the base image has no dynamic loader to run it. The kernel’s error is about the loader it cannot find, not about your binary, which is why this message wastes so many afternoons. Fix: make sure the build line still says CGO_ENABLED=0.

Common mistake

You’ll see: exec /bin/api: exec format error. It means: the image was built for one CPU architecture and is being run on another — most often built on an Apple Silicon Mac (arm64) and run on an ordinary cloud server (amd64). Fix: build for the target: docker build --platform linux/amd64 -t taskd:dev .. Chapter 26’s pipeline builds on Linux runners, which sidesteps this for real deployments.

Two more failures worth naming before they happen:

Common mistake

You’ll see: the container logs msg="cannot connect to database" with an error containing dial tcp 127.0.0.1:5432: connect: connection refused, then exits with status 1. It means: something inside the container tried to reach localhost, and inside a container localhost is the container. Either you left the DSN pointing at localhost, or Postgres is not publishing 5432 to your host. Fix: use host.docker.internal as shown, and check docker compose ps shows db as healthy. On Linux without Docker Desktop that hostname does not exist by default; add --add-host=host.docker.internal:host-gateway to the docker run command.

Common mistake

You’ll see: fatal: bad revision 'HEAD' from git, and afterwards the healthcheck reports an empty version string. It means: $(git describe --always) failed because the repository has no commits yet, so --build-arg VERSION= was passed with nothing after it. Fix: commit something, or use the Makefile target below, which falls back to dev.

Since the build command has four moving parts, put it in the Makefile next to build/api:

# Makefile — add this target at the bottom
## docker/build: build the production image
.PHONY: docker/build
docker/build:
	docker build -t taskd:${GIT_DESC} --build-arg VERSION=${GIT_DESC} .
Note

This target is printed here for the first time. The original chapter runs the docker build command by hand; Appendix B lists docker/build as part of the finished Makefile, so it is shown here where it belongs. It reuses GIT_DESC, which means the image tag and the version stamped inside it are always the same string — a small consistency you will be grateful for.


Part C — the whole system, one command

Step 5 — The full Compose file

This replaces the docker-compose.yml you have been growing since Chapter 5. Two services are new: migrate and api. Everything else you have seen before; what is new is how they are wired together.

# docker-compose.yml
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: taskd
      POSTGRES_PASSWORD: pa55word
      POSTGRES_DB: taskd
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskd -d taskd"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: docker.dragonflydb.io/dragonflydb/dragonfly
    ulimits:
      memlock: -1
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  migrate:
    image: migrate/migrate:v4.18.1
    volumes:
      - ./migrations:/migrations:ro
    command:
      - "-path=/migrations"
      - "-database=postgres://taskd:pa55word@db:5432/taskd?sslmode=disable"
      - "up"
    depends_on:
      db:
        condition: service_healthy

  api:
    build: .
    ports:
      - "4000:4000"
    environment:
      TASKD_APP__ENV: production
      TASKD_DB__DSN: postgres://taskd:pa55word@db:5432/taskd?sslmode=disable
      TASKD_CACHE__ADDR: cache:6379
      TASKD_STRIPE__SECRET_KEY: ${STRIPE_SECRET_KEY:-}
      TASKD_STRIPE__WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
      TASKD_SMTP__HOST: ${SMTP_HOST:-mailpit}
      TASKD_SMTP__PORT: ${SMTP_PORT:-1025}
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    stop_grace_period: 35s

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

  mailpit:
    image: axllent/mailpit
    profiles: ["dev"]
    ports:
      - "8025:8025"

volumes:
  db-data:

What the new parts say

  • migrate: image: migrate/migrate:v4.18.1 — the same golang-migrate tool you installed on your laptop in Chapter 5, packaged as an image and pinned to an exact version. A migration tool that changes under you is not a tool you want.
  • volumes: - ./migrations:/migrations:ro — mounts your local migrations/ folder into the container at /migrations. :ro is read-only: the container may read your migration files and cannot alter them.
  • command: — the arguments handed to the tool. The image already knows to run migrate; this supplies -path, -database and the subcommand up. Written as a YAML list so each argument stays one argument, even with an = and a URL inside it.
  • -database=postgres://taskd:pa55word@db:5432/... — the host is db, not localhost. Compose puts every service on a private network where the service name is the hostname. That is the whole of Docker networking you need today.
  • api: build: . — unlike every other service, this one is not pulled from a registry. . means “build the Dockerfile in this directory”. Compose runs the build for you.
  • TASKD_APP__ENV: production — flips the logger from human-readable text to JSON, per Chapter 19. This is the same override mechanism as Chapter 3, now expressed as deployment configuration.
  • ${STRIPE_SECRET_KEY:-} — Compose substitutes a variable from your shell, or from a .env file sitting next to the compose file, before it reads the YAML. The :- gives a default when it is unset, and here the default is the empty string, so the stack still starts without Stripe credentials. ${SMTP_HOST:-mailpit} shows the same syntax with a real default.
  • depends_on: with condition: — the long form of a service dependency. service_healthy waits for that service’s own healthcheck to pass, not merely for the container to exist. service_completed_successfully waits for a container to exit with status 0, which is what makes the one-shot migration container work.
  • stop_grace_period: 35s — how long Docker waits after SIGTERM before sending the unignorable SIGKILL. See the timeline below.
  • profiles: ["dev"] on mailpit — a service in a profile is skipped unless that profile is named. docker compose up leaves Mailpit out; docker compose --profile dev up includes it. In production you omit the flag and point TASKD_SMTP__HOST at a real mail relay.

The two details doing quiet heavy lifting

The first is stop_grace_period. Chapter 4 built a shutdown that gives in-flight requests up to 30 seconds to finish. Docker’s default grace period is 10 seconds.

  docker compose stop api
      |
      +-- 0s    SIGTERM -> taskd stops accepting, begins its 30 s drain
      |
      |         WITH DOCKER'S DEFAULT GRACE (10 s):
      +-- 10s   SIGKILL. Drain cut off. In-flight requests die mid-response,
      |         background email is lost, exit code 137.
      |
      |         WITH stop_grace_period: 35s:
      +-- 30s   drain finishes, background work finishes, process exits 0
      +-- 35s   (SIGKILL would have fired here — never reached)

Thirty-five is thirty plus five seconds of margin. Without this line, the graceful shutdown you built two hundred pages ago would have been silently truncated on every single deploy, and nothing would have told you.

The second is the migrate dependency chain: “schema before app” stops being a thing you remember and becomes a thing the configuration enforces.

Remember this

depends_on without a condition only waits for the container to start. Waiting for it to be ready requires condition: service_healthy, and that requires the service to have a healthcheck. This is why Chapter 5 made you write pg_isready five chapters before anything used it.

Note

Two honest gaps in this file, worth knowing before they confuse you. It passes the Stripe secret and webhook secret but not price_id_pro, price_id_business, success_url or cancel_url, so the containerised stack can log in and manage tasks but a checkout will fail with Stripe’s “No such price” until you add those variables the same way. It also does not pass TASKD_SMTP__SENDER, so container-sent mail uses the development sender from config.toml. Both are one line each in the environment: block when you need them.


Step 6 — Put the development ports back

Read the file you have written next to the one it replaced. The db service has no ports:. Neither does cache. And mailpit publishes 8025 but no longer 1025.

Inside Compose that is correct: api reaches Postgres at db:5432 over the private network, and publishing a database to your host is something production should never do. But your workflow since Chapter 5 has been running the API on the host with make run/api, against localhost:5432 — and that workflow is what breaks.

Common mistake

You’ll see: after switching to the final Compose file, make db/migrations/up fails with error: failed to open database: dial tcp 127.0.0.1:5432: connect: connection refused. make run/api logs a line whose msg is cannot connect to database, whose error ends in dial tcp 127.0.0.1:5432: connect: connection refused, and exits with status 1. make test/int fails the same way before a single test runs, and redis-cli -h localhost ping gets nothing. It means: nothing is wrong with your code. The database is running perfectly, on a network your laptop is not on. Removing ports: removed the only door. Fix: put the three port mappings back, as below. They are what keeps every host-side command in this book working.

Add these lines back. Each goes inside the service named in its comment, at the same indentation as the other keys of that service.

# docker-compose.yml — in the `db` service, between `environment:` and `volumes:`
    ports:
      - "5432:5432"                  # host-run make targets and go run reach Postgres here
# docker-compose.yml — in the `cache` service, after `ulimits:`
    ports:
      - "6379:6379"
# docker-compose.yml — in the `mailpit` service, alongside the 8025 mapping
      - "1025:1025"

Why production omits them and development does not

Publishing a port is punching a hole from the host’s network into the container. Chapter 26’s server-side compose file deliberately has no published Postgres or Prometheus ports, because on a public server every published port is an open door, and the only service that needs to reach Postgres is api, which reaches it on the private network. On your laptop, the host-run commands are the workflow, so the doors stay open. Same system, two threat models, three lines of difference.

Remember this

Publish a port when something outside the Compose network needs to reach the service. Inside the network, services find each other by service name with no ports published at all.

The api service keeps its "4000:4000" mapping in both worlds, because something has to be able to reach the API. In Chapter 27 (Production checklist, and where to go next) a reverse proxy takes over that job and port 4000 gets firewalled from the outside world.


Step 7 — Point Prometheus at the API’s new address

In Chapter 18 taskd ran on your laptop while Prometheus ran in a container, so the scrape target was host.docker.internal:4000. Now taskd is a Compose service called api, on the same network as Prometheus, so it has a name.

# prometheus.yml — replace the targets line
scrape_configs:
  - job_name: taskd
    scrape_interval: 15s
    static_configs:
      - targets: ["api:4000"]

Prometheus reads this file only at startup, so recreate it:

docker compose up -d --force-recreate prometheus

What you should see — open http://localhost:9090/targets in a browser. The taskd job lists one endpoint, http://api:4000/metrics, with state UP. If it says DOWN, the API container is not running yet; Prometheus retries every 15 seconds and will go green on its own.

Note

This is a genuine either/or. With api:4000 the target only resolves for the containerised API; if you go back to make run/api on the host, Prometheus scrapes nothing until you point it at host.docker.internal:4000 again. You can list both targets, and accept that one of them is always down. The book takes the container’s side because that is what production scrapes.


Step 8 — Start everything with one command

STRIPE_SECRET_KEY=sk_test_... docker compose up --build

What this command says

  • STRIPE_SECRET_KEY=sk_test_... — sets that variable for this one command, which is where ${STRIPE_SECRET_KEY:-} in the compose file gets its value. Use your test key from Chapter 15 (Stripe I: tiers, customers, checkout). Putting it in a gitignored .env file next to docker-compose.yml works identically and saves the typing.
  • docker compose up — start every service that is not behind a profile.
  • --build — build the api image first, rather than reusing whatever was built last time. Leave it off and you may be running yesterday’s code, which is a genuinely confusing thing to debug.

Add --profile dev to include Mailpit:

docker compose --profile dev up --build

What you should see, in this order:

  1. Docker builds the api image, printing one line per Dockerfile instruction.
  2. db and cache start; for a few seconds nothing else happens while their healthchecks run.
  3. migrate starts, prints either a line per applied migration or no change, and exits.
  4. api starts and logs in JSON, because TASKD_APP__ENV: production switched the handler over. The last startup line looks like this, with different timestamps:
{"time":"...","level":"INFO","msg":"starting server","addr":":4000","env":"production"}

If you did not pass a Stripe key, a WARN line appears above it saying stripe.secret_key is empty; billing endpoints will fail. That is the config doing exactly what Chapter 15 designed it to do.

Stop it all with Ctrl-C, or from another terminal with docker compose down. One command; the entire company runs.


7. Checkpoint: prove it works

Run these four in order, from the project root.

docker compose up -d --build
docker compose ps -a
curl -s localhost:4000/v1/healthcheck
docker images taskd

What you should see

  1. docker compose ps -a lists db, cache, api and prometheus as running, with db and cache showing healthy in their status, and migrate in an exited state with exit code 0. (-a matters: without it, Compose hides the container that has already finished.)
  2. The healthcheck returns one line of JSON, keys in alphabetical order, with "database":"up", "environment":"production" — proof that the environment override reached the process — and a "version" field carrying your git description rather than 0.1.0.
  3. docker images taskd shows one row, with a SIZE in the low tens of megabytes.

Then prove the shutdown works properly:

docker compose stop api
docker compose logs api | tail -5

The last lines include shutting down server with "signal":"terminated", then waiting for background tasks, then stopped server. Those three lines in that order mean the drain ran to completion inside the grace period.

If you got something else

What you got Cause Fix
curl: (7) Failed to connect to localhost port 4000 The api container is not running — most likely it exited at boot docker compose logs api and read the last error line
api is stuck in Created and never starts A dependency has not reached its condition docker compose ps -a: is db healthy? Did migrate exit 0? docker compose logs migrate
"environment":"development" in the healthcheck You are curling the API you started with make run/api on the host, not the container Stop the host one, or check docker compose ps really shows api running
"version":"dev" from the container The build ran without a VERSION build arg — docker compose up --build does not pass one Expected. Chapter 26’s pipeline passes the commit SHA; locally use make docker/build
Checkpoint

The single sentence that means this chapter worked: docker compose down followed by docker compose up --build gives you a running, migrated, metrics-reporting taskd, and the healthcheck tells you which commit it is.


8. Common mistakes (and the quick fix)

Symptom / error text What it means in English Fix
exec /bin/api: no such file or directory The binary needs a C library loader the distroless image does not have Restore CGO_ENABLED=0 in the Dockerfile’s go build line
exec /bin/api: exec format error The image was built for a different CPU architecture than the machine running it docker build --platform linux/amd64 ..., or build on the target
Post "https://api.stripe.com/v1/customers": tls: failed to verify certificate: x509: certificate signed by unknown authority The base image has no CA certificates — you switched to scratch Use gcr.io/distroless/static-debian12:nonroot, which includes them
dial tcp 127.0.0.1:5432: connect: connection refused inside the container localhost in a container is the container Use the Compose service name db, or host.docker.internal for a container reaching your laptop
error: failed to open database: dial tcp 127.0.0.1:5432: connect: connection refused from make db/migrations/up on the host Postgres is running but no longer publishes 5432 to your machine Restore the ports: block on db (Step 6)
A build failure mentioning failed to compute cache key and /go.sum not found go.sum is missing from the build context, or .dockerignore excludes it Run go mod tidy to generate it; check your .dockerignore
Makefile:12: *** missing separator. Stop. A Make recipe line begins with spaces instead of a tab Replace the leading whitespace with a real tab
A startup error containing depends on undefined service and a service name A typo or an indentation slip in the compose file Every service name must sit at the same indentation directly under services:
Container exits immediately with code 137 on docker compose stop It was killed rather than allowed to finish — 137 is 128 + 9, the SIGKILL signal Check stop_grace_period: 35s is present on api
Prometheus target shows DOWN with an error mentioning lookup api and no such host Prometheus is running but the api service is not Start it; Prometheus retries on its own

9. Pitfalls

Secrets in docker history. Never pass a secret as a build-time ARG or ENV. Every layer of an image is inspectable forever by anyone who can pull it, and docker history prints the instructions that made them. Secrets are runtime environment, which is how the Compose file passes them. The ${VAR:-} syntax reads from your shell or from a gitignored .env file sitting next to the compose file.

Warning

Deleting a secret in a later layer does not remove it. Layers stack; they do not overwrite. The only fix for a secret baked into an image is to rotate the secret.

latest in production. Fine for prom/prometheus on your laptop. For anything you deploy, immutable tags only — the next chapter tags every image with its commit SHA. “What is actually running right now?” must have exactly one answer, and latest guarantees it has none. (This is also why docker.dragonflydb.io/dragonflydb/dragonfly being untagged in our compose file is a loose end you are free to tie: pin it to a release and a rebuild months from now cannot surprise you.)

Alpine and cgo ghosts. If you ever set CGO_ENABLED=1 — some SQLite drivers require it — then Alpine’s musl libc versus the glibc on most other distributions becomes your problem, and it surfaces as a binary that builds fine and refuses to start elsewhere. Pure-Go dependencies sidestep the entire genre. That is a criterion worth weighing while selecting a library, not after.

Healthcheck is not readiness. Compose’s service_healthy gates startup ordering and nothing else. It is not a load-balancer readiness probe, and it will not take a sick instance out of rotation. When you graduate to Nomad or Kubernetes, wire /v1/healthcheck into their native checks — the endpoint was made database-aware in Chapter 6 for exactly this future.

The config file in the image is a snapshot. COPY config.toml /config.toml freezes today’s defaults into the image. Change a default and it does not reach production until the next build. That is a feature (the image is self-contained) with a sharp edge (a default you changed months ago may still be live). Anything that must be changeable without a rebuild belongs in an environment variable, which is the whole point of Chapter 3’s precedence order.

docker compose down -v still destroys the database. The -v deletes named volumes, including db-data. Chapter 5 warned about this; nothing here has changed except that you now type docker compose commands far more often.


10. Check yourself — quiz

  1. What exactly does a multi-stage build save, and where does the saving show up?
  2. Why does COPY go.mod go.sum ./ come before COPY . . and not after?
  3. You change one line in internal/data/tasks.go and rebuild. Which Dockerfile instructions re-run?
  4. What does CGO_ENABLED=0 buy, and which error tells you that you forgot it?
  5. USER nonroot — what class of problem does it reduce, and what does it not prevent?
  6. Why does the app not run its own migrations at boot?
  7. depends_on: migrate: condition: service_completed_successfully — what exactly is Compose waiting for?
  8. Why is stop_grace_period set to 35 seconds rather than 30, and what would go wrong at 10?
Answers
  1. It saves shipping the build tools. The Go toolchain, the downloaded modules and your source code all live in stage 1 and are discarded; only files named in a COPY --from= reach the final image. The saving shows up in image size (tens of megabytes instead of hundreds), in transfer time on every deploy, and in attack surface — a compiler in a production image is a tool for whoever gets in.

  2. Because Docker caches layers in order, and the first miss invalidates everything after it. go.mod/go.sum change rarely, so the expensive go mod download layer above the source copy stays cached across ordinary code edits. Reverse the order and every edit re-downloads every dependency.

  3. COPY . . and the RUN ... go build after it. The FROM, WORKDIR, COPY go.mod go.sum ./ and RUN go mod download layers are cache hits, because neither those instructions nor their inputs changed. Stage 2 re-runs too, since it copies the new binary.

  4. It produces a fully static binary with no dependency on a system C library, which is what allows a distroless base image with no libc in it. Forget it and the container fails at start with exec /bin/api: no such file or directory — a message about the missing dynamic loader, not about the binary, which is why it misleads.

  5. It reduces what an attacker gains from executing code inside the container: UID 65532 owns nothing, can write nowhere important, and cannot install anything. It does not prevent the break-in itself, does not protect data your process can legitimately read (the database password is in its environment), and is not a substitute for keeping dependencies patched.

  6. Two reasons. It couples starting the app to changing the schema, so a bad migration becomes a crash loop instead of a failed job; and with more than one replica, several processes race into the same schema change at once. A separate one-shot container keeps the two operations independently retryable.

  7. For the migrate container to have exited with status 0. Not started, not healthy — finished, successfully. If the migration fails, the container exits non-zero, the condition is never met, and api never starts, which is the desired outcome.

  8. Chapter 4’s shutdown gives in-flight requests up to 30 seconds to drain; 35 leaves five seconds of margin. Docker’s default of 10 seconds would send SIGKILL 20 seconds into a 30-second drain, cutting live requests off mid-response and dropping any background email still in flight — on every deploy, silently, with the exit code 137 as the only clue.


11. Practice

Exercise 1 — Measure the layer cache instead of believing it

The chapter claims a COPY ordering is worth the difference between a rebuild you tolerate and one you resent. Measure it on your own machine.

  1. Build once so the cache is warm.
  2. Change one line in a Go file (a comment will do), rebuild, and time it.
  3. Swap COPY . . above COPY go.mod go.sum ./ and RUN go mod download, change another line, rebuild, and time that.
  4. Put the Dockerfile back.
Solution
# 1. warm the cache
docker build -t taskd:dev .

# 2. touch a file and time a rebuild with the good ordering
echo "// cache experiment" >> cmd/api/healthcheck.go
time docker build -t taskd:dev .

Now edit the Dockerfile so stage 1 reads:

# Dockerfile — stage 1 TEMPORARILY reordered for the experiment; undo afterwards
COPY . .
RUN go mod download
ARG VERSION=dev
RUN CGO_ENABLED=0 go build \
    -ldflags="-s -w -X main.version=${VERSION}" \
    -o /bin/api ./cmd/api
docker build -t taskd:bad .          # warm this ordering's cache too
echo "// cache experiment 2" >> cmd/api/healthcheck.go
time docker build -t taskd:bad .

How to verify: watch the build output, not only the clock. With the good ordering, the lines for COPY go.mod go.sum ./ and RUN go mod download are marked as cached; with the bad ordering they are not, and go mod download runs again. The wall-clock difference depends on your network, which is exactly the point: the good ordering makes your rebuild time independent of your network.

Restore the original Dockerfile afterwards — git checkout Dockerfile if you committed it — and undo the two comment lines.

Exercise 2 — Prove the container knows its own commit

Build the image through the Makefile target, run it against your Compose stack, and confirm the healthcheck reports the same string git describe prints.

Solution
git describe --always --dirty --tags        # note this string
make docker/build
docker compose up -d db cache               # the dependencies, on published ports
docker run --rm -p 4000:4000 \
  -e TASKD_DB__DSN='postgres://taskd:pa55word@host.docker.internal:5432/taskd?sslmode=disable' \
  -e TASKD_CACHE__ADDR='host.docker.internal:6379' \
  taskd:$(git describe --always --dirty --tags)

In another terminal:

curl -s localhost:4000/v1/healthcheck

How to verify: the version value in the JSON equals the string the first command printed, and docker images taskd shows an image whose TAG is that same string. Two places, one truth.

If you have uncommitted changes, both will end in -dirty — which is the feature working: a running server telling you it was not built from any commit you can look up.

Exercise 3 — Watch the drain finish, then watch it get cut off

Confirm the grace period is doing what the timeline claims, by removing it.

Solution
docker compose up -d --build
docker compose stop api
docker compose logs api | tail -5
docker inspect --format '{{.State.ExitCode}}' $(docker compose ps -aq api)

The log tail ends with shutting down server, waiting for background tasks, stopped server, and the exit code is 0.

Now break it on purpose. Comment out stop_grace_period: 35s, then add a deliberately slow request to make the drain take longer than Docker’s default 10 seconds — the easiest version is to temporarily re-add Chapter 4’s /v1/slow route, if you kept it, or to run a request against a database you have paused with docker compose pause db.

docker compose up -d --build api
# start something slow, then, in another terminal:
docker compose stop api
docker inspect --format '{{.State.ExitCode}}' $(docker compose ps -aq api)

How to verify: the exit code is 137 instead of 0. 137 is 128 + 9, and 9 is SIGKILL: Docker ran out of patience and killed the process mid-drain. Put stop_grace_period: 35s back, undo the slow route, and note the number 137 somewhere in learnings/ch25.md — it is the fingerprint of “something killed my container”, and you will meet it again.


12. FAQ

What is a container, really? Is it a small virtual machine? No. A virtual machine simulates hardware and boots a second operating system, which takes seconds to minutes and hundreds of megabytes of RAM. A container is an ordinary process on the host kernel, started with restrictions on what it can see: its own filesystem view, its own network interface, its own process list. That is why containers start instantly — nothing boots. The trade-off is that containers share the host kernel, so the isolation is strong but not the fortress a VM provides, which is why you still run as a non-root user inside one.

Why is the image so small when Docker images are famous for being huge? Because Go compiles to one self-contained executable and we ship nothing else. The usual reason an image is 900 MB is that it contains a language runtime, a package manager, the application’s source code, its development dependencies, and often a compiler — all needed to build, none needed to run. The multi-stage build separates those two lists, and the distroless base contributes about two megabytes.

How do I debug a container with no shell? Mostly you do not need to: the logs from Chapter 19 and the metrics from Chapter 18 exist so that production questions have answers without touching the machine. When you genuinely need a look inside, docker debug (Docker Desktop) and Kubernetes’ ephemeral containers attach a toolbox container to the running one, giving you a shell that is not in your image. And nothing stops you building a temporary image FROM alpine with the same binary when you are cornered. The philosophy: debugging tools are a thing you bring, not a thing you leave lying around in production.

Where exactly do secrets live now? Runtime environment variables, and nowhere else. On your laptop, a gitignored .env file next to docker-compose.yml, or a variable typed on the command line. In Chapter 26, GitHub Actions secrets injected by the pipeline. Never in config.toml, never in a Dockerfile ARG or ENV, never in a layer. The rule from Chapter 3 — the config file must be safe to commit — is what makes the container interface work at all.

Do I need Kubernetes now that I have containers? No. Kubernetes solves running many containers across many machines, with automatic placement, rollout control and self-healing. For one service on one server, Compose plus the deploy job in Chapter 26 is the right amount of machinery, and it is transparent enough to debug by reading it. The structure here — build one image, deploy it by tag — is deliberately the same shape as the fancier options, so graduating later changes the deployment job and not the application.

Why does my container say “connection refused” for a database that is clearly running? Because localhost inside a container means that container, not your laptop and not the database’s container. There are three correct addresses depending on where you are standing: from one Compose service to another, use the service name (db:5432); from a container to a program running on your laptop, use host.docker.internal; from your laptop to a container, use localhost plus a published port. Nearly every Docker networking confusion is one of those three being used in the wrong place.


13. Where we are

taskd is now a thing you can ship: one image containing one binary, with no compiler, no shell and no package manager in it, running as a user who owns nothing, announcing which commit it is, and starting only after its database is migrated. The whole system — five services — comes up with one command and goes down without dropping a request.

The repo as it now stands

taskd/
├── cmd/api/
│   ├── main.go            # UPDATED: const version → var version
│   ├── server.go   config.go   db.go   background.go
│   ├── routes.go   middleware.go   helpers.go   errors.go   context.go
│   ├── healthcheck.go     # now reports the real build version
│   ├── metrics.go   docs.go   accounts.go   webhooks.go
│   ├── users.go   tokens.go   tasks.go   billing.go   entitlements.go
│   └── docs/openapi.yaml
├── internal/
│   ├── data/   db/   cache/   validator/
│   └── mailer/            # templates embedded in the binary — nothing to COPY
├── migrations/            # mounted read-only into the migrate container
├── sql/queries/
├── Dockerfile             # NEW: two stages, distroless, non-root
├── .dockerignore          # NEW: keeps .git and .envrc out of the image
├── docker-compose.yml     # UPDATED: migrate + api services, grace period
├── prometheus.yml         # UPDATED: target is now api:4000
├── Makefile               # UPDATED: GIT_DESC, build/api, docker/build
├── config.toml   sqlc.yaml   .envrc
└── go.mod   go.sum

What works end to end: docker compose up --build starts Postgres, DragonflyDB, the migration job, taskd and Prometheus, in dependency order, with production JSON logging, and the healthcheck reports the commit that built it. docker compose stop api drains cleanly. make run/api on the host still works, because the published ports came back.

What is still fake or missing:

  • Nothing builds this image except you. Chapter 26 (CI/CD: the robot that says no) puts the build behind a robot that refuses unformatted, unvetted, untested or vulnerable code, tags every image with its commit SHA, and pushes it to a registry.
  • There is no TLS and no domain. The API is plain HTTP on port 4000. Chapter 27 (Production checklist, and where to go next) puts a reverse proxy in front, terminates TLS, and firewalls 4000 from the world.
  • The compose file is a development file. It builds from source, publishes database ports, and keeps its password in plain text. The production variant differs in a handful of lines, all of them named in Chapter 26.
  • The checkout flow is not fully wired in containers — the Stripe price IDs and redirect URLs are not passed as environment variables yet.

For your notes

  • A container is a process wearing blinkers, not a small computer. Its localhost is itself. Service-to-service, use the service name; container-to-host, host.docker.internal; host-to-container, a published port.
  • Multi-stage builds ship the dish, not the kitchen. Nothing crosses between stages except what a COPY --from= names, which is why the compiler and the source never reach production.
  • Layer caching turns file ordering into a build-time decision. Copy the rarely-changing input (go.mod, go.sum) and do the slow work above the constantly-changing input (your source). The first cache miss invalidates everything below it.
  • CGO_ENABLED=0 is what makes a nearly-empty base image possible. Forget it and the container fails with exec /bin/api: no such file or directory, a message that names the wrong culprit.
  • Grace periods have to be longer than your drain. Docker waits 10 seconds by default; our shutdown wants 30. stop_grace_period: 35s is the five-second margin between a clean deploy and a silently truncated one — and exit code 137 is how you find out you got it wrong.

Chapter 26 — CI/CD: the robot that says no

Up to now, the only thing standing between a mistake and your users has been you: your memory of which commands to run, your discipline about running them, and your mood on a Friday evening. This chapter hires a replacement. We build two robots that live inside GitHub — one that refuses to let unformatted, unvetted, untested or known-vulnerable code reach your main branch, and one that takes every change that survives, packs it into a uniquely named Docker image, and puts it on your server without you touching a terminal. Neither robot is clever. That is the point.

What you’ll be able to do by the end

  • Read a GitHub Actions workflow file line by line and say what each part does.
  • Run the exact checks your pipeline runs, locally, with one command: make audit.
  • Explain what each of the six checks catches, and what class of bug slips past all of them.
  • Point at a running server and say which commit is serving traffic — with proof, from its own /v1/healthcheck response.
  • Roll a bad deploy back to the previous commit with one command you have written down in advance.
  • Explain why deploying a tag called latest destroys your ability to answer “what is live?”

Time: ~50 minutes reading, ~30 minutes typing, plus waiting on the robot.

You need before starting: a working Chapter 25 (Docker: a 15 MB production image). Two commands prove it:

go build ./...
docker build -t taskd:local .

The first prints nothing (in Go, silence is success). The second ends with a line saying the image was written, tagged taskd:local.

You also need three things this book has not asked for until now:

Thing Needed for If you don’t have it
A GitHub account, and this repo pushed to it everything Step 0 walks you through it.
GitHub Actions enabled (on by default for new repos) both workflows Nothing to do.
A server you can SSH into, with Docker installed the release half only Do Steps 0–3 and stop. They are the half that pays.
Note

The book’s front matter says you need “Go, Docker and curl. That’s it.” That was true through Chapter 25. From here on it is not: this chapter needs a GitHub account, and its second half needs a host. The audit workflow — the part that catches bugs — needs nothing but GitHub, and you should build it even if you never deploy anything.


1. The problem, in plain words

Here is a true story about every codebase that has ever existed.

Someone is in a hurry. They fix one thing, run the one test that covers the thing they fixed, and push. The change is fine. What is not fine is that six weeks ago, someone else changed a query and never re-generated the Go code from it, and nobody has run the full test suite since — so the bug that has been sitting in main for six weeks ships tonight, in a release nobody associates with it.

Every part of that story is a human failure of memory, not of skill. And human memory does not scale: you can hold “always run the tests” in your head for a month, maybe. You cannot hold it for a year, and you certainly cannot hold it for a year on behalf of three other people.

So we stop relying on memory. We write down the checks once, and we give them to a machine that has no mood, no Friday, and no opinion about whether this change looks safe. The machine runs every check on every change, and it has exactly one power: it can say no.

New word

CI (Continuous Integration) — a robot that builds and tests every change automatically before it can be merged. CD (Continuous Deployment) — automatically shipping every change that passes CI.

The second half — CD — sounds more exciting and is much less important. CI is what stops bad code. CD only removes the tedium of shipping good code. If you build one of the two, build CI.

Think of it like

CI is the quality-control line before packing: every unit goes past the same inspectors, in the same order, and a failure stops the belt. CD is the loading dock: once a unit passes, it goes on the truck without anyone signing a form.

What breaks if you skip this

Nothing today. What you lose is the ability to make a change confidently six months from now. The difference between a codebase you enjoy and a codebase you dread is almost never the code — it is whether you have a fast, honest answer to “did I break anything?”

There is a second, less obvious loss. Without a pipeline, “what is running on the server?” has no answer. You will find yourself SSHing in to read a file, comparing timestamps, guessing. The moment an incident starts, that guess costs you the first twenty minutes.


2. New words in this chapter

Word What it means here
CI (continuous integration) A robot that builds and tests every change automatically before it can be merged.
CD (continuous deployment) Automatically shipping every change that passes CI.
pipeline The whole chain of automated steps a change passes through, from push to live.
workflow A YAML file of named shell steps GitHub runs on a fresh throwaway machine whenever a chosen event fires.
job One independent unit of a workflow, run on its own machine. Jobs run in parallel unless they declare otherwise.
step One named command (or one reusable action) inside a job. Steps run in order on the same machine.
runner The machine GitHub gives a job. Ours is ubuntu-latest.
VM (virtual machine) A simulated computer running inside a real one. Each runner is one, created for your job and destroyed after.
VPS Virtual private server: a rented virtual machine with a public address. The “one host” this chapter deploys to.
service container A real dependency (here Postgres) started alongside the CI job so tests hit the genuine article.
action Someone else’s packaged step, pulled in with uses: instead of run:.
gate A check that must pass before a change is allowed through.
linter A tool that flags likely mistakes and dead code beyond what the compiler rejects.
static analysis Examining code without running it. Every check in the audit except the tests is static analysis.
gofmt The formatter that ends all style arguments by rewriting code to one canonical layout.
go vet A built-in checker for suspicious-but-compiling code.
staticcheck The community’s most respected single Go linter; catches unused code, impossible conditions, misused standard-library calls.
govulncheck Scans which vulnerable library functions your code actually reaches, so its warnings are real rather than theoretical.
call graph The map of which function calls which. govulncheck walks it.
vulnerability database A public list of known security holes in specific versions of specific libraries.
sqlc diff Regenerates the query code in memory and fails if what’s committed is out of date.
exit code The number a command hands back when it finishes. 0 means success; anything else means failure, and CI treats it as failure.
command substitution $(cmd) in a shell: run cmd and paste its output into this spot.
branch A named line of development in Git. main is the one that ships.
pull request (PR) A request to merge one branch into another, with a page for review and for CI results.
commit SHA The 40-character hexadecimal fingerprint Git gives every commit. Globally unique, never reused.
artifact The built, deployable thing a pipeline produces — here, a tagged Docker image.
registry A server that stores Docker images.
GHCR GitHub Container Registry — GitHub’s registry, at ghcr.io.
image tag The label after the colon in ghcr.io/you/taskd:abc123, naming which build you mean.
immutable tag A tag that always means one exact build — a commit SHA, never latest.
secret (CI sense) A value stored encrypted in GitHub, injected into a workflow at run time, never printed in logs.
GITHUB_TOKEN A short-lived credential GitHub mints for each workflow run, scoped by the workflow’s permissions: block.
SSH The encrypted protocol for logging into a remote machine and running commands.
public-key authentication Proving who you are with a private key you hold and a public key the server holds, instead of a password.
deploy user A dedicated, unprivileged account on the server that exists only to run deployments.
rollback Going back to the previous version — here, redeploying the previous commit’s image tag.
zero-downtime deploy Replacing a running version with no interruption, by starting the new one before stopping the old.
blast radius How much damage a compromised credential or failing component can cause.
pinning Depending on an exact immutable version rather than a moving tag, so third-party code can’t change under you.
supply chain Everything you didn’t write but do run: libraries, actions, base images.
Ansible / Nomad / Kubernetes Larger deployment tools, described here as the next rungs up from SSH-and-compose.

3. The goal

The original book’s statement, in its own words:

Two GitHub Actions workflows. Audit (every push/PR): format check, vet, staticcheck, govulncheck, sqlc diff, and the full test suite — with a real Postgres — under -race. Release (push to main): build the image, tag with SHA + latest, push to GHCR, and a deploy job that SSHes to the VPS and rolls the Compose stack. Local make audit mirroring CI, because pipelines you can’t run locally are pipelines you debug by commit spam.

That last clause is the whole chapter compressed into one line, so let us decompress it.

Suppose your pipeline runs a check that you cannot run on your laptop. You push. It goes red. You guess at the cause, push a fix, wait three minutes, and it goes red again. After eight rounds of this your Git history reads:

fix ci
fix ci again
really fix ci
please

That is commit spam: a permanent, public record of you debugging by trial and error against a machine you cannot see. The cure is not discipline. The cure is making the pipeline runnable locally, so that the loop takes ten seconds instead of three minutes and leaves no trace in history.

Hence make audit. It is not a convenience target. It is the design constraint that keeps the pipeline honest.


4. The thinking

4.1 The check list is chosen, not accumulated

Every check in a pipeline costs seconds, and seconds are the currency that decides whether people use the pipeline or route around it. A three-minute pipeline gets run. A twenty-minute one gets bypassed — by pushing at 5 p.m. and hoping, by merging with the failing check marked “flaky”, by disabling it “just for this release”.

So each check has to earn its place. Here is the list, and what each one buys:

Check What it catches Why it earns its seconds
gofmt Code formatted differently from Go’s one canonical layout. Arguments about style are a tax. The tool ends them: there is one correct layout and a program that produces it.
go vet Code that compiles but is suspicious — a Printf with the wrong number of arguments, a struct tag that doesn’t parse, a lock copied by value. It ships with Go and takes about a second. Free correctness.
staticcheck Unused variables and functions, impossible conditions, misuse of standard-library calls, dead branches. The single best linter in the Go community.
govulncheck Known security holes in libraries you use — and specifically the ones your code actually reaches. It walks your call graph, so its warnings are real.
sqlc diff Committed generated Go that no longer matches the .sql files it came from. Closes the Chapter 7 loophole mechanically.
go test -race Regressions in behaviour, and data races between goroutines. It is the only check that runs your program.
New word

call graph — the map of “this function calls that function”. govulncheck builds one for your whole program, then asks the Go vulnerability database: are any of the reachable functions in a known-vulnerable version of a library? A plain dependency scanner only asks “is a vulnerable library in go.mod?”, which produces a pile of warnings for code you never call.

Think of it like

A plain dependency scanner tells you that a part in your car’s model line was recalled. govulncheck tells you whether that part is in your car. That is why its warnings deserve attention rather than suppression: it has already filtered out the noise.

On staticcheck specifically, the original makes a deliberate choice worth naming. The popular alternative is golangci-lint, a program that bundles dozens of linters behind one command. It is excellent and it is also a kitchen sink: you spend your first afternoon deciding which forty opinions to switch off. We take staticcheck alone — fewer opinions, but each one strong enough to be worth obeying. If you later want more, adding golangci-lint is a two-line change to both the Makefile and the workflow.

4.2 Deploy strategy, sized honestly

There is a spectrum of ways to get a new version onto a server. All of them work. They differ in how much machinery you must own and understand.

Option What it is Right when
SSH and compose Log in, pull the new image, docker compose up -d. One host, one binary. Transparent enough to debug by reading it.
Ansible A tool that runs a described sequence of steps against many machines, repeatably. Several hosts, or a host whose whole setup you want written down.
Nomad / Kubernetes An orchestrator: you declare “I want three copies of this image”, and it places, restarts and rolls them out for you. Many services, many hosts, or a rollout policy you can’t perform by hand.

For one VPS and one binary, the first is not a compromise — it is the correct amount of machinery. The structure we build (build once, deploy by tag) is deliberately the same shape as the fancier options, so graduating later changes the deploy job only. The audit workflow, the image build, the SHA tagging and the rollback story all survive the move unchanged.

New word

VPS — virtual private server: a rented virtual machine with a public IP address, from any of a dozen hosting companies. For this chapter, all that matters is that you can SSH into it and it has Docker installed.

And the honest limitation, stated up front: docker compose up -d on one host stops the old container and starts the new one. There is a gap between the two — usually a second or so — during which requests fail. That is a brief-blip deploy, not a zero-downtime deploy. The zero-downtime path is two containers behind a proxy that drains one before swapping to the other, or an orchestrator that does the same thing for you. Chapter 27 (Production checklist, and where to go next) notes it for the day a blip starts costing money. Deploying at 3 a.m. with a blip is a normal way to run a small service; pretending there is no blip is not.

4.3 Where secrets live

Three kinds of secret appear in this chapter, and they live in three different places on purpose.

Secret Lives in Seen by
SSH private key for the deploy user GitHub Actions secret (DEPLOY_SSH_KEY) the deploy job only
Server hostname GitHub Actions secret (DEPLOY_HOST) the deploy job only
Stripe keys, SMTP password, database password a .env file on the server, placed there once by hand the running containers only

Notice what CI never sees: live Stripe keys. It does not need them, because the tests do not call Stripe — that was Chapter 20’s boundary decision, and it compounds here into “a leaked CI secret cannot charge anybody’s card”.

New word

secret (CI sense) — a value you store encrypted in GitHub’s settings. Workflows can read it as ${{ secrets.NAME }}; nobody can read it back out of the UI, and GitHub masks it if a log line would print it. Masking is a safety net, not a permission slip: never echo a secret.


5. A picture of it

Two workflows, two triggers. This is the whole chapter on one screen.

   git push to main   or   opening / updating a pull request
                    │
                    ▼
  ┌──────────────────── audit.yml ─────────────────────────────┐
  │  a fresh VM  +  a postgres:17-alpine service container     │
  │                                                            │
  │   1  gofmt        catches  formatting drift                │
  │   2  go vet       catches  Printf/lock/struct-tag mistakes │
  │   3  staticcheck  catches  dead code, impossible branches  │
  │   4  govulncheck  catches  vulnerable code you call        │
  │   5  sqlc diff    catches  stale generated Go              │
  │   6  go test -race catches regressions and data races      │
  └────────────────────────┬───────────────────────────────────┘
                           │  all six green
                           ▼
                merge allowed  →  main moves forward
                           │
                           ▼
  ┌─────────────────── release.yml ────────────────────────────┐
  │  job build-push:  docker build  ──▶  ghcr.io/you/taskd     │
  │                   tagged  <commit sha>  and  latest        │
  │                           │  needs:                        │
  │                           ▼                                │
  │  job deploy:      ssh deploy@host                          │
  │                   TAG=<sha> compose pull + up -d           │
  └────────────────────────────────────────────────────────────┘

Walking it:

  1. Two things start audit.yml: a push to main, and any pull request — which covers work on a feature branch, because a PR re-runs every time you push to it.
  2. The six gates run in order on one throwaway machine. The first failure stops the job.
  3. A green audit is what makes a merge legitimate. GitHub can enforce that, rather than merely display it — see the note at the end of Step 2.
  4. A push to main — which is what a merge is — additionally starts release.yml.
  5. build-push builds the image from Chapter 25’s Dockerfile and pushes it to GHCR under two names.
  6. deploy waits for build-push (needs:), then SSHes in and rolls the stack to the new tag.

6. The steps

Step 0 — Put the repository on GitHub

You have been committing locally since Chapter 2 (The skeleton). GitHub Actions runs on GitHub, so the code has to be there.

New word

branch — a named line of commits. You have been on one called main all along. remote — a copy of your repository living somewhere else, usually GitHub, referred to by a short name. The conventional name for the main one is origin. push — send your local commits to a remote. pull request (PR) — a page on GitHub proposing that one branch be merged into another. It is where review comments and CI results appear.

On GitHub, create a new empty repository named taskd. Do not let it add a README, a .gitignore or a licence — those would create commits your local repo does not have, and the first push would be rejected as a conflict. Then, in your project folder:

git remote add origin git@github.com:yourname/taskd.git
git branch -M main
git push -u origin main

What this says, line by line

  • git remote add origin ... — remember this URL under the name origin. Use the SSH URL (git@github.com:...) if you have set up an SSH key with GitHub; use the https:// one otherwise and Git will ask for credentials.
  • git branch -M main — rename the current branch to main (harmless if it already is).
  • git push -u origin main — send everything, and remember this pairing so that future pushes are just git push.
Warning

Before you push, open .gitignore and confirm it contains .envrc and .env. Those files hold your database password and, later, your Stripe keys. A secret pushed to GitHub is a secret you must now rotate, even if you delete the commit two minutes later — the history is already on their servers, and possibly in somebody’s scraper.

What you should see: a summary of objects being compressed and written, ending with a line mapping your local main to a new remote main. Reload the repository page in your browser and the files are there.


Step 1 — Mirror the pipeline locally, first

This is the original’s step 1, and doing it first is not an accident. Write the checks where you can run them; only then teach a robot the same list.

Add this target to the Makefile you have been growing since Chapter 5 (Postgres and migrations):

# Makefile — add this target
.PHONY: audit
audit:
	@test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
	go vet ./...
	go run honnef.co/go/tools/cmd/staticcheck@latest ./...
	go run golang.org/x/vuln/cmd/govulncheck@latest ./...
	sqlc diff
	go test -race -count=1 ./...
Common mistake

You’ll see: Makefile:56: *** missing separator. Stop. (the line number will be wherever your recipe starts) It means: the indented lines under audit: start with spaces. Make requires a real TAB character at the start of every recipe line, and nothing else will do. Fix: delete the leading whitespace on each of the six lines and press Tab once. If your editor converts Tab to spaces, turn that off for Makefile — most editors have a per-filetype setting. This is the third Makefile in this book and it is the same trap every time.

What this code says, line by line

.PHONY: audit : Tells make that audit is the name of a command, not the name of a file it should try to build. Without it, creating a file called audit in your project would silently stop the target working.

@test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1) : Five separate ideas crammed into one line. Taking them one at a time:

  • gofmt -l . — the -l flag means list: print the names of files whose formatting differs from the canonical layout, and print nothing else. A correctly formatted project produces no output.
  • $$( ... )command substitution. In a shell, $(cmd) means “run cmd and paste its output here”. Make uses $ for its own variables, so inside a Makefile you write $$ to pass a literal $ through to the shell.
  • test -z "..." — the test command with -z asks: is this string empty? If yes it succeeds (exit code 0); if no it fails (exit code 1).
  • || ( ... ) — run the right-hand side only if the left-hand side failed. The parentheses group three commands into one: say what happened, list the offending files, then exit with failure so make stops.
  • @ at the very start — do not echo this line before running it. Cosmetic; without it make prints the whole incantation, which is noise.

Put together: if any file is misformatted, print which ones and fail.

New word

exit code — every command hands back a number when it finishes. Zero means success; anything else means failure. This is the only language make, shells and CI systems have for “did that work?”, which is why a check that prints a scary message but exits 0 is not a check at all.

go run honnef.co/go/tools/cmd/staticcheck@latest ./... : go run normally runs your own package. Given a module path with an @version suffix, it downloads that tool, builds it, and runs it — without adding it to your go.mod. That is exactly what you want for a tool that only ever runs on your machine and in CI. The first run takes a while (it compiles the tool); later runs come from the build cache.

./... : Go’s “this package and everything under it” pattern. You have seen it since Chapter 2.

sqlc diff : Uses the sqlc binary you installed in Chapter 7 (sqlc: SQL that compiles). It re-runs the generator in memory and compares the result with what is committed in internal/db. Identical means the generated code is current. Different means somebody edited a .sql file and forgot to run make sqlc, and the exit code is non-zero.

go test -race -count=1 ./... : Chapter 20 (Testing what matters) built these. -race turns on the race detector; -count=1 disables Go’s test result cache so the tests genuinely run rather than replaying yesterday’s pass.

Note

Chapter 7 promised that sqlc vet — sqlc’s own lint rules for your queries — “joins CI in Chapter 26”. The workflow we are about to write runs sqlc diff, not sqlc vet. They answer different questions: diff asks is the generated code current?, vet asks are these queries sensible? The original runs only diff. Practice 3 has you make good on the promise, in both places at once — which is also the best possible drill for this chapter’s central rule.

Run it now:

make audit

What you should see: make echoes each command before running it. The gofmt line is silent (the @). go vet prints nothing when it is happy. staticcheck and govulncheck may take a minute the first time while they download and compile, then print nothing (or, for govulncheck, a short summary saying no vulnerabilities were found in code you reach). sqlc diff prints nothing when the generated code is current. Then go test prints one line per package: ok with a duration for packages that have tests, and ? with [no test files] for the ones that don’t.

If any step fails, make stops there and the later steps never run. That is the same behaviour CI will have, and it is deliberate: there is no point running the tests against code that does not even compile cleanly.

Important

Get make audit fully green before you write a single line of workflow YAML. The whole design depends on local and remote agreeing, and the fastest way to learn that lesson badly is to discover twelve failing assertions in a CI log.


Step 2 — The audit workflow

This is the original’s step 2. Before the file, the mental model — the original’s own parenthesis, expanded, because it is the clearest description of GitHub Actions anywhere in this book:

A workflow is a YAML file of named shell steps that GitHub runs on a fresh throwaway VM every time the on: events fire. services: boots sidecar containers next to that VM — our real Postgres. Nothing mystical; it’s your Makefile with a robot operator.

Four words in that sentence need unpacking.

New word

workflow — one YAML file in .github/workflows/. GitHub finds them by that exact path. job — one unit of work inside a workflow, run on its own machine. Two jobs in one workflow run at the same time on two different machines unless one declares needs: the other. step — one named thing inside a job. Steps share a machine and run in order. runner — the machine. runs-on: ubuntu-latest asks GitHub for a fresh Ubuntu VM, which is created for your job, given your repository, and destroyed when the job ends. Nothing you install on it survives. That is a feature: the pipeline cannot accumulate a state that only it has.

Create the directories and the file. Note the leading dot in .github — on Unix that makes it a hidden folder, so ls will not show it unless you pass -a.

mkdir -p .github/workflows
# .github/workflows/audit.yml
name: audit
on:
  push:
    branches: [main]
  pull_request:

jobs:
  audit:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: taskd
          POSTGRES_PASSWORD: pa55word
          POSTGRES_DB: taskd_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U taskd"
          --health-interval 5s --health-timeout 3s --health-retries 10

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
          cache: true

      - name: Verify formatting
        run: test -z "$(gofmt -l .)"

      - name: Vet
        run: go vet ./...

      - name: Staticcheck
        run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...

      - name: Vulnerability scan
        run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

      - name: sqlc up to date
        run: |
          go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest diff

      - name: Migrate test DB
        run: |
          go run -tags postgres \
            github.com/golang-migrate/migrate/v4/cmd/migrate@latest \
            -path ./migrations \
            -database "postgres://taskd:pa55word@localhost:5432/taskd_test?sslmode=disable" \
            up

      - name: Test (race)
        env:
          TASKD_TEST_DSN: postgres://taskd:pa55word@localhost:5432/taskd_test?sslmode=disable
        run: go test -race -count=1 ./...

What this code says, block by block

name: audit : What this workflow is called in GitHub’s Actions tab. Cosmetic, but you will read it a hundred times.

on: push: branches: [main] and pull_request: : The triggers. Run when someone pushes to main, and run when someone opens or updates a pull request against any branch. The pull_request: key with nothing under it means “default settings”, which is what you want.

jobs: audit: : One job, whose id is audit. The id is what other jobs would refer to in needs:.

runs-on: ubuntu-latest : Give me a fresh Ubuntu VM.

services: postgres: : A service container: GitHub starts this container on the same machine as the job, before any of your steps run. The three env: values are the same first-boot settings you gave Postgres in Chapter 5’s Compose file — user, password, database name — except the database is called taskd_test, matching what Chapter 20’s tests expect.

ports: ["5432:5432"] : Publish the container’s port 5432 onto the runner’s own localhost:5432, so your test steps can reach it at exactly the address your local .envrc uses.

options: >- : The >- is YAML for “the following indented lines are one long string; fold the line breaks into spaces and drop the trailing newline”. It exists so a long line stays readable. What it produces is the flags Docker would take on the command line: run pg_isready -U taskd every 5 seconds, give each attempt 3 seconds, and call the container unhealthy after 10 failures. GitHub waits for the service to report healthy before running your first step — which is why the migrate step is not a race against a database that is still booting.

- uses: actions/checkout@v4 : An action: someone else’s packaged step, pulled in by name. This one clones your repository onto the runner. Without it, the machine is empty. @v4 selects a major version.

- uses: actions/setup-go@v5 with go-version: "1.24" and cache: true : Installs that Go toolchain. cache: true saves the downloaded modules and build outputs between runs, keyed on your go.sum, so later runs skip re-downloading the internet.

run: versus uses: : run: is a shell command on the runner. uses: is somebody’s action. Every step gets an optional name:, which is the label you will see in the log.

run: test -z "$(gofmt -l .)" : The same check as the Makefile — but with a single $, because here it is plain shell with no make in the way. This is the one place where the two files legitimately differ in text while being identical in meaning.

run: | (the pipe) : YAML for “the following indented lines are a multi-line string, newlines preserved”. Used for the steps that need more than one line.

go run -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest ... : The runner has no migrate binary — nothing is installed on a fresh VM — so we fetch and run it the same way we fetch staticcheck. -tags postgres is a build tag: migrate compiles support for each database driver behind a tag, and without this one the resulting binary would not know what a postgres:// URL is. The rest is the same invocation as make db/migrations/up, pointed at the service container.

env: TASKD_TEST_DSN: ... on the test step : Chapter 20’s tests skip themselves when this variable is unset, so that go test ./... still works on a machine with no database. Setting it here is what switches the integration tests on in CI. This one line is the difference between a green pipeline that proves something and a green pipeline that proves nothing.

Here is what the runner looks like while the job runs:

  GitHub's runner — one throwaway VM, deleted when the job ends
  ┌──────────────────────────────────────────────────────────────┐
  │                                                              │
  │   your steps, in order          service container            │
  │   ┌────────────────────┐        ┌────────────────────────┐   │
  │   │ checkout           │        │ postgres:17-alpine     │   │
  │   │ setup-go 1.24      │        │ user:     taskd        │   │
  │   │ gofmt / vet        │        │ password: pa55word     │   │
  │   │ staticcheck        │        │ database: taskd_test   │   │
  │   │ govulncheck        │        │                        │   │
  │   │ sqlc diff          │        │ started and confirmed  │   │
  │   │ migrate      ──────┼───────▶│ healthy BEFORE step 1, │   │
  │   │ go test -race ─────┼───────▶│ via pg_isready         │   │
  │   └────────────────────┘   ▲    └────────────────────────┘   │
  │                            └── over localhost:5432           │
  └──────────────────────────────────────────────────────────────┘

The services: block is the underrated hero of this file. Your CI tests hit the same Postgres 17 that your laptop and your server run. When this is green, “works on my machine” and “works” are the same sentence — and the most tedious argument in software engineering stops happening in your team.

Note

The @latest on staticcheck, govulncheck, sqlc and migrate is the original’s choice and we keep it. Be aware of what it means: a new release of any of those four tools can turn your pipeline red tomorrow without anyone changing a line of your code. For staticcheck that is usually a new lint you should obey; for govulncheck it is usually a newly published vulnerability, which is the entire point. If a red pipeline on an untouched branch would be intolerable for you, replace @latest with an exact version — the same reasoning as the pinning pitfall below.

Tip

A red check on a pull request is only advice until you tell GitHub otherwise. In the repository’s Settings → Branches, add a protection rule for main requiring the audit status check to pass before merging. That one setting is the difference between a robot that comments and a robot that says no.

Commit and push:

git add .github/workflows/audit.yml Makefile
git commit -m "ci: audit workflow and make audit"
git push

What you should see: on GitHub, the repository’s Actions tab now lists a run named audit. Open it, open the audit job, and each step from the file appears as an expandable line with its log. A green tick means that step’s command exited 0.


Step 3 — Read a failure on purpose

A gate you have never seen fail is a gate you do not trust. Break formatting deliberately:

printf 'package main\n\nfunc  spare( ) {}\n' > cmd/api/spare.go
make audit

What you should see: the audit stops on the first check, having printed gofmt needed: followed by cmd/api/spare.go. Make then reports that the recipe for target audit failed with a non-zero exit code.

Now compare that with what CI would tell you. The workflow’s version is test -z "$(gofmt -l .)" with no || branch, so it prints nothing at all and the step ends with:

Error: Process completed with exit code 1

That is a genuinely unhelpful message, and it is the single most common confusing CI failure for this pipeline. It means “gofmt listed at least one file”; it does not say which. The fix is always the same and always local:

gofmt -l .        # which files?
gofmt -w .        # rewrite them in place

Then clean up the deliberate mess:

rm cmd/api/spare.go
make audit
Remember this

When CI fails, do not debug it in CI. Reproduce it locally with make audit, fix it there, and push once. The commit history is a document other people read.


Step 4 — The release workflow

The original’s step 3. This one has two jobs: build the artifact, then deploy it.

# .github/workflows/release.yml
name: release
on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  build-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          build-args: |
            VERSION=${{ github.sha }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest

  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    environment: production       # enables required-reviewer gating if you want it
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/taskd                     # the compose stack's home on the VPS
            export TAG=${{ github.sha }}      # deploy EXACTLY the image CI just built
            docker compose pull api           # fetch that image from GHCR
            docker compose up -d              # recreate only what changed
            docker image prune -f             # drop superseded images, reclaim disk

What this code says, block by block

on: push: branches: [main] : Only main. A push to a feature branch gets audited but never built or deployed. Since the normal way main moves is a merged pull request, “merge” and “release” become the same event.

permissions: contents: read / packages: write : Every workflow run gets a temporary credential called GITHUB_TOKEN, minted by GitHub, valid only for that run. This block says what it is allowed to do: read the repository, and write packages (which is what a container image is, to GitHub). Grant the minimum. Leave packages: write out and the push fails with denied: permission_denied: write_package.

${{ ... }} : GitHub Actions’ expression syntax, evaluated before the step runs. github.actor is the username that triggered the run; github.repository is owner/name; github.sha is the full 40-character commit SHA; secrets.X reads a secret you stored.

docker/login-action@v3 : Authenticates the runner’s Docker to ghcr.io using that per-run token. No password of yours is involved anywhere.

docker/build-push-action@v6 : Builds Chapter 25’s Dockerfile (context: . means “the repo root is the build context”) and, because push: true, uploads the result.

build-args: VERSION=${{ github.sha }} : Chapter 25’s Dockerfile declares ARG VERSION=dev and passes it to the linker as -X main.version=${VERSION}. So the commit SHA is compiled into the binary. This is why /v1/healthcheck can tell you which commit is serving traffic.

tags: — two of them : ghcr.io/owner/taskd:<40-char sha> and ghcr.io/owner/taskd:latest. The same image, two names. The SHA tag is the one that matters: it is an immutable tag, meaning it will always name this exact build and no other. See the pitfall below for why latest is a convenience and never a deployment target.

needs: build-push : Without this, the two jobs would start at the same time on two different machines, and deploy would try to pull an image that does not exist yet. needs: says: wait, and only run if it succeeded.

environment: production : Declares that this job deploys to a named GitHub Environment. On its own it does nothing visible. Configure that environment in the repository settings and you can require a named human to click approve before the job runs, add a wait timer, or restrict which branches may deploy. It is a free upgrade path from “deploys happen” to “deploys happen when someone says so”.

appleboy/ssh-action@v1 : Opens an SSH connection to host as username, authenticating with the private key, and runs script on the far end. Everything in script: is ordinary shell running on your server.

The script itself, line by line:

  • cd /opt/taskd — the directory on the server holding the production Compose file and the .env.
  • export TAG=${{ github.sha }} — set a shell variable. Compose substitutes ${TAG} when it reads the file, so this is how the server learns which image to run. export matters: without it the variable would not reach the docker compose process.
  • docker compose pull api — download that exact image from GHCR.
  • docker compose up -d — reconcile the running stack with the file. Containers whose configuration is unchanged are left alone; api is recreated because its image changed. -d means detached: start them and return.
  • docker image prune -f — delete dangling images: ones that no longer carry any tag. An image goes dangling when a tag it held moves to a newer build — which is what happens to the old latest the moment somebody pulls a new one by hand. -f skips the confirmation prompt, because nobody is there to answer it. Note what this does not delete: images still tagged with an old commit SHA survive, which is exactly what makes an instant rollback possible. The bigger hammer, docker image prune -a, removes those too — and with them your ability to roll back without a download.

Step 5 — The server side

The original describes this in one sentence: “Server side, /opt/taskd/ holds a production compose file that differs from dev in three lines.” That sentence is true and it is also the single largest thing this chapter asks you to take on faith, so here is the file it describes.

Note

The production Compose file below is shown here for the first time. The original book names its three differences from Chapter 25’s development Compose file but never prints it. What follows is Chapter 25’s file with exactly those three changes and nothing else, so you can diff the two.

# /opt/taskd/docker-compose.yml — on the SERVER, not in the repo
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: taskd
      POSTGRES_PASSWORD: pa55word
      POSTGRES_DB: taskd
    # DIFFERENCE 2a: no published Postgres port. Nothing outside this
    # host's Docker network should be able to reach the database.
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskd -d taskd"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: docker.dragonflydb.io/dragonflydb/dragonfly
    ulimits:
      memlock: -1
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  migrate:
    image: migrate/migrate:v4.18.1
    volumes:
      - ./migrations:/migrations:ro
    command:
      - "-path=/migrations"
      - "-database=postgres://taskd:pa55word@db:5432/taskd?sslmode=disable"
      - "up"
    depends_on:
      db:
        condition: service_healthy

  api:
    # DIFFERENCE 1: run a published image instead of building here.
    image: ghcr.io/yourname/taskd:${TAG:-latest}
    ports:
      - "4000:4000"
    # DIFFERENCE 3: secrets come from the .env file placed here once.
    env_file: .env
    environment:
      TASKD_APP__ENV: production
      TASKD_DB__DSN: postgres://taskd:pa55word@db:5432/taskd?sslmode=disable
      TASKD_CACHE__ADDR: cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    stop_grace_period: 35s

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    # DIFFERENCE 2b: no published Prometheus port either.

volumes:
  db-data:

What changed, and why

  1. image: instead of build: . — the server does not build anything. It runs the artifact CI produced. This is the “build once, deploy the thing you built” rule, and it is what makes the deploy fast, repeatable and identical to what the tests ran against.
  2. ${TAG:-latest} — Compose substitutes the TAG variable from the environment; the :-latest part means “or latest if it is unset”. The deploy script exports the commit SHA, so in practice TAG is always set. That default exists so that typing docker compose up -d by hand on the server does something sane rather than erroring.
  3. No published db or prometheus ports — inside a Compose project every service can reach every other by name on the private network, so api still talks to db:5432. Publishing a port means opening it to the machine’s network interfaces, which for a public VPS can mean the whole internet. Your database and your metrics server have no business being reachable from Kazakhstan.
  4. env_file: .env — every KEY=value line in that file becomes an environment variable inside the api container. Chapter 3 (Configuration and logging) built the TASKD_* override mechanism precisely so that this would work with no code change.
Note

Two different .env mechanisms are in play and they are easy to confuse. Compose automatically reads a .env file sitting next to the Compose file to fill in ${VARIABLES} in the file itself (that is one way TAG could be supplied). Separately, env_file: passes a file’s contents into a container’s environment. Here we use the second, and the deploy script supplies TAG by exporting it in the shell, which takes precedence over any file.

Warning

ports: - "4000:4000" publishes the API on every interface of the host, which on a fresh VPS means the public internet, over plain HTTP. Chapter 27 (Production checklist, and where to go next) puts Caddy in front to terminate TLS and firewalls port 4000 off. Until you have done that, do not put real user data behind this.

Now create the server-side pieces. Log in as a user with sudo, once, by hand:

# on the server, as an administrator — once
sudo adduser --disabled-password --gecos "" deploy   # Debian/Ubuntu syntax
sudo usermod -aG docker deploy
sudo mkdir -p /opt/taskd
sudo chown deploy:deploy /opt/taskd

What this says

  • adduser --disabled-password creates the deploy account with no password — it can only be reached with an SSH key, which is what we want. --gecos "" skips the interactive questions about full name and phone number.
  • usermod -aG docker deploy adds deploy to the docker group, which is what grants permission to talk to the Docker daemon. Be aware of what you just granted: membership of the docker group is effectively root on that host, because you can start a container that mounts the whole filesystem. That is a known and widely accepted trade-off for a deploy account; it is not a reason to also give the account a password or a shell you log into casually.
  • /opt is the conventional place for software that did not come from the system’s package manager.

Generate a key pair for the robot, on your laptop:

ssh-keygen -t ed25519 -C "taskd deploy" -f ~/.ssh/taskd_deploy -N ""

-t ed25519 picks a modern, short key type; -C is a comment so you can recognise it later; -f names the output files; -N "" means no passphrase, because no human will be there to type one. You now have two files: taskd_deploy (private — this is the secret) and taskd_deploy.pub (public — safe to share).

New word

public-key authentication — you keep the private key; the server keeps the public one. To log in, your client proves it holds the private key without ever sending it. The public key in ~/.ssh/authorized_keys on the server is the list of keys allowed in as that user.

Install the public half on the server. The deploy account has no password, so you cannot log in as it yet — do this from your administrator account, with sudo:

# on the server, as an administrator
sudo mkdir -p /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys   # paste the contents of taskd_deploy.pub
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

SSH refuses to use a key directory or file that other users can read, so those two chmod lines are not decoration: without them you get Permission denied (publickey) with no explanation. Check the whole thing works before involving CI:

# from your laptop — this must succeed before the deploy job can
ssh -i ~/.ssh/taskd_deploy deploy@your-server "docker ps"

Then print the private half so you can paste it into GitHub:

cat ~/.ssh/taskd_deploy

In the repository on GitHub, under Settings → Secrets and variables → Actions, add two repository secrets:

Name Value
DEPLOY_SSH_KEY the entire contents of ~/.ssh/taskd_deploy, including the BEGIN/END lines
DEPLOY_HOST your server’s hostname or IP address

Finally, put the files on the server. Copy docker-compose.yml, prometheus.yml and the migrations/ directory into /opt/taskd/, then create the secrets file:

# on the server, as deploy
cd /opt/taskd
cat > .env <<'EOF'
TASKD_STRIPE__SECRET_KEY=sk_live_replace_me
TASKD_STRIPE__WEBHOOK_SECRET=whsec_replace_me
TASKD_SMTP__HOST=smtp.your-provider.example
TASKD_SMTP__PORT=587
TASKD_SMTP__USERNAME=replace_me
TASKD_SMTP__PASSWORD=replace_me
EOF
chmod 600 .env

cat > file <<'EOF' is a heredoc: everything up to the line reading EOF becomes the file’s contents. Quoting 'EOF' stops the shell from expanding anything inside, which matters when a password contains a $. chmod 600 means “readable and writable by the owner, invisible to everyone else” — the correct mode for a file full of live credentials.

One more thing the original leaves implicit: images pushed to GHCR are private by default, and your server is not logged in to GHCR. So either make the package public in its GitHub settings, or log in on the server once with a personal access token that has read:packages:

# on the server, as deploy — only if the package stays private
echo "$GHCR_TOKEN" | docker login ghcr.io -u yourname --password-stdin

If you skip this and the package is private, the deploy job fails at docker compose pull with a denied or unauthorized message from the Docker daemon, and no amount of re-running will fix it.


Step 6 — Watch a full run

The original’s step 4, and the payoff. Push a commit to main and watch the whole chain:

  you                GitHub                  GHCR              your VPS
   │                   │                       │                   │
   ├─ git push ───────▶│                       │                   │
   │                   ├─ audit.yml            │                   │
   │                   │   6 gates ✓           │                   │
   │                   ├─ release.yml          │                   │
   │                   │   build image         │                   │
   │                   ├── push :abc123 ──────▶│                   │
   │                   ├── push :latest ──────▶│                   │
   │                   ├─ deploy job                               │
   │                   ├────────── ssh deploy@host ───────────────▶│
   │                   │                       │◀── pull :abc123 ──┤
   │                   │                       │                   ├ up -d
   │                                                               │
   ├─ curl https://your-host/v1/healthcheck ──────────────────────▶│
   │◀── {"...","version":"abc123..."} ─────────────────────────────┤

Steps 1–4 take a few minutes with nobody watching. Then, from your laptop:

curl -s https://your-host/v1/healthcheck

Until Chapter 27 (Production checklist, and where to go next) puts Caddy in front of the API, there is no HTTPS yet, so the address that actually answers is http://your-host:4000/v1/healthcheck.

What you should see: a single line of JSON. Because Go serialises a map with its keys in alphabetical order, the fields come out as database, environment, status, version — and version is the full 40-character commit SHA of the commit you just pushed, because Chapter 25’s -ldflags -X main.version=... stamped it into the binary and this chapter’s build-args fed it the SHA.

That is the loop, and it is the entire point of the chapter: commit to verified-live, in minutes, with no hands. More importantly, the first question of every incident — “what is actually running right now?” — is now answered by the software itself, over HTTP, in under a second.


7. Checkpoint: prove it works

Four checks, in order. Do not skip to the last one.

1. Local mirror.

make audit

Every step runs and the command exits without an error. If make prints *** missing separator, see the tabs callout in Step 1.

2. The audit workflow runs on GitHub.

Push to main (or open a pull request — either trigger works). On the repository’s Actions tab there is a run named audit, and expanding the job shows all nine steps — two uses: steps and seven named ones — each with a tick.

3. The gate actually gates. Create a branch, break formatting, open a pull request:

git checkout -b break-ci
printf 'package main\n\nfunc  spare( ) {}\n' > cmd/api/spare.go
git add cmd/api/spare.go && git commit -m "test: break formatting on purpose"
git push -u origin break-ci

Open the pull request on GitHub. The audit check appears on the PR page and goes red at Verify formatting, with Error: Process completed with exit code 1 in the log. Then clean up:

git checkout main
git branch -D break-ci
git push origin --delete break-ci

4. Deploy reaches the server (only if you did Steps 4–5). After a push to main, the release run shows both jobs green, and:

curl -s http://your-host:4000/v1/healthcheck

returns JSON whose version field equals the SHA of the commit you pushed. Compare it against git rev-parse HEAD on your laptop; they must be identical strings.

If you got something else:

You got Cause Fix
No run appears in the Actions tab at all The file is not at exactly .github/workflows/audit.yml, or you pushed a branch the on: block doesn’t match Check the path, including the leading dot. Note that pull_request: covers any branch, so opening a PR always triggers it.
Invalid workflow file with a message about mapping values YAML indentation. Two spaces per level, spaces only, never tabs Compare your indentation against the listing character by character; the error names the line.
Audit green locally, red in CI at Test (race) The integration tests skip on your laptop (no TASKD_TEST_DSN) and run in CI (where the workflow sets it) Run make test/int locally. That is the honest local equivalent of the CI test step.
version shows dev instead of a SHA The image was built without the build arg, or the server is running an older image Check the build-args: block, and that the deploy exported TAG before compose up.
Warning

The moment you switch CI on, it audits everything, including work from earlier chapters you never re-ran. If Chapter 24’s TestEveryRouteIsDocumented or Chapter 20’s lifecycle test are red on your machine, they will be red here too. Fix them locally first — the first push is a terrible place to discover a dozen failing assertions, and it is exactly the “debug by commit spam” trap this chapter exists to prevent.


8. Common mistakes (and the quick fix)

Symptom What it means in English Fix
Makefile:56: *** missing separator. Stop. A recipe line starts with spaces. Make demands a TAB. Retype the leading whitespace as a single Tab. Third time in this book.
Error: Process completed with exit code 1 under Verify formatting, with no other output gofmt -l . listed a file, and the CI version of the check prints nothing before failing. Run gofmt -l . locally to see which file, then gofmt -w ..
Invalid workflow file: .github/workflows/audit.yml#L47 ... mapping values are not allowed in this context YAML indentation is wrong at that line — usually a key indented under something that is not a mapping. Two-space indents, spaces only. Copy the listing again if in doubt.
denied: permission_denied: write_package in the build job The run’s GITHUB_TOKEN is not allowed to publish packages. Add the permissions: block with packages: write to release.yml.
sqlc diff fails with a diff of files under internal/db Somebody changed a .sql file and did not regenerate. This is the safety net working. Run make sqlc, commit the regenerated files, push.
Docker push fails with a message about the repository name having to be lowercase github.repository keeps your account’s capitalisation; registries require lowercase image names. Use a lowercase repository and account name, or lowercase the tag explicitly.
The deploy job hangs, then fails with an SSH timeout Wrong DEPLOY_HOST, a firewall blocking port 22, or the server is down. Try ssh -i ~/.ssh/taskd_deploy deploy@your-host from your laptop. If that fails, CI was never the problem.
Permission denied (publickey) in the deploy job The private key in the secret does not match the public key in the server’s authorized_keys, or the secret was pasted without its BEGIN/END lines. Re-copy the entire private key file. It is multi-line; paste all of it.
docker compose pull fails with denied on the server The GHCR package is private and the server has never logged in. Make the package public, or docker login ghcr.io once as deploy.
The deploy succeeds but /v1/healthcheck still reports the old SHA You pulled latest and the compose file resolved ${TAG} to something stale, or the container was not recreated. Confirm TAG is exported inside the script, and that the compose file uses ${TAG:-latest}.

9. Pitfalls

These are the original’s four, kept and expanded. They are the ones that bite in month three, not on day one.

Un-pinned actions

uses: some-action@v1 points at a mutable tag: the author can move v1 to new code at any time, and your next run executes it. That third-party code runs on a machine that holds your deploy key.

The rigorous practice is pinning to a commit SHA:

# .github/workflows/release.yml — a moving tag replaced by an exact commit
      - uses: appleboy/ssh-action@<40-character-commit-sha>

At minimum, pin anything that touches secrets. In this chapter that is the SSH action, and it is the one to start with. GitHub’s own actions/* are lower risk than a third party’s, but the reasoning is identical.

New word

supply chain — everything you run but did not write: libraries, base images, GitHub Actions. A supply-chain attack compromises one of those instead of attacking you directly, which is a much better deal for the attacker: they get everyone who depends on it.

Race-y latest deploys

Deploying :latest reintroduces the question the SHA tag exists to answer. latest means “whatever was pushed most recently”, so:

  • “Which code is live?” becomes a guess.
  • Rollback becomes impossible, because the previous build has no name you can ask for.

Deploying by SHA makes rollback a single command you can write on a sticky note today and read at 2 a.m. next March:

# on the server, as deploy — go back to a known-good commit
cd /opt/taskd
TAG=<previous-good-sha> docker compose up -d

Compose pulls that image if it is no longer on disk, so the command works whether or not the old image survived the last prune.

Here is the same picture as Step 6, run backwards:

  git log --oneline  ─────▶  pick the last good SHA
                                     │
                                     ▼
  ssh deploy@host ──▶ cd /opt/taskd ──▶ TAG=<old-sha> docker compose up -d
                                     │
                                     ▼
  curl https://your-host/v1/healthcheck  ──▶  "version":"<old-sha>"

The version field closing the loop is the whole reason Chapter 25 bothered stamping it.

Warning

That command rolls back code, not schema. If the release you are backing out included a migration that dropped or renamed a column, the old binary will meet a database it does not understand. The discipline that makes rollback safe is backwards-compatible migrations: add a nullable column, deploy code that writes both old and new, backfill, and only then tighten the constraint — each step safe with the previous version still running. This book never demonstrates that dance; know that it is the missing half of the rollback story.

CI-only breakage

If make audit and audit.yml drift apart, you get failures reproducible only by pushing — which is precisely the commit-spam trap. There is no tool that keeps the two files in sync. The rule is a review-checklist item, enforced by humans:

Any check added to one is added to the other, in the same pull request.

Write that sentence in your notes. It is the maintenance cost of the mirror, and it is cheap compared with what it prevents.

The deploy user

The account CI logs into should be a dedicated deploy user with docker group membership and only the workflow’s key in authorized_keys. Not root. Not your personal key.

New word

blast radius — how much damage a leaked credential can do. Root’s blast radius is the whole machine, forever. A dedicated deploy user’s is one Compose stack, and revoking it is deleting one line from one file.

When — not if — a CI secret leaks, you want to be deleting a line, not rebuilding a server.


10. Check yourself — quiz

  1. The audit workflow runs six checks. Which one is the only one that executes your program, and what does that imply about the other five?
  2. Why does govulncheck produce fewer warnings than a scanner that reads go.mod, and why does that make its warnings more urgent rather than less?
  3. sqlc diff fails in CI on a branch where nobody touched any Go file. What did the author do, and what is the fix?
  4. The test job would still be green if you deleted the env: TASKD_TEST_DSN: ... block from the last step. Why is that dangerous?
  5. In the Makefile the formatting check is written test -z "$$(gofmt -l .)", but in the workflow it is test -z "$(gofmt -l .)". Why the difference, and is the check itself different?
  6. What exactly does needs: build-push prevent, given that both jobs are in the same file?
  7. Your service is live at SHA abc123. You push def456; it deploys; users report errors. Write the command that gets you back, and name the one situation in which it is not enough.
  8. What does environment: production do on its own, and what does it enable?
Answers
  1. go test -race. The other five are static analysis: they read your code without running it. That is why they are fast and why they can never catch a logic error — a program can be perfectly formatted, vet-clean, staticcheck-clean, vulnerability-free and still return the wrong answer. The tests are the only gate that knows what the software is supposed to do.

  2. govulncheck builds your call graph and reports only vulnerabilities in functions your code can actually reach. A go.mod scanner reports every vulnerable version in your dependency tree, including code paths you never call. Because govulncheck has already filtered out the theoretical ones, a warning from it means “this is reachable from your code” — so suppressing it is a decision to ship a known-reachable hole.

  3. Somebody edited a file in sql/queries/ and did not run make sqlc, so the committed Go under internal/db no longer matches the SQL it was generated from. The fix is make sqlc, then commit the regenerated files. This is Chapter 7’s loophole closed mechanically: the reason the check catches it is precisely that generated code is committed, so it can go stale.

  4. Chapter 20’s integration tests skip themselves when TASKD_TEST_DSN is unset. Without the block they would all skip, the job would report success, and you would have a green pipeline that proves nothing about your SQL, your tenancy filters or your handlers — the most expensive kind of green. A skipped test is not a passing test.

  5. Make treats $ as the start of its own variable syntax, so $$ is how you pass a literal $ to the shell. The workflow’s run: is plain shell with no make involved, so a single $ is correct there. The check is identical in meaning: run gofmt -l ., fail if it printed anything.

  6. It prevents deploy from starting at the same time as build-push. Jobs in a workflow run in parallel on separate machines by default, so without needs: the deploy would race the build and usually try to pull an image that has not been pushed yet. needs: also means deploy is skipped entirely if the build fails.

  7. cd /opt/taskd && TAG=abc123 docker compose up -d, run on the server as deploy. It is not enough if def456 included a migration that changed the schema incompatibly — the old binary would then be talking to a database it was not built for. Rolling back code is one command; rolling back schema is a design decision you have to have made in advance.

  8. On its own, nothing visible: it labels the job as deploying to a GitHub Environment named production. Configuring that environment enables required reviewers (a human must approve before the job runs), wait timers, branch restrictions and environment-scoped secrets. It is the one-line upgrade path from automatic deploys to click-to-approve deploys.


11. Practice

Exercise 1 — Watch the formatting gate fail, on both sides

Deliberately misformat a file, observe the local failure and the CI failure, and note how differently they read. Then fix it with the tool rather than by hand.

Solution
# create a badly formatted file
printf 'package main\n\nfunc  spare( ) {}\n' > cmd/api/spare.go

# local: tells you exactly which file
make audit

Make prints gofmt needed: followed by cmd/api/spare.go, then stops with a non-zero exit code. The later checks never run.

git checkout -b fmt-demo
git add cmd/api/spare.go
git commit -m "test: unformatted file"
git push -u origin fmt-demo

Open a pull request. The audit check fails at Verify formatting, and the log shows only Error: Process completed with exit code 1. That is the whole message, because the workflow’s version of the check prints nothing.

Fix and verify:

gofmt -l .     # cmd/api/spare.go
gofmt -w .     # rewrites it
gofmt -l .     # prints nothing now
rm cmd/api/spare.go
make audit     # green

Clean up the branch:

git checkout main
git branch -D fmt-demo
git push origin --delete fmt-demo

The lesson to write down: the local check is more helpful than the CI check, on purpose. CI’s job is to say no; your Makefile’s job is to say why.

Exercise 2 — Make the stale-generated-code net catch you

Change a query without regenerating, and watch sqlc diff do exactly the job Chapter 7 promised it would.

Solution

Open sql/queries/tasks.sql and change one query harmlessly: swap the order of two columns in an existing SELECT list. The query returns the same data; the Go struct sqlc generates from it has its fields in a different order. Save the file, and do not run make sqlc.

sqlc diff

It exits non-zero and prints a unified diff of the generated files under internal/db — the committed version on one side, what the generator would produce now on the other. make audit stops at that step for the same reason.

Now regenerate and confirm the net goes quiet:

make sqlc
sqlc diff      # prints nothing, exits 0
git status     # shows the regenerated files as modified

Either commit the regenerated code alongside the query change, or revert both. What you must never do is commit one without the other — which is exactly the mistake the check exists to make impossible.

Verification: push the query change without the regenerated code on a branch, and the PR’s audit check fails at sqlc up to date. That is the mechanical version of a code-review comment nobody would have caught.

Exercise 3 — Close Chapter 7’s loop, in both places at once

Chapter 7 said sqlc vet “joins CI in Chapter 26” and it never did. Add it — to the Makefile and to the workflow, in one commit — and then write your rollback command somewhere you will find it in an emergency.

Solution

Two files, one commit. First the Makefile, adding the check next to its sibling:

# Makefile — the audit target, with sqlc vet added
.PHONY: audit
audit:
	@test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
	go vet ./...
	go run honnef.co/go/tools/cmd/staticcheck@latest ./...
	go run golang.org/x/vuln/cmd/govulncheck@latest ./...
	sqlc diff
	sqlc vet
	go test -race -count=1 ./...

Then the workflow, in the same position in the sequence:

# .github/workflows/audit.yml — add this step after "sqlc up to date"
      - name: sqlc lint rules
        run: |
          go run github.com/sqlc-dev/sqlc/cmd/sqlc@latest vet

Commit both together:

git add Makefile .github/workflows/audit.yml
git commit -m "ci: run sqlc vet in audit, locally and in CI"

Verification: make audit runs the new check locally, and the pushed run shows a sqlc lint rules step. If sqlc vet complains about a query, read the rule it names in your sqlc.yaml; it is checking your queries against the rules configured there.

Second half — the 2 a.m. note. Add this to learnings/ch26.md:

ROLLBACK taskd
  1. git log --oneline        -> find the last good commit SHA
  2. ssh deploy@<host>
  3. cd /opt/taskd
  4. TAG=<sha> docker compose up -d
  5. curl -s https://<host>/v1/healthcheck   -> version must equal <sha>
  CAVEAT: this rolls back code only. If the bad release ran a migration
  that changed the schema, stop and think before step 4.

A runbook you wrote calmly is worth ten times a command you improvise in a panic.


12. FAQ

Do I really need CI for a solo project?

You need the audit half more than a team does, because nobody else is going to notice your mistakes. The deploy half is genuinely optional for a solo project — many people happily run one SSH command by hand for years. But the sequence “I’ll just push this small fix directly” is how solo projects break, and a robot that says no costs you nothing per day.

What does GitHub Actions cost?

For public repositories, Actions minutes are free. For private ones, the free plan includes a monthly allowance of runner minutes and then charges per minute, with Linux runners the cheapest by a wide margin — check GitHub’s current pricing page rather than trusting a number printed in a book. Two practical consequences: keep the pipeline short (it is also your patience budget), and use ubuntu-latest, which we do. Container storage in GHCR is free for public packages and metered for private ones, which is a second reason docker image prune matters.

Can I deploy without SSH?

Yes, and several ways are better at scale. A platform like Fly, Render or Railway takes the image you pushed and runs it, with the deploy triggered by an API call from a workflow step; a Kubernetes cluster watches the registry or takes a kubectl command. All of them replace the deploy job only — the audit workflow, the image build and the SHA tagging are unchanged. That is the whole reason this chapter separates build-push from deploy.

What if the deploy fails halfway?

Look at where it stopped. If docker compose pull failed, nothing changed and the old container is still serving — the safest kind of failure. If up -d started the new container and the app then crashes on boot, Compose leaves it in a restart loop and your health check goes red: that is the case for rolling back by tag. The genuinely bad case is a migration that applied before the app failed, because the migrate service runs first and does not undo itself. That is the argument for backwards-compatible migrations, and it is why the depends_on: service_completed_successfully ordering from Chapter 25 exists — migrations are a separate, individually retryable operation rather than something buried in application startup.

Why is my pipeline slower than my patience?

The usual causes, in order: the tool downloads (staticcheck, govulncheck, sqlc, migrate are fetched and compiled on a fresh machine every run — cache: true helps but does not eliminate it), the race detector (-race makes tests several times slower and is worth it), and the image build. The first fix is to be sure the checks are ordered cheapest-first, as ours are, so the common failures fail fast. If it is still slow, that is the moment to pin exact tool versions and cache their binaries — not the moment to delete a check.

Do I need Kubernetes for zero-downtime deploys?

No. The mechanism zero-downtime needs is: start the new version, wait for it to report healthy, move traffic, then drain and stop the old one. A reverse proxy in front of two containers can do that, and Chapter 4’s graceful shutdown plus Chapter 25’s stop_grace_period: 35s already provide the draining half. Kubernetes automates it, along with a hundred other things you do not have. Take the blip until it costs you something measurable; the day it does, the fix is a proxy, not a cluster.


13. Where we are

The robot exists. Nothing unformatted, unvetted, untested or known-vulnerable reaches main by accident any more, and everything that does reach main becomes an image with a name that can only mean one thing.

Concretely: opening a pull request — or pushing to main — runs six gates against a real Postgres 17. Merging to main builds Chapter 25’s image, stamps the commit SHA into the binary, pushes it to GHCR under both the SHA and latest, and — if you built the second half — SSHes into your server and rolls the stack to that exact image. /v1/healthcheck then reports the SHA back to you, which turns “what is running?” from an investigation into a curl.

The repo as it now stands

taskd/
├── .github/
│   └── workflows/
│       ├── audit.yml        # NEW: six gates on every push and PR
│       └── release.yml      # NEW: build-push to GHCR, then deploy over SSH
├── cmd/api/
│   ├── main.go          server.go       routes.go
│   ├── config.go        db.go           middleware.go
│   ├── helpers.go       errors.go       context.go
│   ├── healthcheck.go   tasks.go        users.go
│   ├── tokens.go        billing.go      webhooks.go
│   ├── accounts.go      background.go   entitlements.go
│   ├── metrics.go       docs.go
│   ├── docs/openapi.yaml
│   └── *_test.go
├── internal/
│   ├── cache/  data/  db/  mailer/  validator/
├── migrations/          # 000001 … 000007, up + down
├── sql/queries/
├── Dockerfile   .dockerignore
├── Makefile             # UPDATED: the audit target
├── sqlc.yaml   config.toml   docker-compose.yml   prometheus.yml
└── go.mod   go.sum

Two files that are not in this tree, on purpose: the production docker-compose.yml and the .env beside it. Both live only at /opt/taskd/ on the server. Keeping the production Compose file in the repo instead is a perfectly common choice; this book keeps it on the server because it sits next to a file full of live credentials, and because Appendix A’s tree stays a description of the software rather than of one particular host.

What works end to end: commit → six gates → image tagged by commit SHA in GHCR → SSH → compose pull and up → the healthcheck endpoint reports that SHA. Rollback is one command with an older SHA.

What is still fake or missing:

  • No HTTPS. The API is published on port 4000 over plain HTTP. Chapter 27 puts Caddy in front, terminates TLS and firewalls the port.
  • The blip. compose up -d stops the old container before the new one is serving. Seconds of failed requests per deploy, honestly accounted for.
  • The host itself is assumed. Creating the server, DNS, OS updates, firewall rules and backups are not in this book’s scope beyond Chapter 27’s checklist.
  • Schema rollback is undefined. Rolling back code is one command; rolling back a migration is a decision you must make before you deploy it.
  • Actions are not pinned to SHAs. The pitfall names it; doing it is left to you, starting with the SSH action.

For your notes

Copy these into learnings/ch26.md, in your own words:

  1. A pipeline you cannot run locally is a pipeline you debug by commit spam. make audit is not a convenience — it is the constraint that keeps the loop at ten seconds instead of three minutes. Any check added to one is added to the other, in the same pull request.
  2. The SHA tag is the rollback mechanism. latest means “whatever was pushed most recently”, which makes “what is live?” a guess and makes rollback impossible. Deploy by commit SHA and rollback is TAG=<old-sha> docker compose up -d — but code only, never schema.
  3. Five of the six gates never run your code. Static analysis is cheap and blind; the tests are expensive and the only ones that know what the software is supposed to do. Do not let a green pipeline of skipped tests reassure you.
  4. The service container is what makes CI honest. Tests hit the same Postgres 17 as your laptop and your server, so “works on my machine” and “works” become the same sentence.
  5. Blast radius is a design decision. A dedicated deploy user with one key means a leaked CI secret costs you one Compose stack. Root means it costs you the machine.

Chapter 27 — Going live: TLS, the production checklist, and where to go next

Chapter 26 gave you a robot that builds an image and pushes it to a server. That is “deployed”. It is not yet “in production”, and the gap between those two words is where most first launches get hurt: no encryption, a database port open to the internet, a rate limiter that thinks the whole world is one visitor, backups nobody has ever restored, and no alarm to wake you when it all stops. This chapter closes that gap item by item, with real files and real commands, and then ends the book honestly — with a map of what it did not teach you and what to read next.

What you’ll be able to do by the end

  • Put a real HTTPS address in front of taskd, with a certificate that renews itself, and explain every hop the request takes to get there.
  • Say exactly which ports on your server the internet can reach, and prove it.
  • Run a backup, destroy a database on purpose, and bring it back from that backup.
  • Write five Prometheus alert rules with thresholds and durations, and defend why there are five and not fifty.
  • Break your own system in four controlled ways and watch it degrade the way this book claimed it would.

Time: ~55 minutes reading, ~2 hours doing — much of which is waiting for DNS and certificates.

You need before starting: a working Chapter 26 (CI/CD: the robot that says no) — an image in GHCR, tagged by commit SHA, and a server you can SSH into. Locally, one command proves the stack still runs:

docker compose up -d --build
curl -i localhost:4000/v1/healthcheck

You should get HTTP/1.1 200 OK and a single line of JSON containing "status":"available".

Note

This chapter costs money — a small server (about five dollars a month) and a domain name (about ten dollars a year). If you would rather not spend it yet, everything except the certificate parts works on your laptop: the backups, the restore drill, the alert rules and all four operational drills. The steps say which is which.


1. The problem, in plain words

Right now, if you typed your server’s address into a browser, three things would be true.

First, every byte between the user and you travels in the clear. Their password on the way to POST /v1/tokens/authentication, and the 26-character token that comes back, are readable by every network between them and you — the café wi-fi, their internet provider, whoever runs the cable. Modern browsers now say so, in the address bar, in words your customers understand.

Second, your database is on the public internet. Chapter 25’s Compose file publishes port 5432 so that go run on your laptop can reach it. On a laptop that is convenient. On a server with a public IP address it means that within a few hours — this is not an exaggeration, it is what internet background noise does — automated scanners will find it and start guessing your password.

Third, if that one machine’s disk dies tonight, your customers’ data is gone. Not degraded. Gone. And a service that has taken money for a subscription and lost the data has a problem no amount of good code fixes afterwards.

Why this exists

Everything in this chapter exists because production has one property your laptop does not: it is exposed to strangers and to bad luck, continuously, while you sleep. Each item below is a specific stranger or a specific piece of bad luck, and the smallest thing that stops it.

There is a fourth problem, quieter than the others, and it is the one that will bite you first. Chapter 14 (Rate limiting) built a per-IP limiter, and Chapter 14’s own pitfall warned you: the moment a proxy sits in front of your app, every request appears to come from the proxy. One “IP” absorbs the entire limit, and either everybody gets rate-limited or nobody does. That loop was left deliberately open. It closes here, and closing it correctly is more subtle than the one line of advice the original book gave.

What breaks if you skip this chapter: nothing, for a while. Then all of it at once, on a day you did not choose.


2. New words in this chapter

Word What it means here
TLS / HTTPS The encryption layer that makes web traffic private and tamper-evident; the s in https.
certificate A file proving that a particular name (api.example.com) belongs to whoever holds a matching secret key.
certificate authority (CA) An organisation browsers already trust, which signs certificates for names it has verified.
Let’s Encrypt The free certificate authority Caddy uses to issue HTTPS certificates automatically.
ACME The protocol a CA and your server speak to prove you control a name and issue a certificate, with no human involved.
TLS termination Decrypting HTTPS at the front door so the internal app can speak plain HTTP on a private network.
reverse proxy A server in front of your app that receives all public traffic and forwards it inward, usually terminating HTTPS.
Caddy A small web server used here as the reverse proxy, notable for obtaining HTTPS certificates automatically.
Caddyfile Caddy’s configuration file: a site address, then the directives that apply to it.
VPS Virtual Private Server — a rented virtual machine from a hosting provider, where this app is deployed.
DNS A record The entry that maps a name like api.example.com to an IPv4 address.
firewall A rule set on the server deciding which incoming ports are answered and which are ignored.
publishing a port Telling Docker to forward a port on the host machine into a container. Unpublished means unreachable from outside.
X-Forwarded-For The header a proxy adds naming the real client IP; only trustworthy when a proxy you control is the only route in.
trusted proxy A proxy whose headers you have decided to believe, because you control it and nothing else can reach the port.
middleware.RealIP Chi middleware that replaces the proxy’s address with the real client IP from X-Forwarded-For — safe only behind a trusted proxy.
basic auth The simplest HTTP authentication: a username and password sent with each request; one option for protecting /metrics.
HSTS A response header telling browsers “never speak plain HTTP to this name again, for N seconds”.
pg_dump PostgreSQL’s backup tool: reads a database and writes a file you can restore from.
restore drill Actually restoring a backup somewhere harmless, on a schedule, to prove the backup works.
retention How many old backups you keep, and when you delete them.
RPO / RTO Recovery Point Objective (how much data you can afford to lose) and Recovery Time Objective (how long you can afford to be down).
cron The Unix scheduler: a table of “run this command at this time”.
dead man’s switch A monitor that alerts when an expected check-in fails to arrive — how you find out a backup job silently stopped.
shared_buffers Postgres’s main memory cache setting, conventionally about 25% of RAM.
max_connections Postgres’s hard ceiling on simultaneous connections across every app instance, tool and backup job.
alert rule A saved query with a threshold and a duration that notifies a human when it’s true.
threshold / for duration The number the query must cross, and how long it must stay crossed before anyone is told.
operational drill Deliberately breaking something in a controlled way to check the system degrades as designed.
zero-downtime deploy Replacing a running version with no interruption, by starting the new one before stopping the old.
orchestrator Software that runs containers across machines and restarts them for you — Kubernetes, Nomad. Beyond this book.
12-factor A well-known twelve-point checklist for services that behave well in containers; “config comes from the environment” is factor three, and Chapter 3 built it.

3. The goal

Close the remaining gaps between “deployed” and “production”, each with the decision framework that produced it — TLS, real client IPs, /metrics exposure, CORS, secrets, backups, Postgres tuning, alerts — then an honest map of the extensions this book leaves you equipped to build.

Concretely, by the end of the steps your server runs six containers instead of five, exactly two ports on it answer the internet, https://api.yourdomain.com/v1/healthcheck returns 200 with a padlock, a backup runs nightly and has been restored at least once by you, and five alert rules are loaded and evaluating.


4. The thinking

4.1 Where TLS is terminated, and why not in Go

Go can serve HTTPS directly. http.Server has ListenAndServeTLS, and there are libraries that fetch Let’s Encrypt certificates from inside your process. So why put another program in front?

Because certificates are an operational job, not an application job. They expire every ninety days, they need a challenge answered on port 80 or 443 at renewal time, they need somewhere durable to store keys, and when renewal fails you want that failure to be visible in a component that does nothing else. Bolting all of that into the process that also serves your API means a certificate bug is an API outage, and an API restart is a certificate risk.

There is a second reason, and it is the one the original book leans on: the front door is where edge concerns belong. Compression, HTTP/2 and HTTP/3, request-size limits, redirecting plain HTTP to HTTPS, and refusing to route /metrics to the public — all of it is configuration in a proxy and code you would otherwise have to write and maintain.

Option What you get What it costs
TLS inside the Go binary One process; no extra container Certificate renewal and storage become your app’s problem; every edge feature is code you write
Caddy (chosen) Automatic certificates, automatic HTTP→HTTPS redirect, sane TLS defaults, two-line config One more container; one more thing to learn
nginx + certbot The most widely deployed pairing; every problem already has a Stack Overflow answer Certificates are a separate program on a separate schedule; more config, more moving parts
A cloud load balancer Someone else runs it; scales past one machine Monthly cost, vendor lock-in, and your local setup stops resembling production

Caddy’s distinguishing property is that automatic HTTPS is the default rather than a feature you enable. You give it a site address; it obtains and renews the certificate. That is the entire reason it is here.

4.2 The trust boundary, stated once

Once a proxy is in front, r.RemoteAddr — the address Go reports for the connection — is the proxy, on every request. Chapter 14’s limiter and Chapter 19’s logs both read it, and both now see one address forever.

The standard fix is a header: the proxy writes the real client IP into X-Forwarded-For, and the app reads that instead. The danger is equally standard. A header is text a client can type. If anything other than your proxy can reach the app’s port, an attacker sends X-Forwarded-For: 1.2.3.4, gets counted as a different visitor on every request, and walks past your rate limiter.

Remember this

X-Forwarded-For is only trustworthy when two things are true at once: your proxy overwrites it rather than believing what the client sent, and the app’s port is unreachable except through that proxy. One without the other is a hole, not a fix.

That is why “firewall port 4000 from the world” is not a footnote in this chapter. It is the precondition that makes the header safe to believe at all.

4.3 Backups: how much loss, and how long down

Two numbers decide your backup strategy, and both are business decisions rather than technical ones. RPO is how much data you can afford to lose — with a nightly dump at 03:00, a disk failure at 22:00 loses nineteen hours of work. RTO is how long you can afford to be down while restoring.

Strategy RPO RTO What it costs
Nightly pg_dump, off-box (chosen) Up to 24 hours Minutes to an hour, once you have practised One cron job; a script; a scheduled restore test
Continuous archiving (point-in-time recovery) Seconds Longer and more fiddly — you replay a log Real setup effort, more storage, more to understand
Managed Postgres (your host runs it) Seconds to minutes Their number, in their support queue Monthly cost; the database leaves your Compose file

For a service at this size, nightly dumps are the right amount of machinery. What is not optional, at any size, is the sentence this chapter is built around:

Remember this

An untested backup is a hope, not a backup.

DragonflyDB needs no backup at all, and that is not luck. Every cache call site in this book was written to survive the cache being absent — Chapter 13 called it “best-effort by policy”, and Chapter 17’s entitlements fall back to a Postgres read. Everything in Dragonfly is reconstructible by design, so losing it costs latency, not data.

4.4 Five alerts, chosen not accumulated

Every metric you exported in Chapter 18 (Prometheus) could have an alert. Almost none should.

An alert that fires and requires no action trains you to ignore alerts, including the one that mattered. The discipline is the same as the check list in Chapter 26: choose, don’t accumulate. The original’s set is five rules, and each one answers a different question a human would actually act on: is it up, is it erroring, is it slow, is it starved of database connections, is it about to run out of disk.

Remember this

More alerts than you’ll act on is worse than fewer.


5. A picture of it

Here is what changes. On the left, the shape you have been running since Chapter 5. On the right, the shape after this chapter — the same containers, one new one in front, and a very different answer to “what can the internet reach?”

  DEV (your laptop)                PRODUCTION (one VPS)

    curl                             browser / curl
      │  http, port 4000                  │  https, port 443
      ▼                                   ▼
  ┌──────────┐                     ┌────────────────────┐
  │ taskd    │                     │ Caddy              │ the ONLY container
  │ :4000    │                     │ TLS ends here      │ with published
  └────┬─────┘                     │ :80  :443          │ ports
       │                           └─────────┬──────────┘
       ▼                                     │ http, private network
  ┌──────────┐                               │ api:4000
  │ Postgres │                               ▼
  │ :5432    │                     ┌────────────────────┐
  └──────────┘                     │ taskd              │ no published port:
                                   │ :4000              │ unreachable from
   ports 4000, 5432, 6379,         └─────────┬──────────┘ outside the host
   9090 published to the                     │
   host, because that is         ┌───────────┼───────────┐
   convenient on a laptop        ▼           ▼           ▼
                            ┌────────┐ ┌──────────┐ ┌──────────┐
                            │Postgres│ │Dragonfly │ │Prometheus│
                            └────────┘ └──────────┘ └──────────┘
                             no published ports at all

Walking the production path, right-hand side, top to bottom:

  1. The browser resolves api.yourdomain.com to your server’s IP through DNS, and opens a TLS connection to port 443.
  2. Caddy answers. It already holds a certificate for that name, so the browser sees a padlock. TLS ends here: from this point inward, traffic is plain HTTP on a private network only these containers can see.
  3. Caddy decides what to do with the path. /metrics gets a 404 and goes no further. Everything else is forwarded to api:4000api is the Compose service name, which Docker’s internal DNS resolves to the container.
  4. taskd handles the request exactly as it always has. It does not know or care that TLS happened.
  5. Postgres, Dragonfly and Prometheus are reachable only from inside this private network. Nothing on the internet can open a connection to any of them.
Think of it like

Caddy is the reception desk of an office building. Visitors arrive at one entrance, hand over their sealed envelope, and reception opens it and walks the contents to the right internal desk. The desks have no doors to the street. If reception says “there is no such department”, the visitor never learns whether there is.


6. The steps

There are fourteen steps, in three parts. Part A puts the front door on. Part B is the work that saves you at three in the morning. Part C is where you break things on purpose.

Part A — The server and the front door

Step 1 — Get a server, and point a name at it

Any provider that rents you a Linux virtual machine works: Hetzner, DigitalOcean, Vultr, Linode, Scaleway, OVH. What you want is the smallest useful box: 2 vCPU, 4 GB RAM, and 40 GB of disk, running Ubuntu 24.04 LTS. That is enough to run everything in this book with room to spare.

New word

LTS — “long-term support”. An Ubuntu release that gets security updates for five years, so your server does not need reinstalling next spring.

Create the machine with your SSH public key attached — every provider offers this in the creation form, and it means you never type a password to log in. Then, from your laptop:

ssh root@YOUR_SERVER_IP

Create a non-root user for deploys, and put it in the docker group so it can run containers. Chapter 26’s deploy workflow logs in as this user.

adduser --disabled-password --gecos "" deploy
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Line by line: adduser --disabled-password makes an account with no password at all — the only way in is the SSH key, which is what you want. --gecos "" skips the interactive questions about the user’s full name and phone number. Copying authorized_keys gives the new user the same key you just used. The chmod numbers are not decoration: SSH refuses to use a key directory that other users on the machine can read, and 700/600 mean “owner only”.

Install Docker (the official convenience script; read it first if you are cautious, it is plain shell), add deploy to the docker group, and make a home for the stack:

curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker deploy
mkdir -p /opt/taskd
chown deploy:deploy /opt/taskd

Now DNS. In your domain registrar’s control panel, add an A record: name api, value your server’s IPv4 address. That is the entry that turns api.yourdomain.com into a number. Check it from your laptop:

dig +short api.yourdomain.com

What you should see: your server’s IP address, on a line by itself. If you see nothing, the record has not propagated yet — this takes anywhere from seconds to a couple of hours depending on your registrar. Do not continue until this prints the right address; Caddy cannot get a certificate for a name that does not point at it.

Warning

If your DNS provider offers a proxy or “orange cloud” mode (Cloudflare’s, most commonly), turn it off for this record while you set up. Proxied records terminate TLS at the provider, which means Caddy’s certificate challenge is answered by someone else and fails in a way that is genuinely hard to read.

Step 2 — Close the doors

Ubuntu ships ufw, a friendly front end to the kernel’s firewall. The policy you want is: deny everything incoming, then allow exactly three things.

ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose

ufw allow OpenSSH uses a named profile for port 22 — do this before ufw enable, or you will lock yourself out of the machine you are typing on. Port 80 is not optional even though everything real happens on 443: Let’s Encrypt’s challenge arrives on port 80, and Caddy’s automatic HTTP-to-HTTPS redirect answers there.

What you should see: ufw status verbose prints Status: active, a default policy line reading deny (incoming), and a short table listing 22, 80 and 443 as ALLOW IN.

Now the part that catches nearly everyone.

Warning

Docker publishes ports around ufw. When a container publishes a port, Docker writes its own firewall rules that are consulted before ufw’s. A container publishing 5432:5432 is reachable from the internet even though ufw status swears port 5432 is denied. You cannot fix this by adding a ufw deny rule; the reliable fix is to not publish the port at all, which is exactly what Step 4 does.

If you want to see this for yourself, it is one command from your laptop against the dev-style compose file: nc -vz YOUR_SERVER_IP 5432. A successful connection to a database you believed was firewalled is a lesson that sticks.

Step 3 — Write the Caddyfile

Here is the original book’s entire TLS configuration, and it is worth pausing on how little there is:

api.yourdomain.com {
    reverse_proxy localhost:4000
}

Two meaningful lines. The first is the site address; naming it tells Caddy to obtain a certificate for it. The second forwards every request to an app listening on port 4000 on the same machine. When Caddy runs directly on the server as a system service, that is a complete production config.

We are running Caddy in a container, so one word changes. Inside Caddy’s container, localhost means Caddy itself — containers each have their own loopback address. The app is a different container, reachable by its Compose service name:

# /opt/taskd/Caddyfile — new file, on the server
{
	# Let's Encrypt emails this address if a certificate is about to
	# expire and renewal keeps failing. Use one you read.
	email you@yourdomain.com
}

api.yourdomain.com {
	# ch. 18 deferred this decision to ch. 27. Decided: /metrics is
	# internal. Caddy answers 404 and never forwards it, so the outside
	# world cannot learn our route patterns, versions or traffic shape.
	handle /metrics {
		respond 404
	}

	handle {
		reverse_proxy api:4000 {
			# The trust boundary, made explicit. Delete anything the
			# client sent that chi's RealIP would believe, then set
			# X-Forwarded-For to the address Caddy actually sees.
			header_up -True-Client-IP
			header_up -X-Real-IP
			header_up X-Forwarded-For {remote_host}
		}
	}
}

What this config says, line by line

  • The block in braces at the top, before any site address, is Caddy’s global options. email is the ACME account address.
  • api.yourdomain.com { ... } is a site block. Because the address is a real hostname (not localhost, not an IP), Caddy turns on automatic HTTPS for it: it obtains a certificate, renews it, and redirects plain HTTP on port 80 to HTTPS.
  • handle /metrics { respond 404 } matches that exact path and answers it directly. handle blocks are mutually exclusive — the most specific match wins and the others are skipped — so a request for /metrics never reaches the reverse_proxy line below.
  • respond 404 sends a bare 404 with no body. To an outsider, /metrics is indistinguishable from a path that was never registered.
  • header_up modifies headers on the request going up to the app. A - prefix deletes a header.
  • {remote_host} is a Caddy placeholder: the IP address of whoever opened the connection to Caddy.

Those three header_up lines are the most important lines in this chapter, and they are here because of a detail the original leaves implicit. Chi’s RealIP reads three headers in order: True-Client-IP, then X-Real-IP, then the first entry in X-Forwarded-For. A proxy that appends to X-Forwarded-For rather than replacing it leaves the client’s own value in first position — which is precisely the value RealIP picks up.

  What the attacker sends:
      X-Forwarded-For: 1.2.3.4        (a lie)

  A proxy that APPENDS produces:
      X-Forwarded-For: 1.2.3.4, 198.51.100.7
                       ^^^^^^^ chi's RealIP takes THIS one

  A proxy that REPLACES (our config) produces:
      X-Forwarded-For: 198.51.100.7
                       ^^^^^^^^^^^^ the truth
Common mistake

You’ll see: rate limits that never trigger for an abusive client, while ordinary users get 429 at random. It means: the client is choosing its own identity via a forwarded header your proxy passed through untouched. Fix: the three header_up lines above. Do not rely on remembering which default your proxy version shipped with — state it in the config, where you can read it.

Tip

Prefer basic auth over a 404 for /metrics? Replace the handle /metrics block’s body with basic_auth { prometheus <bcrypt-hash> } followed by reverse_proxy api:4000, generating the hash with docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password'. Note that Caddy renamed this directive from basicauth to basic_auth; if your Caddy is older, use the older spelling. Our Prometheus scrapes over the private network and never goes through Caddy, so it needs no credentials either way — which is why the 404 is the simpler choice here.

Step 4 — The production Compose file

Chapter 26 said the server-side Compose file “differs from dev in three lines”. Here it is in full, with those three differences plus the ones this chapter adds. This is a new file that lives only on the server, at /opt/taskd/docker-compose.yml. Keep a copy in your repository under deploy/ so it is version-controlled — it contains no secrets, by design.

# /opt/taskd/docker-compose.yml — new file; production only.
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: taskd
      # ${VAR:?msg} = fail loudly at `docker compose up` if VAR is unset,
      # rather than booting a database with a blank password.
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: taskd
    volumes:
      - db-data:/var/lib/postgresql/data
    command:                      # the two knobs from "The thinking", 4.5
      - "postgres"
      - "-c"
      - "shared_buffers=1GB"      # ~25% of a 4 GB machine
      - "-c"
      - "max_connections=100"     # the default, written down on purpose
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskd -d taskd"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  cache:
    image: docker.dragonflydb.io/dragonflydb/dragonfly
    ulimits:
      memlock: -1
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  migrate:
    image: migrate/migrate:v4.18.1
    volumes:
      - ./migrations:/migrations:ro
    command:
      - "-path=/migrations"
      - "-database=postgres://taskd:${POSTGRES_PASSWORD}@db:5432/taskd?sslmode=disable"
      - "up"
    depends_on:
      db:
        condition: service_healthy

  api:
    # DIFFERENCE 1: no `build: .` — the server never compiles anything.
    # It runs exactly the image CI built, identified by commit SHA.
    image: ghcr.io/yourname/taskd:${TAG:-latest}
    # DIFFERENCE 2: no `ports:` at all. Nothing outside this Compose
    # network can open a connection to :4000. This is what makes
    # X-Forwarded-For safe to believe.
    env_file: .env                # DIFFERENCE 3: secrets, from one file
    environment:
      TASKD_APP__ENV: production
      TASKD_DB__DSN: postgres://taskd:${POSTGRES_PASSWORD}@db:5432/taskd?sslmode=disable
      TASKD_CACHE__ADDR: cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    stop_grace_period: 35s        # ch. 4's 30s drain, plus headroom
    restart: unless-stopped

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"                   # ACME challenge + HTTP→HTTPS redirect
      - "443:443"                 # the actual service
      - "443:443/udp"             # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data          # certificates live here — MUST persist
      - caddy-config:/config
    depends_on:
      - api
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./alerts.yml:/etc/prometheus/alerts.yml:ro
      - prom-data:/prometheus     # keep history across restarts
    ports:
      - "127.0.0.1:9090:9090"     # loopback only: reachable via SSH tunnel
    restart: unless-stopped

volumes:
  db-data:
  caddy-data:
  caddy-config:
  prom-data:

What changed, and why — the dev file from Chapter 25 against this one:

Dev (Chapter 25) Production (here) Why
api: build: . image: ghcr.io/…:${TAG:-latest} The server runs the artefact CI tested. Rollback is TAG=<old-sha> docker compose up -d.
api: ports: ["4000:4000"] removed The precondition for trusting X-Forwarded-For, and one fewer thing on the internet.
db: ports: ["5432:5432"] removed The single most valuable line to delete on any server.
cache: ports: ["6379:6379"] removed Same reason. Dragonfly has no password set.
prometheus: ports: ["9090:9090"] "127.0.0.1:9090:9090" Reachable by SSH tunnel, not by the world.
Secrets inline as ${STRIPE_SECRET_KEY:-} env_file: .env One file, mode 600, never in git and never in the image.
No restart policy restart: unless-stopped The server will reboot. The stack should come back without you.
No Postgres command: shared_buffers, max_connections Step 12.
No Caddy caddy service TLS.
Named volumes: db-data plus caddy-data, caddy-config, prom-data Certificates and metric history survive docker compose down.
mailpit under the dev profile absent Production points TASKD_SMTP__HOST at a real relay.
Warning

caddy-data is not optional bookkeeping. It holds your certificates and your ACME account key. Lose it on every deploy and Caddy re-requests a certificate every time — and Let’s Encrypt caps how many identical certificates you can be issued in a week at a small number. Blow through it and your site has no certificate until the window resets. While experimenting, point Caddy at the staging CA by adding acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to the global options block; staging certificates are not trusted by browsers, but the limits are generous and the mistakes are free.

Two files still have to reach the server: the migrations/ directory (the migrate service mounts it) and prometheus.yml. From your laptop:

scp -r ./migrations deploy@YOUR_SERVER_IP:/opt/taskd/
scp ./prometheus.yml deploy@YOUR_SERVER_IP:/opt/taskd/
scp ./deploy/Caddyfile ./deploy/docker-compose.yml deploy@YOUR_SERVER_IP:/opt/taskd/
Note

Chapter 26’s deploy job pulls a new image but does not copy migration files. That is a real gap: the day you add a migration, you must scp the folder again before the deploy, or the migration container will run the old set and the new code will meet the old schema. Automating it is one more step in the release workflow (appleboy/scp-action before the SSH step). Until you add it, put it on the checklist you keep next to the deploy.

Also update prometheus.yml to load the rules we write in Step 13:

# prometheus.yml — add the rule_files block
rule_files:
  - /etc/prometheus/alerts.yml

scrape_configs:
  - job_name: taskd
    scrape_interval: 15s
    static_configs:
      - targets: ["api:4000"]

Step 5 — Tell taskd the truth about client IPs

This is the line Chapter 14 promised and no chapter ever printed. It goes in routes.go, as the very first middleware — outermost of all — because both logRequest and rateLimitIP read r.RemoteAddr and must see the corrected value.

// cmd/api/routes.go — add the import and ONE line at the top of routes()
import (
	"net/http"

	"github.com/go-chi/chi/v5"
	chimw "github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
)

func (app *application) routes() http.Handler {
	r := chi.NewRouter()

	// Rewrites r.RemoteAddr from the forwarded headers. Safe ONLY because
	// Caddy is the sole route to :4000 (Step 4) and overwrites those
	// headers (Step 3). Remove this line the moment either stops being true.
	r.Use(chimw.RealIP)

	r.Use(secureHeaders)
	// ... the rest of routes() is unchanged

What this code says, line by line

  • chimw is an import alias. Chi’s middleware package is called middleware, and this project already has a file called middleware.go; the alias keeps the two visually distinct. The file cmd/api/middleware.go already imports it under exactly this name.
  • r.Use(...) registers middleware. Registration order is wrapping order, outermost first — so putting RealIP above secureHeaders puts it outside everything.
  • RealIP looks for True-Client-IP, then X-Real-IP, then the first address in X-Forwarded-For. If it finds one that parses as an IP address, it assigns it to r.RemoteAddr. Otherwise it changes nothing.

Now the part you must know before you deploy this, because the original book’s one-line prescription omits it.

Warning

RealIP writes a bare IP address into RemoteAddr203.0.113.9, with no port. But rateLimitIP (Chapter 14) starts with net.SplitHostPort(r.RemoteAddr), which requires the host:port form Go normally puts there. Given a bare IP it returns the error address 203.0.113.9: missing port in address, and the middleware calls app.serverErrorResponse — a 500 on every rate-limited route, for every real user, the moment a proxy is in front.

Note

The original chapter prescribes mounting middleware.RealIP and does not mention this interaction, so this edition prints the line as the original specifies it and flags the consequence rather than quietly rewriting Chapter 14’s limiter. Practice exercise 2 walks you through reproducing the 500 in ten seconds on your laptop and making the limiter tolerate both forms — three lines, no change in behaviour for anything else. Do that exercise before you ship this.

Step 6 — /metrics: the decision Chapter 18 deferred

Chapter 18 said serving /metrics on the main port is fine behind a private network if the reverse proxy never routes it publicly, and left the decision to this chapter. Step 3 made it: Caddy answers /metrics with a 404 and never forwards it.

Prometheus is unaffected, because Prometheus scrapes api:4000 directly over the private Compose network — it never goes through Caddy at all. Look again at prometheus.yml: the target is api:4000, not api.yourdomain.com.

What leaks if you get this wrong is not passwords. It is your route patterns, your deployed version, your traffic shape and your error rate — a free reconnaissance report for anyone who asks.

Step 7 — CORS and security headers: pure configuration

Both shipped as code in Chapter 23 (Hardening the edge). The production duty is configuration only.

The security-header middleware needs nothing at all: secureHeaders is global and unconditional, so X-Content-Type-Options, X-Frame-Options and Referrer-Policy are already on every response in every environment.

CORS is a decision. Populate cors.trusted_origins with the real frontend origins — and only those — or leave it empty for server-to-server deployments so no CORS headers are sent at all. routes.go mounts the CORS middleware only when the list is non-empty, which means “empty” is a real, supported configuration and not an oversight.

Note

Setting this one from the environment does not work, and it fails silently. koanf’s env provider stores a variable’s value as a plain string, and k.Strings("cors.trusted_origins") returns an empty slice for a string value — so TASKD_CORS__TRUSTED_ORIGINS=https://app.example.com leaves CORS switched off with no error anywhere. (Verified against koanf v2 and the book’s own loadConfig.) If you have a browser frontend, mount a production config.toml over the one baked into the image and put the list there: volumes: ["./config.toml:/config.toml:ro"] on the api service. Every other setting stays on the TASKD_* path, which does work.

If your API is called only by other servers — no browser JavaScript — leave the list empty, and no CORS headers are sent at all. That is the safest configuration, and it is the default.

Mini-recap of Part A. You have a server with three ports open, a proxy holding a certificate, an app with no published port, forwarded headers you can believe, and /metrics invisible from outside. What is missing is everything that happens when something goes wrong.

Part B — The things that save you at three in the morning

Step 8 — Secrets, and how they reach the server

Chapter 3 gave the rule (“real secrets never go in the repository”), Chapter 25 gave the warning (“never bake a secret into an image; every layer is inspectable forever”), and Chapter 26 said the deploy “writes a .env server-side once, out of band”. This step is that out-of-band moment, written down.

On the server, as the deploy user:

umask 077                      # anything created now is owner-only
nano /opt/taskd/.env           # or vim, or whatever you have
chmod 600 /opt/taskd/.env
ls -l /opt/taskd/.env

What you should see: a line beginning -rw------- and owned by deploy deploy. Those dashes are the point — no group, no world, no other account on the box can read your Stripe key.

The file itself:

# /opt/taskd/.env — server only. Never committed, never in the image.
POSTGRES_PASSWORD=<40+ random characters>

TASKD_STRIPE__SECRET_KEY=sk_live_...
TASKD_STRIPE__WEBHOOK_SECRET=whsec_...
TASKD_STRIPE__PRICE_ID_PRO=price_...
TASKD_STRIPE__PRICE_ID_BUSINESS=price_...
TASKD_STRIPE__SUCCESS_URL=https://app.yourdomain.com/billing/success
TASKD_STRIPE__CANCEL_URL=https://app.yourdomain.com/billing/cancel

TASKD_SMTP__HOST=smtp.your-provider.example
TASKD_SMTP__PORT=587
TASKD_SMTP__USERNAME=...
TASKD_SMTP__PASSWORD=...
TASKD_SMTP__SENDER=taskd <no-reply@yourdomain.com>

TASKD_LOG__LEVEL=info

Generate the database password with openssl rand -base64 32 and paste the result. Do not reuse pa55word; that string exists in this book so that a laptop works out of the box, and it is in every reader’s copy.

Two mechanisms, one file, and the difference matters. Docker Compose automatically reads a file named .env in the project directory and uses those values for ${VAR} substitution in the Compose file itself — that is how ${POSTGRES_PASSWORD} in the db service gets filled in. Separately, env_file: .env injects the file’s lines as environment variables inside the api container — that is how taskd sees TASKD_STRIPE__SECRET_KEY. Same file, two jobs. And where both apply, an explicit environment: entry beats env_file, which is why TASKD_DB__DSN in the Compose file wins.

Common mistake

You’ll see: docker compose up warns that a variable is not set, or the database refuses to start with a message about a blank password. It means: you put the secret in a file Compose is not reading for interpolation, or the file is not in the same directory as the Compose file. Fix: run docker compose config from /opt/taskd. It prints the fully-resolved Compose file with every ${...} substituted, which turns this class of bug into something you can see. It also prints your secrets to the terminal, so do not do that on a shared screen.

What must never be in the image, checked mechanically. Chapter 25’s .dockerignore lists .envrc, and the shipped config.toml holds dev defaults only. Verify rather than trust:

docker run --rm --entrypoint /bin/api ghcr.io/yourname/taskd:latest -help 2>&1 | head -5
docker history --no-trunc ghcr.io/yourname/taskd:latest | grep -i -E 'sk_|whsec_|password'

The second command should print nothing. If it prints a layer containing a key, that key is compromised — it is in the registry, in every pull, forever. Rotate it today.

Rotation, in the order that avoids downtime. Rotating a secret is always the same four moves, and doing them in this order means there is never a moment when no valid key exists:

  1. Create the new credential alongside the old one (Stripe, your mail provider and most services allow several active keys).
  2. Put it in /opt/taskd/.env.
  3. docker compose up -d api — Compose recreates only the container whose configuration changed.
  4. Verify, then revoke the old credential at the provider.

For the Postgres password, the order differs because there is only one: change it inside Postgres first (ALTER USER taskd WITH PASSWORD '...'), then update .env, then docker compose up -d. Expect a few seconds of failed connections between those steps; pick a quiet hour.

Warning

If a live key ever leaks — pasted into a chat, committed, printed in a log — revoking it is the first action, before the investigation. A leaked sk_live_ key can create charges. Revoke, then read the provider’s API logs to see what was done with it, then work out how it escaped.

Step 9 — Stripe in live mode

Chapter 16 (Stripe II) deferred four production tasks to this chapter. They are all dashboard configuration; no code changes.

  1. Switch off test mode and copy the live secret key (sk_live_…) into .env. Live mode has its own keys, its own products and its own price IDs — the price_… values from test mode do not exist in live mode and will fail with a “no such price” error.
  2. Register the webhook endpoint: https://api.yourdomain.com/v1/stripe/webhook. Note that it is the public HTTPS URL, terminating at Caddy and forwarded inward — Stripe cannot reach api:4000, and does not need to.
  3. Select the four event types Chapter 16 handles: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted. Anything else your endpoint answers with 200 and ignores, by design.
  4. Copy that endpoint’s signing secret (whsec_…) into .env as TASKD_STRIPE__WEBHOOK_SECRET. This is a different value from the one stripe listen printed during development. Deploying with the CLI’s secret produces signature-verification failures whose error message does not tell you why. Chapter 16 warned about this; it is the single most common Stripe deployment mistake.

Then turn on the Customer Portal in the dashboard, which gives your users a hosted page for card updates, plan changes and cancellation. Every action they take there arrives back at your webhook as customer.subscription.updated or deleted — already handled.

Note

Chapter 16 also promised a POST /v1/billing/portal handler to send users to that portal, and Appendix A lists it. Enabling the portal in the dashboard is the configuration half; the handler is application code, and it belongs in billing.go with Chapter 16’s other Stripe calls, not here. If yours is missing, that is the gap to close next.

Note

success_url and cancel_url are where Stripe redirects the customer’s browser after checkout. They belong to your web frontend, not to your API — taskd registers no /v1/billing/success route, so pointing them at the API sends a paying customer to a 404 JSON error page. Point them at real pages you control.

Step 10 — Backups that exist

pg_dump reads a database and writes a file. Run it against the container:

cd /opt/taskd
docker compose exec -T db pg_dump -U taskd -d taskd -Fc --no-owner --no-privileges \
  > /opt/taskd/backups/manual-test.dump

Every flag, decoded

Flag What it does
exec -T Run a command in the running db container with no terminal attached. Without -T Docker allocates a pseudo-terminal, which mangles binary data on its way to the file. Omitting it is the most common way to produce a backup that cannot be restored.
-U taskd Connect as the taskd role.
-d taskd Dump the taskd database.
-Fc Format: custom. A compressed archive that pg_restore can restore selectively and in parallel. The alternative, plain SQL, is human-readable and much larger; custom is the better default.
--no-owner Do not record which role owns each object, so the dump restores cleanly into a database with different role names.
--no-privileges Same idea for GRANT statements.
> Shell redirection: send the command’s standard output into this file.

What you should see: no output at all, and a new file. ls -lh shows a size in kilobytes for a small database. A zero-byte file means the dump failed and the redirection created an empty file anyway — always check the size.

Now the real thing: a script, because a command you have to remember is a command that stops running.

#!/bin/bash
# /opt/taskd/backup.sh — new file. chmod 700.
set -euo pipefail

STAMP=$(date -u +%Y-%m-%dT%H%M%SZ)
DEST=/opt/taskd/backups
OUT="$DEST/taskd-$STAMP.dump"

mkdir -p "$DEST"
cd /opt/taskd

# Write to .part first, rename on success. An interrupted dump must
# never look like a finished backup.
docker compose exec -T db \
  pg_dump -U taskd -d taskd -Fc --no-owner --no-privileges > "$OUT.part"
mv "$OUT.part" "$OUT"

# Off-box. A backup on the same disk as the database is not a backup;
# it is a second copy of the thing that is about to break.
rsync -a "$DEST/" backups@your-storage-host:taskd/

# Retention: 14 days locally. -mtime +14 means "last modified more
# than 14 full days ago".
find "$DEST" -name 'taskd-*.dump' -mtime +14 -delete

# Dead man's switch: this URL is pinged only if every line above
# succeeded. If the ping stops arriving, something is told about it.
curl -fsS -m 10 --retry 3 "$HEALTHCHECK_URL" > /dev/null

What this script says, line by line

  • set -euo pipefaile: stop at the first failing command; u: treat an unset variable as an error; pipefail: a pipeline fails if any stage fails. Without these three, a script keeps going after an error and reports success.
  • date -u +%Y-%m-%dT%H%M%SZ — a UTC timestamp with no colons, because colons in filenames cause trouble on other systems. Sorting the filenames sorts them by time.
  • The .part rename is atomic on the same filesystem. Either the file is complete or it is not there.
  • rsync -a copies recursively, preserving timestamps, over SSH — the same key-based login you set up in Step 1, from the server to wherever you keep backups. A rented storage box, a second VPS, or object storage via rclone: the requirement is “a different machine”, not a particular brand.
  • find -mtime +14 -delete is the retention policy in one line. Fourteen daily dumps of a small database cost very little disk.
  • The final curl is the dead man’s switch. A free service such as Healthchecks.io gives you a URL and alerts you when the expected ping does not arrive. This matters more than it looks: a backup job that fails is a job that stops pinging, and silence is exactly what you would otherwise never notice.

Make it executable and schedule it:

chmod 700 /opt/taskd/backup.sh
crontab -e

Add one line:

15 3 * * * HEALTHCHECK_URL=https://hc-ping.com/YOUR-UUID /opt/taskd/backup.sh >> /var/log/taskd-backup.log 2>&1

The five fields, left to right: minute, hour, day-of-month, month, day-of-week. 15 3 * * * means 03:15 every day. >> appends output to a log file; 2>&1 sends error output to the same place. Cron sends you nothing on success and, on most systems, mails you on failure — to a mailbox nobody reads. That is the whole argument for the dead man’s switch.

   BACKUP LIFECYCLE

   ┌──────────┐  pg_dump -Fc   ┌───────────┐   rsync    ┌────────────┐
   │ Postgres │ ─────────────▶ │ .dump on  │ ─────────▶ │ another    │
   │ (live)   │   03:15 daily  │ the box   │            │ machine    │
   └──────────┘                └─────┬─────┘            └─────┬──────┘
                                     │ find -mtime +14        │
                                     ▼                        │
                                  deleted                     │
                                                              │
   ┌────────────────┐   pg_restore into a scratch DB   ◀───────┘
   │ RESTORE DRILL  │   weekly, automatic, then a query
   │ (Step 11)      │   and then DROP the scratch DB
   └────────────────┘

Dragonfly is not in that picture, deliberately. Everything in it is reconstructible cache by design — a property this book preserved every time it wrote “best-effort”.

Step 11 — The restore drill

A backup you have not restored is a file of unknown quality. Restore it into a scratch database, on a schedule, and query it.

#!/bin/bash
# /opt/taskd/restore-test.sh — new file. chmod 700.
set -euo pipefail

cd /opt/taskd
LATEST=$(ls -1t /opt/taskd/backups/taskd-*.dump | head -1)
echo "restoring $LATEST"

# A scratch database, dropped and recreated each run.
docker compose exec -T db dropdb   -U taskd --if-exists taskd_restore_test
docker compose exec -T db createdb -U taskd taskd_restore_test

docker compose exec -T db \
  pg_restore -U taskd -d taskd_restore_test --no-owner --no-privileges < "$LATEST"

# The proof: three tables that must have rows if the dump is real.
docker compose exec -T db psql -U taskd -d taskd_restore_test -c \
  "SELECT (SELECT count(*) FROM users) AS users,
          (SELECT count(*) FROM tasks) AS tasks,
          (SELECT count(*) FROM subscriptions) AS subs;"

docker compose exec -T db dropdb -U taskd taskd_restore_test
echo "restore drill OK"

What this script says, line by line

  • ls -1t … | head -1 lists by modification time, newest first, one per line, and takes the top one — the most recent dump.
  • dropdb --if-exists does not fail when the scratch database is already gone, which is what makes the script safe to re-run.
  • pg_restore with no filename argument reads from standard input, which is what < gives it.
  • The SELECT is the actual assertion. A dump can restore without error and still be empty — you are checking for rows, not for an exit code.

What you should see: psql prints a small table with the headers users, tasks, subs, one row of numbers beneath it, and a (1 row) line. Zeros where you expect data mean the backup is worthless and you have found out on a Tuesday instead of during an outage.

Schedule it weekly:

40 4 * * 0 HEALTHCHECK_URL=https://hc-ping.com/OTHER-UUID /opt/taskd/restore-test.sh >> /var/log/taskd-restore.log 2>&1

* * 0 is Sunday. Give it its own dead man’s switch URL, so a silent restore-test failure is also noticed.

Tip

Do the drill by hand once before you automate it. The point of the first run is not the result — it is that you learn the commands while nothing is on fire. Practice exercise 3 is this drill, run against your laptop.

Step 12 — Postgres sizing: two knobs, then stop

Two settings matter before any others, and the production Compose file in Step 4 already sets both.

shared_buffers is how much memory Postgres uses for its own page cache. The convention is about 25% of the machine’s RAM — 1GB on a 4 GB server. The default is far smaller, which on a dedicated database machine means Postgres re-reads from disk work it could have kept in memory.

max_connections is the hard ceiling on simultaneous connections, and the default is 100. Chapter 6 (pgx) set your pool to 25 per instance. The arithmetic has to work across all app instances plus everything else that connects:

  instances × db.max_conns  +  headroom  ≤  max_connections

     2      ×      25       +     10     =    60   ≤   100   ✓

  headroom = psql sessions, the migrate container, pg_dump, and your
             own debugging window at the worst possible moment

Get this wrong upward and Postgres refuses new connections with too many clients already — your app is fine, your database says no. Get it wrong downward and requests queue inside the pool waiting for a free connection, which is slow rather than broken, and is exactly what taskd_pgxpool_empty_acquire_count_total counts.

Then stop. Tune further only when taskd_pgxpool_empty_acquire_count_total climbs or slow-query logs point somewhere specific. If you want those logs, add one more line to the db service’s command list — "-c", "log_min_duration_statement=250ms" — which makes Postgres log every statement taking longer than a quarter-second. That is a diagnostic, not a tuning knob: it tells you what to fix before you start guessing.

Remember this

Capacity planning by measurement, not vibes. Two knobs, then evidence.

Step 13 — Five alerts, written out

Chapter 18 ended with three PromQL queries and the observation that alert rules are the same expressions with thresholds. Here they are as a rule file.

# /opt/taskd/alerts.yml — new file. Also keep a copy in deploy/.
groups:
  - name: taskd
    interval: 30s
    rules:
      # 1. Is it up? A failed scrape is itself a signal.
      - alert: TaskdDown
        expr: up{job="taskd"} == 0
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "taskd has not answered a scrape for 2 minutes"
          runbook: "docker compose ps; docker compose logs --tail=100 api"

      # 2. Is it erroring? 5xx as a fraction of all requests.
      - alert: TaskdErrorRateHigh
        expr: |
          sum(rate(taskd_http_request_duration_seconds_count{status=~"5.."}[5m]))
            /
          sum(rate(taskd_http_request_duration_seconds_count[5m]))
            > 0.01
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "more than 1% of requests are 5xx"
          runbook: "docker compose logs api | grep '\"status\":5' — take a request id, grep it"

      # 3. Is it slow? p99 across all routes.
      - alert: TaskdLatencyHigh
        expr: |
          histogram_quantile(0.99,
            sum by (le) (rate(taskd_http_request_duration_seconds_bucket[5m])))
            > 1
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "p99 latency above 1s for 10 minutes"
          runbook: "check taskd_pgxpool_* and the cache hit ratio before touching code"

      # 4. Is the pool starved? ch. 6's promise, measured.
      - alert: TaskdPoolExhausted
        expr: rate(taskd_pgxpool_empty_acquire_count_total[5m]) > 0
        for: 10m
        labels:
          severity: ticket
        annotations:
          summary: "requests are waiting for a free database connection"
          runbook: "raise db.max_conns within the Step 12 arithmetic, or find the slow query"

      # 5. Is the disk filling? Needs node_exporter — see the note below.
      - alert: DiskAlmostFull
        expr: |
          node_filesystem_avail_bytes{mountpoint="/"}
            / node_filesystem_size_bytes{mountpoint="/"}
            < 0.2
        for: 15m
        labels:
          severity: page
        annotations:
          summary: "less than 20% disk free on /"
          runbook: "docker image prune -f; check /opt/taskd/backups retention"

What this file says, line by line

  • groups: — rules are organised in named groups, evaluated together. interval: 30s runs this group’s queries every thirty seconds.
  • expr: is the PromQL query. It “fires” whenever the expression returns any result — for a comparison like > 0.01, that means whenever the comparison is true.
  • for: 5m is the part that separates an alert from a nuisance: the expression must be true continuously for five minutes before anyone is told. A one-scrape blip is not an incident.
  • labels: are attached to the alert and are how you route it later — page versus ticket is the distinction between “wake a human” and “look at it tomorrow”.
  • annotations: are free text for the human who receives it. runbook is worth writing while you are calm; at 3 a.m. it is the difference between fixing and flailing.
  • | starts a YAML multi-line string, which lets a long PromQL expression wrap without YAML trying to interpret the punctuation.
  • status=~"5.." is a regular-expression label match: any status code starting with 5.
  • sum by (le) preserves the le (“less than or equal”) bucket label, which histogram_quantile needs. Chapter 18’s pitfall applies: drop le in the aggregation and you get confident nonsense.

Load and check them:

cd /opt/taskd
docker compose up -d prometheus
docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml

What you should see: promtool echoes the filename and reports success along with the number of rules it found. If a rule has a syntax error it names the line, which is far more pleasant than discovering it during an outage.

To look at the alerts, tunnel to Prometheus — remember, port 9090 is bound to loopback:

ssh -L 9090:localhost:9090 deploy@YOUR_SERVER_IP

Then open http://localhost:9090/alerts in your browser. Each rule appears as Inactive, Pending (the expression is true but the for duration has not elapsed) or Firing.

Note

Three honest caveats. One: rule 5 needs node_exporter, a small container that exposes machine-level metrics; without it that rule never fires because the metric does not exist. If you would rather not run it, most hosting providers offer a disk alert in their own control panel — use theirs. Two: Prometheus evaluating a rule is not the same as somebody being told. Notifications need Alertmanager (one more container and a receiver configuration) or an external checker. Three: rule 2’s expression divides by total request rate, so with no traffic at all it produces no result and cannot fire. That is usually what you want, and rule 1 covers the “nothing is happening because nothing is running” case.

The original’s advice on wiring stands: point these at whatever already pages you. Uptime Kuma and Healthchecks.io both integrate fine, and both will cover up == 0 and the cron dead man’s switch without any Prometheus at all. Start with the alerts you will act on.

Mini-recap of Part B. Secrets arrive out of band and live in one mode-600 file. Stripe points at the real endpoint. Backups run nightly, go off-box, expire after fourteen days, and get restored weekly into a scratch database that is then dropped. Postgres has its two knobs set. Five rules are loaded. Nothing so far has been tested — which is Part C.

Part C — Break it on purpose

Step 14 — The four drills

Every safety mechanism in this book has so far been asserted. Chapter 4 said in-flight requests drain on shutdown. Chapter 13 said the cache is optional. Chapter 6 said the pool reconnects. Drills are where assertions become observations. Do these once, deliberately, before reality does them to you.

  DRILL              WHAT YOU BREAK        WHAT MUST HAPPEN        VERIFIES
  ─────────────────────────────────────────────────────────────────────────
  1  cache down      stop the cache        slower, not broken;     ch. 13
                     container             X-Cache: MISS, 200s
  ─────────────────────────────────────────────────────────────────────────
  2  database down   stop the db           healthcheck 503,        ch. 6
                     container             alert fires, recovery
                                           on its own after start
  ─────────────────────────────────────────────────────────────────────────
  3  deploy under    docker compose up -d  no request loses its    ch. 4
     load            while a loop runs     answer; a short blip
  ─────────────────────────────────────────────────────────────────────────
  4  restore last    nothing — you run     row counts match        ch. 27
     night's backup  restore-test.sh       yesterday's reality

Drill 1 — kill Dragonfly under load. In one terminal, a request every half second:

while true; do
  curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
    -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks
  sleep 0.5
done

In another: docker compose stop cache. Watch the first terminal. The status code must stay 200. The time will rise, because every list now goes to Postgres and every entitlement lookup does too. Then docker compose start cache and watch it drop back. This is Chapter 13’s policy — “cache down is not app down” — observed rather than promised. Practice exercise 1 walks through it with the X-Cache header included.

Drill 2 — kill Postgres. docker compose stop db, then curl -i localhost:4000/v1/healthcheck. You should get 503 Service Unavailable and a body containing "database":"down" — the DB-aware healthcheck Chapter 6 built, doing its job. Restart with docker compose start db and, without touching the app, watch the healthcheck return to 200: pgxpool reconnects on its own. On the server, this is also the drill that proves your TaskdDown alert works, since the scrape target stays up but the healthcheck does not — which is a useful reminder that up == 0 and “the service works” are different questions.

Drill 3 — deploy during load. Start the loop from Drill 1, then run docker compose up -d api. Every request must get an answer; you will see a brief gap while the new container starts. That gap is the honest limitation Chapter 26 named: compose up on one host is a brief-blip deploy, not a zero-downtime one. What you are verifying is narrower and more valuable — that requests already in flight when SIGTERM arrives are allowed to finish, because of stop_grace_period: 35s and the 30-second drain from Chapter 4.

Drill 4 — restore last night’s backup. Run /opt/taskd/restore-test.sh by hand and read the row counts. If they match roughly what you expect the system to hold, you have a backup. If the script errors, you have discovered that at a time of your choosing.

Checkpoint

After all four drills you should be able to answer, from things you watched with your own eyes: what happens to a user when the cache dies, what your healthcheck says when the database dies, and whether your backup contains rows.


7. Checkpoint: prove it works

From your laptop, against the real server:

curl -i https://api.yourdomain.com/v1/healthcheck

What you should see: a first line of HTTP/2 200 (or HTTP/1.1 200 OK if your curl does not speak HTTP/2), then headers including content-type: application/json, x-content-type-options: nosniff from Chapter 23’s middleware, and an x-request-id. The body is one line of JSON containing "status":"available", "database":"up", "environment":"production" and a "version" equal to the commit SHA your pipeline deployed.

Then check the redirect and the metrics decision:

curl -i http://api.yourdomain.com/v1/healthcheck    # note: http, not https
curl -i https://api.yourdomain.com/metrics

The first should return a 308 Permanent Redirect with a Location: header pointing at the https:// URL — Caddy’s automatic redirect. The second should return 404 with no body: the metrics endpoint exists, and the outside world cannot tell.

If you got something else:

What you got Cause Fix
curl: (6) Could not resolve host The DNS A record is missing or has not propagated. dig +short api.yourdomain.com and wait, or fix the record.
curl: (7) Failed to connect … port 443 Caddy is not running, or the firewall is blocking 443. docker compose ps on the server; ufw status verbose.
A certificate warning in the browser Caddy could not complete the ACME challenge, or you left acme_ca pointing at the staging CA. docker compose logs caddy — the certificate story is narrated there in plain English.
502 Bad Gateway Caddy is up, the app is not — or the Caddyfile still says localhost:4000 instead of api:4000. docker compose logs api, then re-read Step 3.
405 Method Not Allowed You used curl -I, which sends a HEAD request. Chi routes HEAD separately and this book only ever registers GET. Use curl -i instead of curl -I.
503 with "database":"down" The app is running and the database is not. docker compose ps db, then docker compose logs db.
Checkpoint

One more, for the whole chapter: nc -vz api.yourdomain.com 4000 from your laptop should fail to connect, and so should the same command for 5432, 6379 and 9090. If any of them succeeds, a port is published that should not be — go back to Step 4.


8. Common mistakes (and the quick fix)

Symptom What it means in English Fix
Every request to a rate-limited route returns 500, and the log shows address 203.0.113.9: missing port in address RealIP put a bare IP in RemoteAddr; rateLimitIP calls net.SplitHostPort, which needs host:port. Practice exercise 2 — teach the limiter to accept both forms. Until then, do not mount RealIP.
Rate limits never trigger for one abusive client Your proxy passed the client’s own X-Forwarded-For through, and chi believed it. The three header_up lines in Step 3.
pg_restore reports that it did not find the magic string in the file header The dump is not a valid custom-format archive — almost always docker compose exec without -T, which inserted terminal control characters. Add -T, take the dump again, and re-run the drill.
Certificate stops working after a redeploy The caddy-data volume was removed, so Caddy lost its certificate and account key. Never docker compose down -v in production; -v deletes volumes.
Prometheus shows the target as DOWN after moving to the server prometheus.yml still points at host.docker.internal:4000 from Chapter 18. Change the target to api:4000.
CORS headers never appear despite setting TASKD_CORS__TRUSTED_ORIGINS koanf’s env provider stores the value as a string, and k.Strings returns an empty slice for a string. Put the list in a mounted config.toml — see the note in Step 7.
docker compose up complains about a variable not being set Compose reads .env from the project directory; you ran the command from somewhere else. cd /opt/taskd first. Confirm with docker compose config.
Backups exist but the directory grows forever The find -mtime line is missing, or the filename pattern does not match. Check the glob in backup.sh against a real filename.
Stripe webhooks return signature errors in production but worked locally You deployed with the whsec_ that stripe listen printed instead of the dashboard endpoint’s own secret. Copy the secret from the registered endpoint in the dashboard.

9. Pitfalls

  • RealIP without the firewall. Mounting middleware.RealIP while port 4000 is still published is worse than not mounting it: you have replaced “one IP for everybody” with “every client picks its own IP”, and rate limiting becomes decorative. The header is trustworthy only because Caddy is the sole route to the port. That condition must be enforced, not assumed — and enforced means checked with nc, not remembered.

  • Leaving /metrics publicly routable. Chapter 18 deferred this decision here, and “deferred” quietly becomes “public” if you do nothing. Metrics leak route patterns, deployed versions, traffic shape and error rates.

  • Backups configured and never restored. The most common backup failure is not a corrupt file; it is a job that stopped running in March and a monitoring system nobody told. Retention, a restore drill and a dead man’s switch are three separate defences and you need all three.

  • Backups on the same disk as the database. A local dump protects against DROP TABLE. It does not protect against the disk, the machine or the account being gone. Off-box is the requirement.

  • Alerting on everything and therefore on nothing. Five rules you act on beat fifty you filter. Every time you add a rule, name the action you would take when it fires; if you cannot, it is a dashboard panel, not an alert.

  • Assuming docker compose up -d is zero-downtime. It is a brief blip. In-flight requests are protected by the drain from Chapter 4; new requests during the swap are not. That is fine until it is not, and the fix is two API containers behind Caddy with health-checked upstreams — which is a real project, not a config line.

  • Forgetting that port 80 must stay open forever. Certificates renew roughly every sixty days, automatically, and the renewal needs the same challenge path that issuance did. A firewall rule tightened six months from now produces an expired certificate on a quiet Sunday.

  • HSTS before you are sure. Adding Strict-Transport-Security tells browsers to refuse plain HTTP for that name for the stated duration, and they honour it even if you change your mind. Start at a small max-age, confirm everything works, and raise it later.

  • docker compose down -v. The -v deletes named volumes: your database, your certificates and your metric history in one keystroke. There is never a reason to type it on a production host.


10. Check yourself — quiz

  1. Caddy holds the certificate and taskd speaks plain HTTP. Where exactly does the encryption stop, and why is that safe here but not on a shared network?
  2. middleware.RealIP is mounted, and port 4000 is published to the internet. What can an attacker now do that they could not do before, and which chapter’s protection does it defeat?
  3. Why is handle /metrics { respond 404 } in the Caddyfile rather than a change to routes.go — and does Prometheus still get its data?
  4. Name the five alerts, and for each one say what you would do first at 3 a.m.
  5. Why does Dragonfly need no backup? Name the chapter whose policy made that true, and the exact phrase in the code that encodes it.
  6. Your server has 4 GB of RAM, and you plan to run two API containers. Give values for shared_buffers, db.max_conns and max_connections, and show the arithmetic.
  7. Prediction: you remove -T from the pg_dump line in backup.sh. The backup job keeps succeeding for six months. What have you actually got, and when do you find out?
  8. Which drill verifies which chapter’s promise? Match: kill the cache / kill the database / deploy under load / restore a backup.
Answers
  1. Encryption stops at Caddy — it decrypts the request and forwards plain HTTP to api:4000. That is safe because the hop from Caddy to taskd is a private Docker network on a single host that nothing outside can reach. On a shared or routed network, that same plaintext hop would be readable by anyone on the path, which is why real multi-machine setups either encrypt internally or run the hop over a private network they control.

  2. They can set X-Forwarded-For to a different value on every request, be counted as a new client by the per-IP limiter every time, and never be rate limited. It defeats Chapter 14’s per-IP limiter — and it also poisons Chapter 19’s logs, since the remote field becomes whatever the attacker typed. The firewall is not a companion measure to RealIP; it is the thing that makes RealIP valid.

  3. Because it is an exposure decision, not an application one: the same binary should be able to run in a deployment where /metrics is fine to serve. Keeping it in the proxy means the app has no environment-specific code. Prometheus is unaffected because it scrapes api:4000 directly over the private Compose network and never passes through Caddy.

  4. up == 0 for 2m — check that the container is running and read its logs. 5xx ratio > 1% for 5m — grep the logs for "status":5, take a request ID and follow it. p99 > 1s for 10m — check pool metrics and cache hit ratio before suspecting code. empty_acquire_count increasing — the pool is too small or a query is too slow; the arithmetic in Step 12 decides which. Disk > 80% — prune images and check backup retention.

  5. Because everything in it is reconstructible from Postgres. Chapter 13 set the policy, and the phrase in the code is // best-effort by policy on the cache’s writes, together with the app.cache != nil check at every call site and Allow’s explicit return true, err // fail open. A cache outage is a demotion in speed, never a loss of data.

  6. shared_buffers=1GB (about 25% of 4 GB). db.max_conns stays at 25 per instance. Two instances is 50, plus roughly 10 for psql, the migrate container and pg_dump, giving 60 — comfortably under max_connections=100. If you later run four instances, 4 × 25 + 10 = 110, which exceeds 100: either raise max_connections or lower the per-instance pool. The arithmetic is the point, not the numbers.

  7. You have six months of dump files that pg_restore cannot read, because without -T Docker attaches a pseudo-terminal that mangles the binary stream. The backup job exits zero, the file is non-empty, and the dead man’s switch keeps pinging happily. You find out during your first real restore — unless you run the weekly restore drill, which finds out for you the following Sunday. This is the entire argument for the drill in one question.

  8. Kill the cache → Chapter 13’s “cache down is not app down”. Kill the database → Chapter 6’s DB-aware healthcheck and pgxpool’s automatic reconnection. Deploy under load → Chapter 4’s graceful shutdown and the 30-second drain. Restore a backup → this chapter’s own claim, and the only one with no earlier promise to verify, which is exactly why it is the one people skip.


11. Practice

Exercise 1 — Drill 1, with evidence

Run the cache-outage drill on your laptop and capture proof that the API degraded rather than broke. Show the status code, the response time, and the X-Cache header before, during and after.

Solution

Bring the stack up, register and log in to get a token (Chapter 21’s activation flow applies — the user must be activated), create a couple of tasks, then run:

while true; do
  curl -s -o /dev/null -D - \
    -H "Authorization: Bearer $TOKEN" \
    -w "%{http_code}  %{time_total}s\n" \
    localhost:4000/v1/tasks 2>/dev/null | grep -E 'X-Cache|^[0-9]{3} '
  sleep 0.5
done

-D - dumps response headers to standard output, -o /dev/null throws the body away, and -w appends the status code and total time. The grep keeps only the two lines you care about.

With the cache running you will see X-Cache: HIT on repeated identical requests. Now, in a second terminal:

docker compose stop cache

What you should see: the status code stays 200. The X-Cache header reads MISS on every request, because Cache.Get returns “not found” for both a genuine miss and a connection error — that is the line return nil, false // redis.Nil and real errors both → miss. The time per request rises, since every list query now reaches Postgres and resolveEntitlements falls back to a GetSubscription query.

Nothing 500s. The per-user rate limiter also stops limiting, because Allow fails open by policy — worth noticing, because it means a cache outage temporarily removes a protection. Then:

docker compose start cache

Within a few seconds X-Cache: HIT returns and times drop. Write the three observations in learnings/ch27.md: status unchanged, latency up, one protection silently relaxed.

Exercise 2 — Mount RealIP and survive it

Mount chimw.RealIP as Step 5 shows, reproduce the 500 without deploying anything, then make rateLimitIP accept both address forms.

Solution

Reproduce it. With the line mounted and the app running locally, send a request carrying the header a proxy would add:

curl -i -H "X-Forwarded-For: 203.0.113.9" \
  -d '{"email":"x@example.com","password":"pa55word1234"}' \
  localhost:4000/v1/tokens/authentication

That route is inside the per-IP limiter group. You get 500 Internal Server Error, and the server’s log line contains address 203.0.113.9: missing port in address. Without the header, the same request behaves normally — which is why this bug never appears in development and always appears in production.

Fix it. In cmd/api/middleware.go, rateLimitIP currently does:

ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
	app.serverErrorResponse(w, r, err)
	return
}

Replace those five lines with:

// cmd/api/middleware.go — inside rateLimitIP
// RemoteAddr is "host:port" normally, but chi's RealIP rewrites it to a
// bare IP when a trusted proxy forwarded the request (ch. 27). Accept both.
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
	ip = r.RemoteAddr
}

Why this is safe. The only two shapes RemoteAddr can hold here are Go’s host:port (from the connection) and a bare IP (from RealIP, which assigns only values that pass net.ParseIP). If SplitHostPort cannot parse the first shape, the second is what remains, and it is already validated. The limiter’s map key is a string either way.

Verify. Re-run the curl above: you should now get the normal 401 or 422 for bad credentials, not a 500. Then run it seven times in a row and watch the seventh return 429 — the bucket is 2 requests per second with a burst of 6. Finally, run go test -race -count=1 ./... to confirm nothing else moved.

Exercise 3 — Destroy a database and bring it back

On your laptop, take a backup, delete the data for real, restore it, and prove the rows returned. This is the drill that matters most and the one you have never done.

Solution
# 1. Know what you have.
docker compose exec -T db psql -U taskd -d taskd -c "SELECT count(*) FROM tasks;"

# 2. Back it up.
mkdir -p ./backups
docker compose exec -T db pg_dump -U taskd -d taskd -Fc --no-owner --no-privileges \
  > ./backups/drill.dump
ls -lh ./backups/drill.dump

# 3. Destroy something. This is a dev database; that is the point.
docker compose exec -T db psql -U taskd -d taskd -c "DROP TABLE tasks CASCADE;"

# 4. Confirm the damage.
docker compose exec -T db psql -U taskd -d taskd -c "SELECT count(*) FROM tasks;"

# 5. Restore just that table from the dump.
docker compose exec -T db pg_restore -U taskd -d taskd --no-owner --no-privileges \
  < ./backups/drill.dump

# 6. Prove it.
docker compose exec -T db psql -U taskd -d taskd -c "SELECT count(*) FROM tasks;"

What you should see. Step 1 prints a count. Step 4 fails with a message saying the relation tasks does not exist — that is the failure you engineered, and reading it is part of the drill. Step 6 prints the same count as step 1.

Two things worth noticing. First, restoring into a database that still has the other tables makes pg_restore complain about objects that already exist; those messages are noise here, and --clean --if-exists is the flag pair for a genuine full replacement. Second, DROP TABLE tasks CASCADE also dropped the foreign key from any dependent object — which is why a partial restore is fiddlier than a full one, and why the production drill in Step 11 restores into a fresh scratch database instead.

For the full-fidelity version, do what restore-test.sh does: create taskd_restore_test, restore into it, count rows in users, tasks and subscriptions, and drop it again. That version is safe to run on a schedule, which is the whole idea.


12. FAQ

Do I need a VPS, or will a platform-as-a-service do? A platform (Render, Fly.io, Railway, App Platform) will run this image and give you TLS without any of Step 1 through Step 4. That is a legitimate choice and it is faster. What you give up is exactly what this chapter teaches: you will not know where TLS terminates, which ports are open, or where your data physically is — until the day you need to know, urgently. Doing it once by hand on a five-dollar machine buys you that knowledge permanently. Afterwards, move to a platform if you like it better; the Docker image is the same either way.

How much traffic can one instance of this handle? Honestly: this book has not measured it, and neither should you guess from a number in a book. The shape of the answer is that a Go API doing indexed Postgres queries on a small VPS handles far more than a new product needs, and the first thing to become slow is almost always a query, not Go. The right response is not a benchmark you read but one you run — k6, vegeta or hey pointed at your own endpoints while you watch taskd_http_request_duration_seconds and taskd_pgxpool_empty_acquire_count_total. Load testing is one of the subjects this book did not teach; it is on the list below.

When should I add a second server? When one of three things is true: a single machine’s failure is no longer an acceptable outage, your p99 is above target and the metrics point at CPU rather than at a query, or deploys have started costing money because of the blip. Note that none of those is “we have a lot of users”. Adding a machine adds a load balancer, session-free assumptions you already satisfy, and the max_connections arithmetic from Step 12 — worth it for a real reason, never as a precaution.

Is one binary really enough? This does not look like what large companies run. Large companies run many services because many teams need to deploy independently, which is an organisational problem before it is a technical one. You are one team. A modular monolith with clean internal packages splits later, when a measured bottleneck demands it — and the seams in this codebase (the internal/ packages, the config layer, the cache being optional) are where it would split. Chapter 1 argued this; four hundred pages of code have not contradicted it.

Why is docker compose up -d not zero-downtime, and how much should I care? Because the old container stops before the new one is serving. In-flight requests are protected by the 30-second drain; a request arriving during the swap gets a connection error. For a service with a handful of requests per second and a client that retries, this is a non-event. It becomes worth fixing when a failed request costs money or a customer notices — and then the fix is two API containers behind Caddy with health-checked upstreams, which is a real project with real configuration, not a flag.

This chapter has a lot of steps that are not code. Is that normal? Yes, and it is the part most tutorials omit. The code was finished in Chapter 26. What remains is operations: exposure, secrets, backups, alerts, drills. If you only ever learn to write handlers you will build things that work on your machine; the ability to make one run unattended, safely, for a year is a separate skill, and it is more transferable than any framework you will learn.


13. Where we are

Live. Encrypted, firewalled, backed up, restored at least once, watched by five rules, and broken on purpose four times in a controlled way. The system in Chapter 1’s diagram — including the Caddy (TLS, ch. 27) box that has been sitting there since the first page — is now a real thing running on a real machine at a real address.

The server, as it now stands

/opt/taskd/                       # on the VPS, owned by deploy
├── docker-compose.yml            # NEW: production stack, 6 services
├── Caddyfile                     # NEW: TLS + /metrics 404 + XFF hygiene
├── .env                          # NEW: mode 600, never in git or the image
├── prometheus.yml                # UPDATED: rule_files + target api:4000
├── alerts.yml                    # NEW: the five rules
├── backup.sh                     # NEW: nightly, off-box, retention, ping
├── restore-test.sh               # NEW: weekly scratch-database restore
├── migrations/                   # copied from the repo; keep in sync
└── backups/                      # 14 days of .dump files

And in the repository, so that none of the above exists only on one machine:

taskd/
├── cmd/api/
│   └── routes.go                 # UPDATED: r.Use(chimw.RealIP), one line
├── deploy/                       # NEW: version-controlled copies, no secrets
│   ├── docker-compose.yml
│   ├── Caddyfile
│   ├── alerts.yml
│   ├── backup.sh
│   └── restore-test.sh
├── .github/workflows/            # ch. 26: audit.yml, release.yml
├── internal/ migrations/ sql/    # unchanged by this chapter
├── Dockerfile  docker-compose.yml  config.toml  prometheus.yml
└── Makefile  go.mod  go.sum

What works end to end: a commit on main runs the audit, builds an image tagged with its SHA, pushes it to GHCR, and rolls it onto the server; https://api.yourdomain.com serves the API over a certificate that renews itself; a paying customer’s Stripe events arrive at a registered webhook endpoint; the database is dumped nightly, shipped off the box, aged out after fourteen days and restored weekly into a scratch database; five alert rules evaluate every thirty seconds.

What is still fake or missing: notifications are not wired unless you added Alertmanager or an external checker; node_exporter is not running, so the disk rule is inert; migrations reach the server by hand; and the deploy is a brief blip, not zero-downtime. Each of those is named in the list below rather than hidden.

The honest gaps — your syllabus

Each of these is now a bounded project, because the seams were cut for them.

  • Teams and organisations — the real Business-tier feature. An orgs table, a membership join table with roles, and Chapter 12’s discipline re-applied: tenancy filters move from user_id to org_id in every query, and the compiler again escorts you through the call sites. “Bounded” means you can see the whole shape of the work from here: the schema change, the query change, and a permission check. No new architecture.
  • Zero-downtime deploys — two API containers behind Caddy with health-checked upstreams, or an orchestrator such as Nomad if you already run one. The SHA-tagged images and the drain behaviour are the prerequisites, and both are done.
  • Keyset pagination, trigram search, OpenTelemetry, a public API-keys scope, and usage-based billing via Stripe metered prices — each was flagged in its own chapter with the upgrade path.
   THE SYLLABUS, AND THE SEAM EACH ONE PLUGS INTO

   teams / orgs ─────────▶ the tenancy filter          (ch. 12)
   zero-downtime ────────▶ SHA tags + the 30s drain    (ch. 4, 26)
   keyset pagination ────▶ the Filters struct          (ch. 9)
   trigram search ───────▶ the same WHERE clause       (ch. 9)
   OpenTelemetry ────────▶ the request-id middleware   (ch. 19)
   API-key scopes ───────▶ the tokens.scope column     (ch. 11)
   metered billing ──────▶ the entitlements resolver   (ch. 17)

What this book did not teach you

An honest list is more useful than a triumphant one. These are real subjects, and their absence is a decision about scope rather than a claim that they do not matter.

Subject Why it matters Where to start
Database transactions Several handlers do two writes that should succeed or fail together. sqlc’s DBTX interface was built for it in Chapter 7 and never used. The webhook handler is the pointed case: its idempotency-ledger insert commits before the subscription write, so a failure there can leave a paying customer on the free tier. The pgx documentation on Tx, then q.WithTx(tx).
Load testing You cannot tune what you have not measured, and Step 12 tells you to wait for evidence. k6 or hey, pointed at a staging copy.
Expand/contract migrations Chapter 25’s migration container runs before the new image starts, so old code briefly meets new schema. Adding a NOT NULL column in one step breaks that window. The three-step dance: add nullable, backfill, then constrain.
Point-in-time recovery Nightly dumps mean up to 24 hours of loss. Continuous archiving means seconds. The PostgreSQL manual’s “Backup and Restore” chapter.
Debugging with a debugger This book taught you to read errors and logs. delve and breakpoints are the next tool. dlv debug ./cmd/api.
Log aggregation docker compose logs stops scaling the moment there are two machines or two weeks. Loki, or your provider’s log product.
Alert delivery Rules that fire are not the same as humans who are told. Alertmanager, or Uptime Kuma / Healthchecks.io for the simple cases.
Read replicas and multi-region One database is a single point of failure and a single point of latency. Only after a measured reason.

Six things worth reading next, in roughly this order: Alex Edwards’s Let’s Go Further, which is where this codebase’s structure comes from and which goes deeper on several chapters here; the Go standard library documentation, especially net/http and context, which repay reading directly; the PostgreSQL manual, which is unusually well written and is the reference for everything in Chapters 5 through 9; Kailash Nadh’s blog and the listmonk source, for the temperament this book copied; Google’s Site Reliability Engineering, particularly its chapters on monitoring, for why you alert on symptoms rather than causes; and Martin Kleppmann’s Designing Data-Intensive Applications, for the day one database stops being enough.

The last word

Look back at what the “boring” choices bought. chi is net/http, so nothing framework-shaped ever fought you. sqlc turned two schema evolutions into compiler-guided refactors. Plans-as-code made billing reviewable. The cache was always optional, so its outages are demotions, not incidents. The config seam from Chapter 3 became the entire container interface in Chapter 25 — and in this chapter it became the entire production interface too, which is why going live changed almost no code.

None of these were clever. All of them compounded. That is the actual lesson of both of this book’s patron saints, and it fits in a sentence: choose components you can fully understand, connect them with seams you consciously placed, and let the boredom compound.

For your notes

Copy these into learnings/ch27.md, in your own words:

  1. An untested backup is a hope, not a backup. The drill is the deliverable, not the dump file. Schedule the restore, count the rows, and give the job a dead man’s switch so silence is detected.
  2. A forwarded header is only as true as the port is closed. X-Forwarded-For becomes trustworthy at the exact moment two conditions hold together: your proxy overwrites it, and nothing but your proxy can reach the app. One without the other is theatre.
  3. Terminate TLS where certificates are somebody’s whole job. The front door handles encryption, redirects and exposure decisions; the app stays ignorant of all three and is therefore identical in every environment.
  4. Five alerts you will act on beat fifty you will filter. For every rule, write the first command you would run at 3 a.m. If you cannot write it, you have made a dashboard panel, not an alert.
  5. Every safety mechanism is a claim until you have watched it work. Graceful shutdown, cache degradation, pool reconnection, restore — four drills, one afternoon, and the difference between believing your system and knowing it.

Now go ship something people pay for.

Appendix A — The final tree

This is the repository after Chapter 27, generated from the working code. Every file is here because a chapter needed it, and the chapter that created it is named on the right.

Tip

When you fall out of sync with the book, compare against this first. A missing file is a skipped step; an extra file is usually a rename you did and the book didn’t.

taskd/
├── .github/
│   └── workflows/          # audit.yml and release.yml — the robot that says no (ch. 26)
│       ├── audit.yml
│       └── release.yml
├── cmd/
│   └── api/
│       ├── docs/
│       │   └── openapi.yaml # the API contract, served and tested (ch. 24)
│       ├── accounts.go     # activation, password reset, email change, deletion (ch. 22)
│       ├── background.go   # the background() helper and the janitors (ch. 21)
│       ├── billing.go      # checkout, portal, plan status (ch. 15)
│       ├── config.go       # koanf loader → typed config struct (ch. 3)
│       ├── context.go      # user and request-id context plumbing (ch. 12)
│       ├── db.go           # the pgxpool constructor (ch. 6)
│       ├── docs_test.go
│       ├── docs.go         # embedded OpenAPI spec and the /docs page (ch. 24)
│       ├── entitlements.go # tier resolver and cache invalidation (ch. 17)
│       ├── errors.go       # every non-2xx response the API can emit (ch. 8)
│       ├── healthcheck.go  # the one endpoint that must never be clever (ch. 2)
│       ├── helpers.go      # readJSON / writeJSON / readIDParam / query-string readers (ch. 8)
│       ├── main.go         # wiring only: config → dependencies → serve (ch. 2)
│       ├── metrics.go      # instruments, pool collector, /metrics (ch. 18)
│       ├── middleware.go   # recover, metrics, logging, auth, limiters, idempotency (ch. 4)
│       ├── routes.go       # the whole API surface, one screen (Appendix F) (ch. 2)
│       ├── server.go       # lifecycle and graceful shutdown (ch. 4)
│       ├── tasks_test.go
│       ├── tasks.go        # CRUD and list handlers — the product (ch. 8)
│       ├── testutils_test.go
│       ├── tokens.go       # login (ch. 11)
│       ├── users.go        # registration (ch. 10)
│       └── webhooks.go     # the Stripe source-of-truth endpoint (ch. 16)
├── internal/
│   ├── cache/              # Dragonfly wrapper and the rate limiter (ch. 13)
│   │   ├── cache.go
│   │   └── ratelimit.go
│   ├── data/               # domain types, validation, filters, plans (ch. 8)
│   │   ├── filters_test.go
│   │   ├── filters.go
│   │   ├── plans.go
│   │   ├── tasks.go
│   │   ├── tokens.go
│   │   └── users.go
│   ├── db/                 # sqlc output — committed, never hand-edited (ch. 7)
│   ├── mailer/             # SMTP client and embedded email templates (ch. 21)
│   │   ├── templates/
│   │   │   ├── activation.tmpl
│   │   │   ├── email-change.tmpl
│   │   │   └── password-reset.tmpl
│   │   └── mailer.go
│   └── validator/          # the 40-line validation helper (ch. 8)
│       └── validator.go
├── migrations/             # 000001..000007, each an up/down pair (ch. 5)
│   ├── 000001_create_tasks.down.sql
│   ├── 000001_create_tasks.up.sql
│   ├── 000002_create_users.down.sql
│   ├── 000002_create_users.up.sql
│   ├── 000003_create_tokens.down.sql
│   ├── 000003_create_tokens.up.sql
│   ├── 000004_add_user_id_to_tasks.down.sql
│   ├── 000004_add_user_id_to_tasks.up.sql
│   ├── 000005_billing.down.sql
│   ├── 000005_billing.up.sql
│   ├── 000006_activation.down.sql
│   ├── 000006_activation.up.sql
│   ├── 000007_pending_email.down.sql
│   └── 000007_pending_email.up.sql
├── sql/
│   └── queries/            # the SQL sqlc compiles into Go (ch. 7)
│       ├── billing.sql
│       ├── tasks.sql
│       ├── tokens.sql
│       └── users.sql
├── .dockerignore
├── .envrc                  # machine-local variables the Makefile includes (ch. 5)
├── .gitignore
├── config.toml             # every runtime setting, overridable by environment (ch. 3)
├── docker-compose.yml      # the whole system on your laptop (ch. 5)
├── Dockerfile              # the 15 MB production image (ch. 25)
├── go.mod
├── go.sum
├── Makefile                # every command you type more than once (Appendix B) (ch. 5)
├── prometheus.yml          # what Prometheus scrapes (ch. 18)
├── README.md
└── sqlc.yaml               # how SQL becomes Go (ch. 7)

How to read a Go project tree

Four rules explain the whole layout, and they are the same four in most Go services:

  1. cmd/ holds programs. Each subdirectory of cmd/ builds one executable. cmd/api is our only one, so go build ./cmd/api produces one binary called api.
  2. internal/ is enforced privacy. The Go compiler refuses to let any module outside this one import anything under internal/. It is not a convention — it is a rule with an error message.
  3. A folder is a package. Every .go file in internal/cache starts with package cache, and the folder’s path is how everyone else refers to it.
  4. Generated code lives apart and is never edited. internal/db is written by sqlc from migrations/ plus sql/queries/. Editing it works right up until the next make sqlc silently deletes your edit.

The count

Go files you write 20 in cmd/api, 9 in internal
Go files sqlc writes for you 6
Migration files 14 (7 up/down pairs)
SQL query files 4
Test files 4

For your notes — the tree is the shape of the argument the book made: one binary, private packages, generated code quarantined, SQL and migrations at the root where operations tooling expects them. If you can explain why each top-level folder exists, you can defend this layout in a code review.

Appendix B — The complete Makefile

A Makefile is a file of named shortcuts. make run/api runs whatever command is written under run/api:. That is the entire idea — the rest is convention.

Two conventions this file uses:

  • .PHONY: name tells make “this target is a command, not a file to build”. Without it, make would look for a file called run/api on disk, find nothing, and behave oddly.
  • ## comments are the help text. The help target greps for them, so a new target documents itself by being written in the same style.
Warning

Every command line inside a target must be indented with a TAB, never spaces. This is make’s oldest and least forgivable rule. If you see Makefile:12: *** missing separator. Stop., an editor turned your tab into spaces.

# Makefile
include .envrc

GIT_DESC = $(shell git describe --always --dirty --tags 2>/dev/null || echo dev)

## help: print this help message
.PHONY: help
help:
	@grep -E '^##' ${MAKEFILE_LIST} | sed 's/## //'

## run/api: run the API server
.PHONY: run/api
run/api:
	go run ./cmd/api

## db/psql: open psql against the dev database
.PHONY: db/psql
db/psql:
	docker compose exec db psql -U taskd -d taskd

## db/migrations/new name=$1: create a new migration pair
.PHONY: db/migrations/new
db/migrations/new:
	migrate create -seq -ext sql -dir ./migrations $(name)

## db/migrations/up: apply all pending migrations
.PHONY: db/migrations/up
db/migrations/up:
	migrate -path ./migrations -database $(TASKD_DB_DSN) up

## sqlc: regenerate internal/db from schema + queries
.PHONY: sqlc
sqlc:
	sqlc generate

## sqlc/vet: run sqlc's lint rules against the queries
.PHONY: sqlc/vet
sqlc/vet:
	sqlc vet

## test: run unit tests with the race detector
.PHONY: test
test:
	go test -race -count=1 ./...

## test/int: run all tests against a real test database
.PHONY: test/int
test/int: export TASKD_TEST_DSN = postgres://taskd:pa55word@localhost:5432/taskd_test?sslmode=disable
test/int:
	migrate -path ./migrations -database $$TASKD_TEST_DSN up
	go test -race -count=1 ./...

## audit: format check, vet, staticcheck, govulncheck, sqlc diff, tests
.PHONY: audit
audit:
	@test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
	go vet ./...
	go run honnef.co/go/tools/cmd/staticcheck@latest ./...
	go run golang.org/x/vuln/cmd/govulncheck@latest ./...
	sqlc diff
	go test -race -count=1 ./...

## build/api: build a static production binary with version stamp
.PHONY: build/api
build/api:
	CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=${GIT_DESC}' \
		-o bin/api ./cmd/api

## docker/build: build the production image
.PHONY: docker/build
docker/build:
	docker build -t taskd:${GIT_DESC} --build-arg VERSION=${GIT_DESC} .

The trap on line 1

include .envrc

.envrc is gitignored — it holds your machine’s database URL — so it does not arrive with a clone. And include (unlike -include) is mandatory: if the file is missing, make refuses to do anything at all.

$ make help
Makefile:1: .envrc: No such file or directory
make: *** No rule to make target `.envrc'.  Stop.

The fix is to create it (Chapter 5 does this as a numbered step):

# .envrc — machine-local variables, never committed
export TASKD_DB_DSN='postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable'
Note

Writing -include .envrc would make the file genuinely optional. The book keeps include on purpose: a missing DSN should stop you here, with a clear message, rather than three commands later inside a failing migration.

A wart in help, and why it is left alone

Run it and the output is not quite what you expect:

$ make help
Makefile:help: print this help message
Makefile:run/api: run the API server
Makefile:db/psql: open psql against the dev database

Every line is prefixed with Makefile:. The target is:

help:
	@grep -E '^##' ${MAKEFILE_LIST} | sed 's/## //'

${MAKEFILE_LIST} expands to a filename, and grep labels every match with the file it came from. The sed then strips ## but not the label. Two one-word fixes exist — grep -h (don’t print filenames) or sed -e 's/^.*## //' — and either is a fine first pull request to your own project.

It is printed here as it is, warts included, because a book that shows you idealised output teaches you to distrust your own terminal.

What each target is for

Target Why it exists
help so you never have to read this file again
run/api the inner loop: edit, make run/api, curl, repeat
db/psql types the long docker compose exec line for you, forever
db/migrations/new creates the numbered up/down pair so you never invent a filename
db/migrations/up the only correct way to change the schema
sqlc regenerate Go from SQL after touching either
sqlc/vet sqlc’s own lint rules, before CI runs them
test unit tests with -race, and -count=1 to defeat the test cache
test/int the same tests against a real Postgres
audit everything CI runs, runnable locally — format, vet, staticcheck, vulnerabilities, sqlc diff, tests
build/api the production binary, with the git description compiled into version
docker/build the image, tagged with the same git description
Remember this

make audit is the one to run before you push. It is the same gate the pipeline applies, so a green audit locally means a green pipeline remotely — and a red one costs you ten seconds instead of ten minutes.

For your notes — a Makefile is documentation that executes. Every command you type twice belongs in it, because the version in your shell history is only on your machine.

Appendix C — The complete final schema

Five tables. This is every piece of state the service keeps, after all seven migrations have run.

Note

You never type this file. It is what you end up with — the sum of migrations/000001 through 000007. Postgres builds it for you when you run make db/migrations/up. It is printed here so you can check your database against it, and so you can see the whole design at once, which the chapters never show you.

CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE users (
    id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    created_at         timestamptz NOT NULL DEFAULT now(),
    name               text        NOT NULL,
    email              citext      NOT NULL UNIQUE,
    password_hash      bytea       NOT NULL,
    activated          boolean     NOT NULL DEFAULT false,
    stripe_customer_id text        UNIQUE,
    pending_email      citext,
    version            integer     NOT NULL DEFAULT 1
);

CREATE TABLE tokens (
    hash    bytea PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE,
    expiry  timestamptz NOT NULL,
    scope   text NOT NULL
);
CREATE INDEX idx_tokens_user_id ON tokens (user_id);

CREATE TABLE tasks (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    bigint NOT NULL REFERENCES users ON DELETE CASCADE,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    title      text NOT NULL,
    notes      text NOT NULL DEFAULT '',
    status     text NOT NULL DEFAULT 'open'
               CHECK (status IN ('open','done','archived')),
    priority   text NOT NULL DEFAULT 'none'
               CHECK (priority IN ('none','low','medium','high')),
    due_at     timestamptz,
    version    integer NOT NULL DEFAULT 1
);
CREATE INDEX idx_tasks_user_id ON tasks (user_id);

CREATE TABLE subscriptions (
    user_id                bigint PRIMARY KEY REFERENCES users ON DELETE CASCADE,
    stripe_subscription_id text NOT NULL UNIQUE,
    tier                   text NOT NULL,
    status                 text NOT NULL,
    current_period_end     timestamptz NOT NULL,
    updated_at             timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE stripe_events (
    id          text PRIMARY KEY,
    received_at timestamptz NOT NULL DEFAULT now()
);

How the tables relate

                          ┌──────────────────────┐
                          │        users         │
                          │  id (PK)             │
                          │  email (unique)      │
                          │  password_hash       │
                          │  activated           │
                          │  stripe_customer_id  │
                          └──────────┬───────────┘
                                     │  one user …
              ┌──────────────────────┼──────────────────────┐
              │                      │                      │
      … has many tasks       … has many tokens     … has 0 or 1 subscription
              │                      │                      │
     ┌────────▼────────┐   ┌─────────▼────────┐   ┌─────────▼─────────────┐
     │      tasks      │   │      tokens      │   │    subscriptions      │
     │  id (PK)        │   │  hash (PK)       │   │  user_id (PK + FK)    │
     │  user_id (FK)   │   │  user_id (FK)    │   │  stripe_subscription  │
     │  title, notes   │   │  expiry          │   │  tier, status         │
     │  status,priority│   │  scope           │   │  current_period_end   │
     │  version        │   └──────────────────┘   └───────────────────────┘
     └─────────────────┘

     ┌──────────────────┐   stripe_events belongs to nobody: it is a list of
     │  stripe_events   │   webhook IDs we have already processed, so a repeat
     │  id (PK)         │   delivery can be recognised and ignored (Chapter 16).
     └──────────────────┘

Read the arrows as “belongs to”. A task belongs to a user; delete the user and ON DELETE CASCADE deletes their tasks with them, in the same transaction, with no application code involved.


Why each column is the type it is

Choice Reason
bigint GENERATED ALWAYS AS IDENTITY auto-numbered primary key, SQL-standard, and GENERATED ALWAYS stops anyone inserting their own id by accident. bigint because running out of int at 2.1 billion rows is a bad afternoon.
citext for email case-insensitive text. Ada@x.com and ada@x.com are the same account, and the UNIQUE constraint enforces it in the database rather than hoping every code path remembers to lowercase.
bytea for password_hash raw bytes, not text. bcrypt output is bytes; storing it as text invites an encoding to mangle it silently.
bytea for the token hash the token itself is never stored — only its SHA-256 hash. A stolen database backup therefore contains no usable tokens (Chapter 11).
timestamptz everywhere timestamp with time zone: stored as an absolute instant, so a server in a different zone can’t shift your data. Plain timestamp is a bug waiting for a deploy in another region.
text with a CHECK for status/priority a small fixed set of values, enforced by the database. Chosen over a Postgres enum because adding a value to an enum is a schema migration, while adding one here is a one-line CHECK change.
version integer NOT NULL DEFAULT 1 optimistic concurrency (Chapter 8): every update bumps it, so a second writer working from stale data updates zero rows instead of overwriting the first writer.
due_at timestamptz (nullable) the only nullable column on tasks, because “no due date” is genuinely different from any date.
pending_email citext (nullable) holds a requested new address until it is confirmed, so an unconfirmed change cannot lock you out (Chapter 22).

Which migration created what

The book shows some of these migrations in full and others only as inline SQL. The full set:

# File What it does Chapter
1 000001_create_tasks tasks — before users exist, so no user_id yet 5
2 000002_create_users citext extension, users (with activated defaulting to true) 10
3 000003_create_tokens tokens 11
4 000004_add_user_id_to_tasks adds tasks.user_id + its index; TRUNCATEs existing tasks first 12
5 000005_billing users.stripe_customer_id, subscriptions, stripe_events 15
6 000006_activation flips activated to default false, adds idx_tokens_user_id 21
7 000007_pending_email adds users.pending_email 22

Two of those deserve a second look, because they teach the two hardest things about migrations:

Warning

Migration 4 runs TRUNCATE tasks. Adding a NOT NULL column to a table that already has rows is impossible without deciding what those rows should contain, and at that point in the book the honest answer is “nothing — they were test data”. The book labels this dev-grade on purpose. On a database with real rows you would add the column as nullable, backfill it, then add the constraint. Run this migration against production data and you delete every task in the system.

Note

Migration 6 changes a default rather than the data. New users must now confirm their email, but everyone who signed up before activation existed stays activated. Changing a DEFAULT only affects future inserts — an important property to know before you reach for UPDATE.


Checking your database against this

make db/psql
\dt                  -- five tables: stripe_events, subscriptions, tasks, tokens, users
\d tasks             -- columns, defaults, CHECK constraints, indexes, foreign keys
\di                  -- idx_tasks_user_id and idx_tokens_user_id should both be here
\q
Common mistake

You’ll see: Did not find any relations. It means: you are connected to an empty database — the migrations never ran here. Fix: make db/migrations/up, then \dt again.

For your notes — three ideas from this schema are worth carrying to every future project: constraints belong in the database (UNIQUE, CHECK, foreign keys), store hashes rather than secrets, and a version column is the cheapest concurrency control that exists.

Appendix D — Dependency ledger

Every third-party module in the finished service, and the chapter that argued for it — because a dependency you can’t justify is a dependency you should delete.

Module Version Chapter Earns its place by
github.com/go-chi/chi/v5 v5.2.1 2 routing and URL patterns, while every handler stays a plain net/http handler
github.com/knadh/koanf/v2 (+ parsers/toml, providers/file, providers/env) v2.1.2 3 file-plus-environment configuration, without a framework attached
github.com/jackc/pgx/v5 v5.7.2 6 Postgres driver and connection pool, with native type handling
golang.org/x/crypto (bcrypt) v0.32.0 10 password hashing that is deliberately slow
github.com/redis/go-redis/v9 v9.7.0 13 the client DragonflyDB speaks
golang.org/x/sync (singleflight) v0.10.0 13 collapsing a stampede of identical cache misses into one query
golang.org/x/time (rate) v0.9.0 14 the in-memory token bucket
github.com/stripe/stripe-go/v78 v78.12.0 15 billing API plus webhook signature verification
github.com/prometheus/client_golang v1.20.5 18 counters, histograms, and the /metrics handler
github.com/wneessen/go-mail v0.5.2 21 an SMTP client that handles the parts of email you don’t want to learn
github.com/go-chi/cors v1.2.1 23 CORS from an explicit origin allow-list

Fourteen runtime modules for a monetized, cached, observable SaaS. (Fourteen rather than eleven because koanf is split into four small modules.) That number is the philosophy, measured.

Build-time tools, never imported by the program: sqlc, golang-migrate, staticcheck, govulncheck. They run in the Makefile and in CI; none of them ships in the binary.


What a dependency actually costs

Fourteen direct dependencies pull in more than that. Ask your own project:

go list -m all | wc -l          # every module in the graph, direct and indirect
go mod graph | wc -l            # every edge between them
go mod why github.com/x/y       # why is this here at all?
go version -m bin/api           # what actually made it into the built binary

For taskd the numbers are 14 direct and 68 total modules. The gap is what other people’s dependencies dragged in — mostly Prometheus’s and Stripe’s own trees. That ratio is normal and worth knowing before you add the fifteenth.

Every dependency costs you five things:

  1. Attack surface. Their bug is your CVE. govulncheck in make audit exists for this.
  2. Upgrade work. Someone has to move you off v78 when v79 lands.
  3. Build time and image size. Small here, decisive in a monorepo.
  4. Abstraction lock-in. The expensive one: if a library’s types leak into your function signatures, removing it becomes a refactor rather than a deletion.
  5. Understanding. You are responsible for behaviour you did not write and may not have read.
Remember this

The test the book applies before every dependency: could I remove this in an afternoon? chi passes — its handlers are http.HandlerFunc, so ripping it out means rewriting one file. A full web framework fails, because its context type would be in every signature in the codebase.


The ones deliberately not here

Knowing what was rejected teaches more than the list above.

Not used Would have replaced Why not
Gin / Echo / Fiber chi + net/http their context types leak into every handler signature; the exit cost is the whole codebase
GORM / ent sqlc + hand-written SQL generates SQL you didn’t write and can’t easily predict; at scale you end up fighting it
database/sql + lib/pq pgx/v5 lib/pq is in maintenance mode; pgx is faster, actively developed, and has a real pool
Viper koanf kitchen-sink design and a heavy dependency graph — the exact objection that made koanf exist
zap / zerolog log/slog the standard library has done structured logging since Go 1.21; a third-party logger now has to justify itself, and for an API server it can’t
wire / fx the application struct dependency injection is one struct literal in main; a framework solves a problem Go doesn’t have
JWT libraries stateful tokens in Postgres revocation. A JWT is valid until it expires; a row can be deleted (Chapter 11)
testify the standard testing package table tests plus t.Helper() cover it; assertion DSLs mostly add vocabulary

Keeping the ledger honest

make audit        # includes govulncheck: known vulnerabilities in anything you depend on
go mod tidy       # removes what nothing imports any more

go mod tidy in CI (Chapter 26) is what stops the ledger rotting: if someone deletes the last use of a library but leaves it in go.mod, the diff shows it.

For your notes — write down the five costs above. The next time you are about to go get something to save twenty minutes, read them first. Roughly half the time you will write the twenty lines yourself instead, and be glad you did.

Appendix E — The final main.go, annotated

The whole program’s startup, in one file. If you are lost, diff your main.go against this one: almost every “it compiles but does nothing” problem is a missing line here.

Read it once for the shape before reading it for the detail. The shape is:

  1. read configuration        ── fail now if it's wrong
  2. build the logger          ── so every later failure is reportable
  3. connect to Postgres       ── fail now if it's unreachable
  4. connect to Dragonfly      ── degrade, don't fail: cache is optional
  5. build the mailer          ──
  6. assemble &application{}   ── every dependency in one value
  7. register metrics          ──
  8. serve()                   ── blocks until a signal arrives

Three principles decide that order, and they are worth more than the code:

  1. Fail fast, at boot, in the open. Anything the service cannot run without — configuration, the database — is checked before the first request. A process that refuses to start is a good outage; one that starts and 500s every request is a bad one.
  2. Degrade for the optional. The cache is a speed feature, not a correctness feature, so a dead Dragonfly logs a warning and the service runs slower. Deciding which dependencies are which is an architecture decision, and it is made here.
  3. main wires, it does not work. No business logic lives in this file. Everything it does is construct something and hand it to something else, which is why it stays readable at 120 lines after 27 chapters.
// cmd/api/main.go — final form
package main

import (
	"flag"
	"fmt"
	"log/slog"
	"os"
	"sync"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/stripe/stripe-go/v78"
	"golang.org/x/sync/singleflight"

	"github.com/yourname/taskd/internal/cache"
	"github.com/yourname/taskd/internal/db"
	"github.com/yourname/taskd/internal/mailer"
)

// Overridden at build time by -ldflags (ch. 25); reported by the
// healthcheck so you always know which commit is serving traffic.
var version = "dev"

// The dependency container. Every handler and middleware in the
// codebase is a method on *application — that is how they all reach
// the logger, the DB, the cache, and each other's helpers.
type application struct {
	config  config             // ch. 3  — typed settings, file + env
	logger  *slog.Logger       // ch. 3  — structured logging
	db      *pgxpool.Pool      // ch. 6  — Postgres connection pool
	q       *db.Queries        // ch. 7  — sqlc-generated typed queries
	cache   *cache.Cache       // ch. 13 — Dragonfly; nil = degraded mode
	sfGroup singleflight.Group // ch. 13 — stampede protection
	wg      sync.WaitGroup     // ch. 21 — background work, drained on exit
	mailer  *mailer.Mailer     // ch. 21 — SMTP with embedded templates
}

func main() {
	// --- configuration (ch. 3): fail fast or don't boot ---
	configPath := flag.String("config", "config.toml", "path to config file")
	flag.Parse()

	cfg, err := loadConfig(*configPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	logger := newLogger(cfg) // text in dev, JSON in production

	// --- Stripe SDK key (ch. 15): package-level, set once ---
	stripe.Key = cfg.stripe.secretKey
	if stripe.Key == "" {
		logger.Warn("stripe.secret_key is empty; billing endpoints will fail")
	}

	// --- database (ch. 6): required; a dead DB fails the boot in 5s ---
	pool, err := openDB(cfg)
	if err != nil {
		logger.Error("cannot connect to database", "error", err)
		os.Exit(1)
	}
	defer pool.Close()
	logger.Info("database connection pool established")

	// --- pool metrics (ch. 18): expose pgxpool stats to Prometheus ---
	prometheus.MustRegister(newPoolStatsCollector(pool))

	// --- cache (ch. 13): OPTIONAL; failure degrades, never blocks boot ---
	appCache, err := cache.New(cfg.cache.addr)
	if err != nil {
		logger.Warn("cache unavailable, continuing without it", "error", err)
		appCache = nil
	} else {
		defer appCache.Close()
		logger.Info("cache connection established", "addr", cfg.cache.addr)
	}

	// --- mailer (ch. 21): required; activation depends on it ---
	m, err := mailer.New(cfg.smtp.host, cfg.smtp.port,
		cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender)
	if err != nil {
		logger.Error("configuring mailer", "error", err)
		os.Exit(1)
	}

	// --- assemble ---
	app := &application{
		config: cfg,
		logger: logger,
		db:     pool,
		q:      db.New(pool),
		cache:  appCache,
		mailer: m,
	}

	// --- janitors (ch. 21): hourly token/event cleanup. A plain
	// goroutine, deliberately NOT on the WaitGroup — its deletes are
	// single atomic statements, safe to kill mid-loop.
	go app.janitor()

	// --- serve (ch. 4): blocks until SIGINT/SIGTERM, drains requests,
	// then waits on app.wg so background email finishes too (ch. 21).
	err = app.serve()
	if err != nil {
		logger.Error("server error", "error", err)
		os.Exit(1)
	}
}

// newLogger picks the log format and level from config:
// human-readable text in dev, machine-parseable JSON in production.
func newLogger(cfg config) *slog.Logger {
	var lvl slog.Level
	_ = lvl.UnmarshalText([]byte(cfg.logLevel)) // bad value → info

	opts := &slog.HandlerOptions{Level: lvl}
	if cfg.env == "production" {
		return slog.New(slog.NewJSONHandler(os.Stdout, opts))
	}
	return slog.New(slog.NewTextHandler(os.Stdout, opts))
}

The application struct is the book

Every handler and every middleware in this codebase is a method on *application. That single decision, made in Chapter 2 before any real code existed, is what lets a handler reach the logger, the database, the cache and the mailer without a single global variable and without a dependency-injection framework.

It also makes tests obvious: build a different application with a test database and a discard logger, and every handler is testable with no mocking library at all (Chapter 20).

For your notes — write out the eight startup steps from memory. If you can list them in order and say what each one would break if removed, you understand how this service boots better than most people understand the services they maintain.

Appendix F — The complete routes.go and OpenAPI spec

The original edition never printed either of these in full. Chapter 2 creates routes.go with one route, and eleven later chapters each add a line or two to it — so the complete file, the one that shows the entire API on a single screen, exists only in your editor. Appendix A calls it “the whole API surface, one screen” without ever showing that screen. This appendix is that screen.

Both files below are copied from the working repository that accompanies this edition. They compile and they run.

Why this exists

A router file is the best documentation a service has. It is the only place where every way into your system is listed, in the order the checks happen. When you join a new codebase, this is the first file to read. When you leave one, it is the file you wish someone had kept tidy.


1. How to read a chi router

Middleware wraps handlers like coats: the first one registered is the outermost coat, and a request puts them on in order on the way in, then takes them off in reverse on the way out.

                    ┌──────────────────────────────────────────────┐
  request ─────────▶│ secureHeaders          always, every request │
                    │ ┌──────────────────────────────────────────┐ │
                    │ │ CORS            only if origins config'd  │ │
                    │ │ ┌──────────────────────────────────────┐ │ │
                    │ │ │ recoverPanic   turns a panic into 500 │ │ │
                    │ │ │ ┌──────────────────────────────────┐ │ │ │
                    │ │ │ │ metricsMiddleware  counts + times │ │ │ │
                    │ │ │ │ ┌──────────────────────────────┐ │ │ │ │
                    │ │ │ │ │ logRequest   one line per req │ │ │ │ │
                    │ │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │
                    │ │ │ │ │ │ authenticate  IDENTIFIES  │ │ │ │ │ │
                    │ │ │ │ │ │   (never rejects anyone)  │ │ │ │ │ │
                    │ │ │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ │
                    │ │ │ │ │ │ │  per-group gates:     │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  rateLimitIP          │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  requireAuthenticated │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  rateLimitUser        │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  requireActivated     │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  idempotent (POSTs)   │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  ┌────────────────┐   │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  │  your handler  │   │ │ │ │ │ │ │
                    │ │ │ │ │ │ │  └────────────────┘   │ │ │ │ │ │ │
                    │ │ │ │ │ │ └──────────────────────┘ │ │ │ │ │ │
                    │ │ │ │ │ └──────────────────────────┘ │ │ │ │ │
                    │ │ │ │ └──────────────────────────────┘ │ │ │ │
                    │ │ │ └──────────────────────────────────┘ │ │ │
                    │ │ └──────────────────────────────────────┘ │ │
                    │ └──────────────────────────────────────────┘ │
                    └──────────────────────────────────────────────┘

Four facts explain every ordering decision in the file:

  1. secureHeaders and CORS are first because their answers must be correct even for requests that get rejected later — including the browser’s preflight OPTIONS, which by specification carries no Authorization header and would fail authentication if it reached it.
  2. recoverPanic is outside metricsMiddleware, so a panic is still counted as the 500 it becomes. If it were inside, your dashboards would go quiet exactly when things caught fire.
  3. authenticate identifies but never rejects. It attaches “who is this?” to the request and lets anonymous requests through untouched. Rejection is a per-group decision made by requireAuthenticatedUser, because /v1/healthcheck and the Stripe webhook must stay open.
  4. The rings get stricter inward: public → authenticated → activated. A route is placed in the ring that matches what it needs. Password reset lives outside the activated ring on purpose — you must be able to fix an account you cannot yet log into.

2. The complete routes.go

// cmd/api/routes.go
package main

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/cors"
)

// routes builds and returns the router: the object that looks at each
// incoming request's method + path and decides which handler runs.
// The whole API surface, on one screen.
func (app *application) routes() http.Handler {
	r := chi.NewRouter()

	// Registration order = wrapping order, outermost first.
	//
	// secureHeaders and CORS come first (ch. 23): the headers are
	// unconditional, and CORS must answer preflight OPTIONS — which
	// carry no bearer token, by spec — before anything tries to
	// authenticate them.
	r.Use(secureHeaders)

	// CORS is off by default: a server-to-server API with zero
	// configured origins sends no CORS headers at all.
	if len(app.config.cors.trustedOrigins) > 0 {
		r.Use(cors.Handler(cors.Options{
			AllowedOrigins: app.config.cors.trustedOrigins,
			AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
			AllowedHeaders: []string{"Authorization", "Content-Type", "Idempotency-Key"},
			ExposedHeaders: []string{"X-Request-ID", "X-Cache"},
			MaxAge:         300,
		}))
	}

	// recoverPanic is outermost of the application middleware, so it
	// also catches panics thrown inside the ones below it (ch. 4).
	// metricsMiddleware sits directly inside it, so panics are counted
	// as the 500s they become (ch. 18).
	r.Use(app.recoverPanic)
	r.Use(app.metricsMiddleware)
	r.Use(app.logRequest)

	// authenticate IDENTIFIES; it tolerates anonymity and never gates.
	// The gating is done per-group by requireAuthenticatedUser (ch. 11).
	r.Use(app.authenticate)

	// What happens when no route matches.
	r.NotFound(func(w http.ResponseWriter, r *http.Request) {
		app.notFoundResponse(w, r)
	})

	// ch. 18 — Prometheus scrape target. Internal surface: fine on the
	// main port behind a private network, provided the reverse proxy
	// never routes it publicly.
	r.Get("/metrics", metricsHandler().ServeHTTP)

	// ch. 24 — the browsable reference page. Public by choice.
	r.Get("/docs", app.docsPageHandler)

	// Everything else lives under /v1 so a breaking /v2 can exist someday
	// without disturbing old clients.
	r.Route("/v1", func(r chi.Router) {
		// --- public ring ---

		// Outside every limiter (ch. 14): rate-limiting the healthcheck
		// makes your monitoring the DoS and your pager the victim.
		r.Get("/healthcheck", app.healthcheckHandler)

		// ch. 24 — the contract itself. Registered here so the route
		// pattern reads /v1/openapi.yaml.
		r.Get("/openapi.yaml", app.openapiHandler)

		// ch. 16 — outside authentication (Stripe carries no bearer
		// token) and outside the IP limiter (Stripe's retry bursts must
		// not be throttled into failure).
		r.Post("/stripe/webhook", app.stripeWebhookHandler)

		// ch. 14 — the unauthenticated surface is brute-force and
		// mail-cannon territory: everything here sits behind the per-IP
		// token bucket.
		r.Group(func(r chi.Router) {
			r.Use(app.rateLimitIP)

			r.Post("/users", app.registerUserHandler)                             // ch. 10
			r.Post("/tokens/authentication", app.createAuthTokenHandler)          // ch. 11
			r.Post("/tokens/activation", app.createActivationTokenHandler)        // ch. 21
			r.Put("/users/activated", app.activateUserHandler)                    // ch. 21
			r.Post("/tokens/password-reset", app.createPasswordResetTokenHandler) // ch. 22
			r.Put("/users/password", app.resetPasswordHandler)                    // ch. 22
			r.Put("/users/email", app.confirmEmailChangeHandler)                  // ch. 22
		})

		// --- authenticated ring (activation NOT required) ---
		//
		// ch. 22's fix-your-own-account flows live here: they require
		// proving a password while not yet activated.
		r.Group(func(r chi.Router) {
			r.Use(app.requireAuthenticatedUser)
			r.Use(app.rateLimitUser) // ch. 14/17 — plan-aware, per user

			r.Put("/me/password", app.changePasswordHandler) // ch. 22
			r.Put("/me/email", app.changeEmailHandler)       // ch. 22
			r.Delete("/me", app.deleteAccountHandler)        // ch. 22

			// --- activated ring: the product itself ---
			r.Group(func(r chi.Router) {
				r.Use(app.requireActivatedUser) // ch. 21

				r.Route("/tasks", func(r chi.Router) {
					// Idempotency keys on exactly the creates that hurt
					// to duplicate (ch. 23).
					r.With(app.idempotent).Post("/", app.createTaskHandler) // ch. 8
					r.Get("/", app.listTasksHandler)                        // ch. 9
					r.Get("/{id}", app.showTaskHandler)                     // ch. 8
					r.Patch("/{id}", app.updateTaskHandler)                 // ch. 8
					r.Delete("/{id}", app.deleteTaskHandler)                // ch. 8
				})

				r.Route("/billing", func(r chi.Router) {
					r.With(app.idempotent).Post("/checkout", app.createCheckoutHandler) // ch. 15/23
					// Beginner edition: ch. 16 step 4 and Appendix A name a
					// Customer Portal endpoint but never show its
					// handler; billing.go must define createPortalHandler.
					r.Post("/portal", app.createPortalHandler)
					r.Get("/plan", app.showPlanHandler) // ch. 17
				})
			})
		})
	})

	return r
}

3. Every route in the service

Method Path Handler Who may call it Rate limit Built in
GET /metrics metricsHandler no none Ch. 18
GET /docs docsPageHandler no none Ch. 24
GET /v1/healthcheck healthcheckHandler no none Ch. 2
GET /v1/openapi.yaml openapiHandler no none Ch. 24
POST /v1/stripe/webhook stripeWebhookHandler signature none Ch. 16
POST /v1/users registerUserHandler no per IP Ch. 10
POST /v1/tokens/authentication createAuthTokenHandler no per IP Ch. 11
POST /v1/tokens/activation createActivationTokenHandler no per IP Ch. 21
PUT /v1/users/activated activateUserHandler token in body per IP Ch. 21
POST /v1/tokens/password-reset createPasswordResetTokenHandler no per IP Ch. 22
PUT /v1/users/password resetPasswordHandler token in body per IP Ch. 22
PUT /v1/users/email confirmEmailChangeHandler token in body per IP Ch. 22
PUT /v1/me/password changePasswordHandler bearer per user Ch. 22
PUT /v1/me/email changeEmailHandler bearer per user Ch. 22
DELETE /v1/me deleteAccountHandler bearer per user Ch. 22
POST /v1/tasks createTaskHandler bearer + activated per user Ch. 8
GET /v1/tasks listTasksHandler bearer + activated per user Ch. 9
GET /v1/tasks/{id} showTaskHandler bearer + activated per user Ch. 8
PATCH /v1/tasks/{id} updateTaskHandler bearer + activated per user Ch. 8
DELETE /v1/tasks/{id} deleteTaskHandler bearer + activated per user Ch. 8
POST /v1/billing/checkout createCheckoutHandler bearer + activated per user Ch. 15
POST /v1/billing/portal createPortalHandler bearer + activated per user Ch. 16
GET /v1/billing/plan showPlanHandler bearer + activated per user Ch. 17

Three rows deserve a second look:

  • POST /v1/stripe/webhook sits outside authentication and outside the rate limiter. Stripe does not carry your bearer token, and its retry bursts must not be throttled into failure. Its authentication is the signature check inside the handler (Chapter 16).
  • GET /v1/healthcheck is outside every limiter. Rate-limiting your own monitoring turns your uptime check into the attack and your pager into the victim.
  • POST /v1/tasks and POST /v1/billing/checkout are the only routes wrapped in idempotent. They are the two creates where a duplicate costs the customer something real (Chapter 23).

4. The complete OpenAPI specification

This is the machine-readable contract served at GET /v1/openapi.yaml and rendered by the docs page at GET /docs. Chapter 24 teaches the pattern with three paths; all sixteen are here.

Note

The original edition printed two of these sixteen paths, yet Chapter 24’s own test walks every registered route and asserts it is documented — so that test, as printed, failed twelve times. With the full spec below, it passes.

# cmd/api/docs/openapi.yaml
openapi: 3.1.0
info:
  title: taskd API
  version: "1.0"
  description: Task management with plans, quotas, and idempotent creates.
servers:
  - url: https://api.yourdomain.com
security:
  - token: []

paths:
  /v1/healthcheck:
    get:
      summary: Liveness and database status
      security: []
      responses:
        "200":
          description: Service status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: {type: string}
                  database: {type: string}
                  environment: {type: string}
                  version: {type: string}

  /v1/tasks:
    get:
      summary: List your tasks
      parameters:
        - {name: status,    in: query, schema: {type: string, enum: [open, done, archived]}}
        - {name: priority, in: query, schema: {type: string}}
        - {name: search,    in: query, schema: {type: string}, description: Pro plan and above.}
        - {name: sort,      in: query, schema: {type: string, example: -created_at}}
        - {name: page,      in: query, schema: {type: integer, minimum: 1}}
        - {name: page_size, in: query, schema: {type: integer, maximum: 100}}
      responses:
        "200":
          description: A page of tasks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks: {type: array, items: {$ref: "#/components/schemas/Task"}}
                  metadata: {$ref: "#/components/schemas/Metadata"}
        "422": {$ref: "#/components/responses/ValidationError"}
    post:
      summary: Create a task
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: {type: string, maxLength: 200}
          description: Repeats with the same key replay the original response.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TaskInput"}
      responses:
        "201": {description: Created., content: {application/json: {schema:
                  {type: object, properties: {task: {$ref: "#/components/schemas/Task"}}}}}}
        "402": {description: Plan limit reached (code `upgrade_required`).}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/tasks/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer, format: int64}}
    get:
      summary: Fetch one task
      responses:
        "200": {description: The task.}
        "404": {description: Not found (including tasks you don't own).}
    patch:
      summary: Partially update a task
      description: Send `version` for optimistic locking; a stale version yields 409.
      responses:
        "200": {description: Updated.}
        "409": {description: Edit conflict.}
    delete:
      summary: Delete a task
      responses:
        "204": {description: Deleted.}

  /v1/users:
    post:
      summary: Register an account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, password]
              properties:
                name: {type: string, maxLength: 100}
                email: {type: string, format: email}
                password: {type: string, minLength: 8, maxLength: 72}
      responses:
        "202": {description: Accepted; an activation email is on its way.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/users/activated:
    put:
      summary: Activate an account with an emailed token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TokenInput"}
      responses:
        "200": {description: The activated user.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/users/password:
    put:
      summary: Reset a forgotten password with an emailed token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: {type: string, minLength: 26, maxLength: 26}
                password: {type: string, minLength: 8, maxLength: 72}
      responses:
        "200": {description: Password reset; all existing tokens are revoked.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/users/email:
    put:
      summary: Confirm an email change with an emailed token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TokenInput"}
      responses:
        "200": {description: Email updated.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/tokens/authentication:
    post:
      summary: Exchange email and password for a bearer token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: {type: string, format: email}
                password: {type: string}
      responses:
        "201":
          description: A 24-hour bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  authentication_token: {$ref: "#/components/schemas/Token"}
        "401": {description: Invalid authentication credentials.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/tokens/activation:
    post:
      summary: Resend an activation token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: {type: string, format: email}
      responses:
        "202": {description: Always accepted, whether or not the address has an unactivated account.}

  /v1/tokens/password-reset:
    post:
      summary: Request a password-reset token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: {type: string, format: email}
      responses:
        "202": {description: Always accepted, whether or not the address has an account.}

  /v1/me:
    delete:
      summary: Delete your account
      description: Cancels any live Stripe subscription first, then removes the user and everything cascading from it.
      responses:
        "204": {description: Deleted.}
        "401": {description: Invalid authentication credentials.}

  /v1/me/password:
    put:
      summary: Change your password
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, password]
              properties:
                current_password: {type: string}
                password: {type: string, minLength: 8, maxLength: 72}
      responses:
        "200": {description: Password changed; other sessions are revoked.}
        "401": {description: Invalid authentication credentials.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/me/email:
    put:
      summary: Start an email change
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: {type: string, format: email}
                password: {type: string}
      responses:
        "202": {description: Confirmation sent to the new address.}
        "401": {description: Invalid authentication credentials.}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/billing/checkout:
    post:
      summary: Start a Stripe Checkout session
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: {type: string, maxLength: 200}
          description: Repeats with the same key replay the original response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier]
              properties:
                tier: {type: string, enum: [pro, business]}
      responses:
        "200":
          description: The URL to send the customer to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  checkout_url: {type: string, format: uri}
        "422": {$ref: "#/components/responses/ValidationError"}

  /v1/billing/portal:
    post:
      summary: Open the Stripe customer portal
      description: Card updates, plan switches and cancellation, self-served.
      responses:
        "200":
          description: The URL to send the customer to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  portal_url: {type: string, format: uri}

  /v1/billing/plan:
    get:
      summary: Your current tier and entitlements
      responses:
        "200":
          description: What you are allowed to do.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tier: {type: string, enum: [free, pro, business]}
                  entitlements: {$ref: "#/components/schemas/Entitlements"}

components:
  securitySchemes:
    token: {type: http, scheme: bearer, description: From /v1/tokens/authentication.}
  schemas:
    Task:
      type: object
      properties:
        id: {type: integer, format: int64}
        title: {type: string}
        notes: {type: string}
        status: {type: string, enum: [open, done, archived]}
        priority: {type: string, enum: [none, low, medium, high]}
        due_at: {type: [string, "null"], format: date-time}
        version: {type: integer}
    TaskInput:
      type: object
      required: [title]
      properties:
        title: {type: string, maxLength: 500}
        notes: {type: string}
        priority: {type: string, description: Pro plan and above for non-none values.}
        due_at: {type: [string, "null"], format: date-time}
    Token:
      type: object
      properties:
        token: {type: string, minLength: 26, maxLength: 26}
        expiry: {type: string, format: date-time}
    TokenInput:
      type: object
      required: [token]
      properties:
        token: {type: string, minLength: 26, maxLength: 26}
    Entitlements:
      type: object
      properties:
        MaxActiveTasks: {type: integer, description: -1 means unlimited.}
        RatePerMinute: {type: integer}
        Priorities: {type: boolean}
        Search: {type: boolean}
    Metadata:
      type: object
      properties:
        current_page: {type: integer}
        page_size: {type: integer}
        last_page: {type: integer}
        total_records: {type: integer}
  responses:
    ValidationError:
      description: Field-level validation failures.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: {type: object, additionalProperties: {type: string}}

5. What to do with the spec

  • Validate it: npx @redocly/cli lint cmd/api/docs/openapi.yaml — catches a mistyped schema before a client does.
  • Read it: open http://localhost:4000/docs while the server runs.
  • Generate a client: openapi-generator or oapi-codegen will produce a typed client in most languages from this file. That is the whole point of writing it by hand — the contract is data, not prose.
  • Keep it honest: Chapter 24’s route-walk test is what stops the spec from drifting away from the code. Never delete that test to make CI green.

For your notes — copy these into learnings/appF.md in your own words:

  1. The router file is the only complete map of a service’s attack surface; read it first in any codebase.
  2. Middleware order is a series of deliberate decisions, not a list. Ask of every layer: what must still be true if the request is rejected below me?
  3. Authentication that identifies and authorisation that rejects are separate jobs, and keeping them separate is what lets one router serve public, authenticated and activated routes at once.
  4. A hand-written OpenAPI file is only trustworthy if a test forces it to match the router.

Appendix G — Glossary

Every technical word this book uses, defined in plain English, with the chapter where you first meet it. 459 entries.

Two ways to use it. Reading forwards, ignore it — each term is defined where it first appears, and the chapter’s own New words section repeats the ones it needs. Reading backwards, come here: when a word in Chapter 19 rings a faint bell from Chapter 6, this is faster than searching.

Tip

If a definition here still does not land, go to the chapter named at the end of the entry. A word makes sense when you see what it does, and that is where it does something.


:= — Declare-and-assign in one step: x := 5 creates x and infers its type. First met in Chapter 2.

12-factor — A widely-cited twelve-rule checklist for building apps that run well in containers; the rule used here is “keep configuration in environment variables, not in code”. First met in Chapter 3.

200 OK — Success, and the answer is in the body. First met in Chapter 2.

201 Created — Success, and something new now exists — the Location header says where. First met in Chapter 8.

202 Accepted — We took your request and will finish it in the background. Think of it like: “Your order is placed” — before the food is cooked. First met in Chapter 21.

204 No Content — Success, and there is deliberately nothing to send back. First met in Chapter 22.

400 Bad Request — Your request was malformed — we could not even read it. First met in Chapter 8.

401 Unauthorized — You are not identified; log in (badly named — it means unauthenticated). First met in Chapter 11.

402 Payment Required — You have hit a paid-plan limit; upgrade to continue. First met in Chapter 17.

403 Forbidden — We know who you are; you’re still not allowed. First met in Chapter 21.

404 Not Found — Nothing lives at that address — used here also for other people’s data, deliberately. First met in Chapter 2.

409 Conflict — Someone changed this record since you read it; fetch it again and retry. Think of it like: Two people editing the same paper form. First met in Chapter 8.

422 Unprocessable Entity — The request was well-formed JSON but its contents broke a rule (empty title, bad status value). Think of it like: The form is filled in neatly but the answers are wrong. First met in Chapter 8.

429 Too Many Requests — You are going too fast; slow down (see Retry-After). First met in Chapter 14.

500 Internal Server Error — Our fault, not yours; details are in our logs, never in your response. First met in Chapter 4.

5xx / 4xx — Shorthand for the whole family: 4xx = caller’s fault, 5xx = server’s fault. First met in Chapter 8.

A

account activation — Proving the signup email address is real by emailing a token that must be presented back before the product unlocks. Think of it like: Confirming the address on the delivery slip. First met in Chapter 21.

advisory lock (pg_advisory_xact_lock) — A named lock you ask Postgres to hold so only one transaction at a time runs a given piece of logic. Think of it like: Taking the single “counting the till” badge. First met in Chapter 17.

air-gapped — A machine with no internet access at all. First met in Chapter 24.

alert rule — A saved query with a threshold and a duration that notifies a human when it’s true. Think of it like: The smoke alarm’s trigger setting. First met in Chapter 18.

Alex Edwards — A British author of well-known practical Go web-development books; the source of this book’s file layout, application struct and error-handling style. First met in Front matter.

algorithm-confusion attack — A JWT-specific attack that tricks a verifier into accepting a token signed the wrong way. First met in Chapter 11.

allow-list vs wildcard * — An explicit list of permitted origins; * combined with credentials is how private APIs leak. First met in Chapter 23.

anonymous struct — A one-off struct declared at the spot it’s used, with no name — used here for request shapes. Think of it like: A sticky note instead of a printed form. First met in Chapter 8.

Ansible / Nomad — Larger deployment tools, described as the next steps up from SSH-and-compose. First met in Chapter 26.

any — A value of any type at all; Go’s escape hatch when the type genuinely varies. First met in Chapter 8.

API — A way for one program to ask another program to do something, over a fixed set of requests with fixed shapes. Think of it like: A drive-through menu: a short list of exact things you may order, and exactly what comes back. First met in Front matter.

argon2id / scrypt — Modern alternatives to bcrypt designed to also be memory-hungry. First met in Chapter 10.

artifact — The built, deployable thing a pipeline produces — here a tagged Docker image. First met in Chapter 26.

at-least-once delivery — The sender guarantees you’ll get every event, but may send some twice and out of order. Think of it like: Recorded delivery that sometimes posts a duplicate. First met in Chapter 16.

atomicity — The guarantee that an operation happens completely or not at all, with nothing in between. First met in Chapter 8.

B

backfill — Filling in values for existing rows when a new required column is added. First met in Chapter 12.

background work — Work started by a request but finished after the response is sent. Think of it like: Taking the order, then cooking. First met in Chapter 21.

backoff — Waiting longer between each failed retry instead of hammering. First met in Chapter 16.

base32 — An encoding turning raw bytes into 26 letters/digits that survive URLs, headers and copy-paste. First met in Chapter 11.

basic auth — The simplest HTTP authentication: a username and password sent with each request; suggested for protecting /metrics. First met in Chapter 27.

bcrypt — The password-hashing function used here, deliberately slow, with a cost knob. First met in Chapter 10.

best-effort — We try, and if it fails we carry on rather than failing the request. First met in Chapter 13.

bigint GENERATED ALWAYS AS IDENTITY — Postgres assigns 1, 2, 3… itself and forbids clients supplying their own id. Think of it like: Take-a-number machine. First met in Chapter 5.

binary / single static binary — One finished file the computer can run directly, with everything it needs already inside it. Think of it like: A microwave meal that needs no other ingredients. First met in Front matter.

bind parameter ($1) — A numbered placeholder in SQL that a value is safely substituted into by the database, never by string-pasting. Think of it like: Leaving a blank on the form for the clerk to fill, not rewriting the form. First met in Chapter 7.

blank identifier _ — A deliberate throwaway: “I must accept this value, and I am ignoring it on purpose”. First met in Chapter 3.

blast radius — How much damage a compromised credential or failing component can cause. First met in Chapter 26.

body — The actual data carried by a request or response, usually JSON here. Think of it like: The letter inside the envelope. First met in Chapter 2.

brute force / credential stuffing — Guessing passwords repeatedly, or trying username/password pairs stolen from other sites. First met in Chapter 14.

btree index — Postgres’s default index type — great for equality and ranges, useless for “contains” searches. First met in Chapter 9.

bucket / le — A histogram’s “less than or equal to” boundary; the le label must survive aggregation or percentile maths goes wrong. First met in Chapter 18.

burst — A short allowance to exceed the steady rate. First met in Chapter 14.

C

CA certificate — The list of certificate authorities a program trusts; without it every HTTPS connection fails. Think of it like: The book of official signatures a notary checks against. First met in Chapter 25.

cache — A fast temporary copy of an answer, kept so the slow original doesn’t have to be asked again. Think of it like: Keeping the phone number on a sticky note instead of looking it up each time. First met in Chapter 13.

cache-aside / lazy loading — Check the cache; on a miss, load from the database, save the answer, return it. The cache never talks to the database itself. Think of it like: Checking the sticky note first, then the phone book, then writing a new note. First met in Chapter 13.

Caddy — A small web server used here as the reverse proxy, notable for obtaining HTTPS certificates automatically. First met in Chapter 27.

cardinality — How many unique label combinations exist; putting an id or email in a label creates one series per value and eventually crashes Prometheus. Think of it like: Filing one folder per customer versus one per department. First met in Chapter 18.

CD (Continuous Deployment) — Automatically shipping every change that passes CI. First met in Chapter 26.

CDN — Content Delivery Network — a global cache in front of your site; a reason Vary matters. First met in Chapter 11.

channel — A typed pipe for passing values between goroutines; receiving waits until something arrives. Think of it like: A pneumatic tube between two desks. First met in Go in one sitting.

CHECK constraint — A rule the database enforces on every write, so invalid values cannot be stored by any code path. Think of it like: The bouncer, not the sign. First met in Chapter 5.

chi — A small Go library that matches an incoming web address to the piece of code that should answer it. Think of it like: The receptionist who reads the sign on your envelope and walks it to the right desk. First met in Front matter.

churn — The share of paying customers who cancel in a period; the opposite force to MRR. Think of it like: Water leaving the bucket while you pour more in. First met in absent from the book.

CI (Continuous Integration) — A robot that builds and tests every change automatically before it can be merged. Think of it like: The quality-control line before packing. First met in Chapter 5.

citext — A Postgres column type where comparisons ignore capitalisation, so Bob@x.com and bob@x.com are one account. First met in Chapter 10.

CLI — Command-Line Interface — a tool you type commands at, e.g. stripe listen. First met in Chapter 15.

clickjacking — Invisibly framing your page inside a hostile one so victims click things they can’t see. Think of it like: An invisible sheet over the “confirm” button. First met in Chapter 23.

client — Whatever is calling your API — a browser, a phone app, curl, another server. First met in Chapter 2.

clock skew — Two machines disagreeing about the time, which can break signature timestamp checks. First met in Chapter 16.

closure — A function written inside another function that remembers (“captures”) the variables around it even after the outer function has returned. Think of it like: A photograph that keeps the scene after everyone has left the room. First met in Chapter 21.

cmd/api — The folder holding the program’s entry point — the code that becomes the runnable binary. First met in Chapter 2.

Compose healthcheck — A command Docker runs repeatedly to decide whether a container is actually ready, not merely started. Think of it like: Knocking before you walk in. First met in Chapter 5.

composite index — An index over two columns together, e.g. (user_id, created_at). First met in Chapter 12.

concurrency — Several things making progress at once inside one program. First met in Go in one sitting.

config / configuration — Every setting that differs between your laptop and production — ports, addresses, passwords, keys. First met in Chapter 3.

connection pool — A small set of database connections opened once and lent out to requests, because opening one is expensive. Think of it like: A pool cars for the office rather than buying one per trip. First met in Chapter 6.

constant-time comparison — Comparing secrets in a way that takes the same time whether or not they match, so timing reveals nothing. First met in Chapter 10.

constraint — Any rule the database enforces itself: uniqueness, non-null, check, foreign key. First met in Chapter 5.

container — A running, isolated copy of a packaged program, with its own filesystem and network view. Think of it like: One shipping container on the deck. First met in Chapter 5.

context (Go) — A per-request value that carries a cancellation signal (and later, values) down through every function. Think of it like: The “stop everything” whistle that everyone on the job can hear. First met in Chapter 6.

context key collision — Two packages accidentally using the same context key; prevented here by an unexported private type. First met in Chapter 11.

contract — The promises your API makes to callers, as opposed to how it happens to be implemented today. First met in Chapter 24.

convergence over choreography — Treat every event as “here is the current truth, write it down”, rather than as a step in a sequence. Think of it like: Copying the latest scoreboard instead of replaying every goal. First met in Chapter 16.

correlation (request ID) — Tagging every log line of one request with a shared random ID so they can be found together — and giving that ID to the user for support tickets. Think of it like: The reference number on a complaint. First met in Chapter 19.

CORS — Rules telling browsers which other sites’ JavaScript may read your responses; it is not access control, since curl ignores it entirely. Think of it like: A guest list the venue’s own staff enforce — it does nothing about people arriving by helicopter. First met in Chapter 23.

CORS middleware — Code that tells web browsers which other websites’ JavaScript may read your API’s responses. First met in Front matter.

cost / work factor — The bcrypt setting that doubles the work per increment; 12 here (~250 ms), 4 in tests. First met in Chapter 10.

counter — A metric that only ever goes up: requests served, errors, cache hits. Think of it like: The odometer. First met in Chapter 18.

coverage — The percentage of lines a test suite executes; a poor goal on its own. First met in Chapter 20.

CRUD — Create, Read, Update, Delete — the four basic operations on stored records. First met in Chapter 8.

crypto/rand vs math/randcrypto/rand produces unguessable randomness from the operating system; math/rand produces predictable numbers and must never generate secrets. Think of it like: Dice from a casino versus dice a magician gave you. First met in Chapter 11.

CSP — Content Security Policy — a header restricting what a page may load and execute; relevant only once you serve HTML. First met in Chapter 23.

curl — A command-line program that sends one web request and prints the answer. Think of it like: Poking a website with a stick and reading what falls out. First met in Front matter.

custom collector — Code that produces metric values on demand at scrape time, e.g. reading pool statistics. First met in Chapter 18.

Customer Portal — A Stripe-hosted page where your users update cards, switch plans and cancel — every action arriving back at your webhook. First met in Chapter 16.

D

data minimisation — The principle of keeping as little personal data as you can get away with. First met in Chapter 22.

database trigger — Code the database runs automatically whenever a row changes. First met in Chapter 17.

DBTX — The generated interface satisfied by both the pool and a transaction, so the same query code works in either. First met in Chapter 7.

decoder / json.Decoder — The object that reads JSON out of a request body one value at a time. First met in Chapter 8.

defer — Schedules a piece of cleanup to run when the surrounding function finishes, whatever the exit route. Think of it like: Setting the “lock up when you leave” reminder as you walk in. First met in Go in one sitting.

degraded mode — Running with a component missing: slower or with fewer features, but still correct and available. Think of it like: Running the shop with the card machine down, cash only. First met in Chapter 13.

deny-list — A list of credentials that are no longer accepted. First met in Chapter 11.

dependency — Someone else’s code your program needs in order to build and run. First met in Front matter.

dependency injection — Handing a piece of code the things it needs instead of letting it fetch them itself. Here it is just one struct. Think of it like: Giving the chef the ingredients rather than sending them shopping. First met in Chapter 2.

DI container (dependency injection) — A framework that automatically supplies each part of a program with the other parts it needs; rejected here as unnecessary in Go. First met in Front matter.

direnv / .envrc — A file of machine-local environment variables; direnv loads it automatically when you enter the folder. First met in Chapter 5.

dirty migration state — The flag migrate sets when a migration fails halfway, forcing a human to look before anything else runs. Think of it like: A jammed machine that refuses to restart until inspected. First met in Chapter 5.

DisallowUnknownFields — Reject bodies containing keys we don’t recognise, so a client’s typo is an error instead of silent data loss. First met in Chapter 8.

distroless — A minimal base image with certificates and time-zone data but no shell and no package manager, so a compromised container has nothing to work with. Think of it like: An empty room with one door and no tools left lying around. First met in Chapter 25.

Docker — A tool that packages a program plus everything it needs into a sealed box that runs the same everywhere. Think of it like: A shipping container: identical fittings whatever’s inside. First met in Front matter.

Docker Compose — A file that describes several Docker containers and starts them together as one system. Think of it like: The stage plot that says which instruments set up where. First met in Front matter.

docker history — A command that shows every layer of an image — which is why build-time secrets are permanently exposed. First met in Chapter 25.

.dockerignore — A list of files never sent to the Docker build, keeping builds fast and secrets out of images. First met in Chapter 25.

DoS (denial of service) — Making a service unavailable by overwhelming it — here, by requesting a ten-million-row page. First met in Chapter 9.

downgrade stranding — A user drops to a cheaper plan while holding more data than it allows; a policy question, not a code question. First met in Chapter 17.

DragonflyDB — A very fast in-memory store used for temporary data — caches and counters. Speaks the same commands as Redis. Think of it like: A notepad by the phone: fast to write, expected to be thrown away. First met in Front matter.

drain — Letting in-flight work finish while accepting no new work. First met in Chapter 4.

drift — Documentation and reality silently diverging over time. First met in Chapter 24.

DSN (Data Source Name) — One string containing everything needed to reach a database: type, username, password, host, port, database name, options. Think of it like: A full postal address written on one line. First met in Chapter 3.

DTO — Data Transfer Object — an extra struct that exists only to shape data for the wire; deliberately avoided here. First met in Chapter 8.

dunning — The retry-and-remind process a payment provider runs after a card is declined, before giving up. Think of it like: The polite series of “your payment failed” letters. First met in Chapter 16.

durable queue — A job list stored on disk (or in Postgres) so queued work survives a crash or restart. First met in Chapter 21.

E

endpoint — One address-plus-method your API answers, e.g. POST /v1/tasks. Think of it like: One item on the menu. First met in Chapter 2.

entitlement — What a specific user is allowed to do right now, derived from their plan — limits and feature switches. Think of it like: What your ticket actually admits you to today. First met in Chapter 15.

entropy / “128 bits of randomness” — A measure of how unguessable a secret is; 128 bits means guessing is hopeless. First met in Chapter 11.

envelope — This book’s convention of wrapping every response in a named key: {"task": {...}}, {"error": ...}. Think of it like: Putting the letter in a labelled envelope instead of handing over a loose page. First met in Chapter 8.

environment variable — A named value the operating system hands to a program when it starts — the standard way to pass settings and secrets into a container. Think of it like: A note pinned to the door before the shop opens. First met in Front matter.

error (as a value) — In Go a failure is returned as an ordinary value you must check, not thrown as an exception. Think of it like: A receipt that says “this didn’t work” handed back to you every time. First met in Go in one sitting.

error wrapping / %w — Attaching context to an error while keeping the original inside it, so the message reads “loading config: file not found”. Think of it like: Stapling a covering note to a complaint before passing it up. First met in Go in one sitting.

errors.Is / errors.AsIs asks “is this that known error?”; As asks “is this an error of type X, and if so give it to me”. First met in Chapter 8.

exception — The error style used by other languages, where a failure jumps out of the code by itself; Go deliberately has none. First met in Go in one sitting.

EXCLUDED — Inside an upsert, the name for the values that were being inserted, used to overwrite the existing row. First met in Chapter 15.

EXPLAIN / EXPLAIN ANALYZE — The command that shows how Postgres plans to run (or actually ran) your query. Think of it like: Asking the driver to show you the route before the trip. First met in Chapter 9.

exported vs unexported (capital letters) — In Go, a name starting with a capital letter is visible to other packages; a lowercase name is private to its own package. There is no public/private keyword. Think of it like: Capitalisation is the lock on the door. First met in Chapter 3.

ExposedHeaders — Without it, browser JavaScript receives your custom headers but is not allowed to read them. Think of it like: Handing over a sealed envelope. First met in Chapter 23.

extension (Postgres) — An optional add-on that gives Postgres new types or functions; enabled with CREATE EXTENSION. First met in Chapter 10.

F

fail closed / fail open — When something breaks, fail closed = deny by default; fail open = allow by default. Pick per feature: money fails closed, fairness fails open. Think of it like: A locked door on power loss versus an unlocked one. First met in Chapter 12.

fail fast — Refusing to start at all when something essential is wrong, rather than half-running and failing later on a user. First met in Chapter 3.

fail-to-free — This book’s policy: if billing lookups break, treat the user as free-tier rather than locking them out entirely. First met in Chapter 17.

feature gate — A check that turns a feature on or off for a user based on their plan. Think of it like: The velvet rope in front of the VIP room. First met in Chapter 15.

federation / OIDC — Arrangements where a third party vouches for a user’s identity so other systems don’t have to ask you (e.g. “Sign in with Google”). First met in Chapter 11.

file descriptor — The operating system’s numbered handle for an open file or connection; each process may only have so many, and leaking them eventually stops the server. Think of it like: Numbered cloakroom tickets — run out and nobody else can check a coat. First met in Chapter 2.

fixed window — Count requests per calendar minute and reset at the boundary; simple, with a known 2× burst at the edge. Think of it like: A parking meter that resets on the hour. First met in Chapter 14.

fixture — The known starting state a test sets up before it asserts anything. First met in Chapter 20.

flag (command-line) — An option typed after the program name, e.g. -config config.toml. First met in Chapter 3.

flame graph — A profiling chart showing where a program spends its time. First met in Chapter 11.

FOR UPDATE SKIP LOCKED — The Postgres trick that lets several workers claim different rows from one queue table without blocking each other. Think of it like: Several people picking from one stack, each taking a different sheet. First met in Chapter 21.

foreign key / FK / REFERENCES — A column that must match a row in another table, so the database itself forbids orphans. Think of it like: A cloakroom ticket that must correspond to a real coat. First met in Chapter 5.

G

gauge — A metric that goes up and down: requests in flight, connections open. Think of it like: The fuel gauge. First met in Chapter 18.

GDPR — European data-protection law; the reason “delete my account” must really delete data. First met in Chapter 12.

generated code — Code written by a tool, not a person — never hand-edit it, because regenerating destroys your edits. First met in Chapter 2.

generics ([T comparable]) — Writing one function that works for several types instead of copying it per type. Think of it like: One adjustable spanner instead of a drawer of fixed ones. First met in Chapter 8.

GIN index / pg_trgm / tsvector — Index types and extensions for real text search; noted as the upgrade path, not built. First met in Chapter 9.

Git / commit / repository — Git records the history of your files; a commit is one saved snapshot; a repository is the project plus its whole history. Think of it like: A time machine with a written reason for each stop. First met in Chapter 2.

GitHub Actions / workflow / job / step — A workflow is a YAML file of named shell steps GitHub runs on a fresh throwaway machine whenever a chosen event fires. First met in Chapter 26.

.gitignore — A list of files Git should pretend not to see — build output, secrets. First met in Chapter 2.

global variable — A value any code in the package can read or change; avoided here because it hides who depends on what. Think of it like: Leaving the office keys on the shared table. First met in Chapter 2.

Go — A programming language made at Google, designed to be small, fast, and easy to read. First met in Front matter.

go vet — A built-in checker for suspicious-but-compiling code. First met in Chapter 26.

//go:embed — A directive that bakes files (templates, the OpenAPI spec) into the compiled binary. Think of it like: Laminating the instructions onto the machine. First met in Chapter 21.

go.mod — The file naming your module and listing its dependencies and versions. Think of it like: The ingredients list. First met in Chapter 2.

gofmt — The formatter that ends all style arguments by rewriting code to one canonical layout. First met in Chapter 26.

golang-migrate — The specific command-line tool this book uses to apply migrations. First met in Front matter.

goroutine — A piece of work running at the same time as the rest of the program, extremely cheap to start. Think of it like: An extra pair of hands you can hire for a second and dismiss. First met in Go in one sitting.

govulncheck / call graph — Scans which vulnerable library functions your code actually reaches, so its warnings are real rather than theoretical. Think of it like: Checking which recalled parts are actually in your car. First met in Chapter 26.

grace period — Continuing to grant access during dunning rather than cutting a paying customer off at the first failed charge. First met in Chapter 17.

graceful shutdown — Stopping the server by refusing new requests, letting the ones in progress finish, then exiting. Think of it like: Locking the shop door at closing time but serving the people already inside. First met in Chapter 4.

Grafana — The usual dashboard tool that draws graphs from Prometheus data. First met in Chapter 18.

grandfathering — Applying a new rule only to new records, so existing users are unaffected. First met in Chapter 21.

H

handler — The function that actually answers one kind of request. Think of it like: The clerk at the desk your envelope reached. First met in Chapter 2.

hard delete vs soft delete — Hard delete removes the row; soft delete marks it dead and forces every future query to filter it out. Think of it like: Shredding the file versus stamping it “closed”. First met in Chapter 22.

hardcoding — Writing a setting directly into the source code, so changing it means editing and rebuilding the program. Think of it like: Painting the price onto the wall. First met in Chapter 3.

hash / one-way function — A scrambler that is easy to run forwards and hopeless to reverse; passwords are stored only as hashes and compared by re-hashing. Think of it like: Blending fruit: easy to make a smoothie, impossible to get the fruit back. First met in Chapter 10.

header — A labelled line of metadata attached to a request or response — content type, auth token, cache instructions. Think of it like: The envelope’s markings, as opposed to the letter inside. First met in Chapter 2.

Hetzner storage box — A cheap remote file-storage product, referenced as where backups already go. First met in Chapter 27.

histogram — A metric that records how many observations fell into each bucket, so percentiles can be computed later across all instances. Think of it like: Sorting exam marks into grade bands. First met in Chapter 18.

hit / miss — A hit is finding the answer in the cache; a miss is not. First met in Chapter 13.

HMAC — A cryptographic fingerprint computed over a message using a shared secret; matching it proves the sender knew the secret and the bytes weren’t altered. Think of it like: A wax seal only two people own the stamp for. First met in Chapter 16.

host.docker.internal — A special hostname letting a container reach the machine hosting Docker; needs extra_hosts on plain Linux. First met in Chapter 18.

hot path — Code that runs on every request, where even small costs multiply. First met in Chapter 19.

HTTP — The set of rules browsers and servers use to talk: the client sends a request, the server sends back a response. Think of it like: Posting a letter and receiving a reply. First met in Chapter 1.

HTTP method (GET/POST/PATCH/PUT/DELETE) — The verb of a request: GET fetches, POST creates, PATCH edits part of something, PUT replaces or performs an action, DELETE removes. First met in Chapter 2.

HTTP request — One message from a client asking for something: a method, a path, headers, and sometimes a body. First met in Chapter 2.

HTTP response — The server’s reply: a status code, headers, and usually a body. First met in Chapter 2.

http.Handler / ServeHTTP — Go’s word for “anything that can answer an HTTP request”. First met in Go in one sitting.

httptest — Go’s standard helper for running your real router on a real port inside a test. Think of it like: A crash-test rig, not a drawing of one. First met in Chapter 20.

I

idempotency key — A client-chosen header value; the server remembers the response per (user, endpoint, key) and replays it on retries instead of acting twice. Think of it like: A cloakroom ticket: same ticket, same coat, not a second coat. First met in Chapter 23.

idempotency ledger — A table of already-processed event IDs, so replaying an event does nothing the second time. Think of it like: The doorman’s list of who’s already been admitted. First met in Chapter 15.

idempotent — Doing the same operation twice has the same effect as doing it once. Think of it like: Pressing a lift button that’s already lit. First met in Chapter 16.

if err != nil — The standard three-line Go check performed after any operation that can fail. First met in Go in one sitting.

ILIKE — Postgres’s case-insensitive “contains this text” match. First met in Chapter 9.

image — The frozen template a container is started from. Think of it like: The mould; the container is the casting. First met in Chapter 5.

immutable tag — Tagging images by commit SHA so “what is running?” has exactly one answer, and rollback is redeploying an old tag. First met in Chapter 25.

import — The statement that lets one file use code from another package. First met in Chapter 2.

in-flight request — A request that has arrived and is still being worked on. First met in Chapter 4.

index — An extra sorted structure the database keeps so it can find matching rows without reading them all. Think of it like: The index at the back of a book. First met in Chapter 5.

information disclosure — Accidentally telling an attacker something useful through an error message or timing. First met in Chapter 8.

instance / replica — One running copy of your app; several may run at once behind a load balancer. First met in Chapter 14.

integration test — A test that exercises several real parts together — here, handlers against a real Postgres. First met in Chapter 20.

interface — A list of methods; any type with those methods automatically qualifies, with nothing to declare. Think of it like: “Anything that can be plugged into a socket” — the plug shape is the qualification. First met in Go in one sitting.

internal/ — A folder name Go treats specially: code inside it cannot be imported by anyone outside this project. Think of it like: A staff-only door the building enforces, not just signposts. First met in Chapter 2.

invalidation — Making cached answers stop being used once the underlying data changes. First met in Chapter 13.

J

janitor — This book’s name for a background loop that deletes expired rows on a schedule. First met in Chapter 21.

JOIN / INNER JOIN — A SQL clause that combines rows from two tables by a matching column — here, token to user. Think of it like: Matching cloakroom tickets to coats in one pass. First met in Chapter 11.

jq — A command-line tool for filtering and reshaping JSON, used here to filter logs. First met in Chapter 19.

JSON — The standard text format for data on the web: {"title": "buy milk", "done": false}. Think of it like: A form filled in with labelled boxes. First met in Chapter 1.

JWT — A self-contained signed token that can be verified without a database lookup — and therefore cannot be revoked, which is why this book doesn’t use one. Think of it like: A signed permission slip nobody can take back once written. First met in Chapter 11.

K

Kailash Nadh — CTO of Zerodha and author of several small, deliberately minimal open-source Go tools; the source of this book’s “few dependencies, boring technology” attitude. First met in Front matter.

keep-alive connection — Reusing one open connection for several requests instead of reconnecting each time. Think of it like: Keeping the phone line open between questions. First met in Chapter 2.

key stretching — Deliberately slowing a hash down so each guess costs an attacker time — needed for human passwords, unnecessary for random tokens. First met in Chapter 11.

key versioning / generation number — Building a counter into every cache key so incrementing the counter makes all old keys unreachable in one step. Think of it like: Changing the lock instead of collecting every copy of the key. First met in Chapter 13.

keyset pagination — “Give me the next 20 after this item” — fast at any depth, but no page numbers. Think of it like: A bookmark instead of a page count. First met in Chapter 9.

koanf — Nadh’s small Go library for reading settings from files and environment variables. First met in Front matter.

Kubernetes — A large system for running containers across many machines automatically; deliberately not used in this book. First met in Chapter 4.

L

label / time series — Labels are the key=value tags on a metric; each unique combination is a separate stored series. First met in Chapter 18.

latency — How long one request takes from start to answer. First met in Chapter 6.

layer / layer caching — Docker images are stacked steps; unchanged steps are reused, which is why dependencies are copied before source. First met in Chapter 25.

lazy vs eager creation — Create the record the first time it’s actually needed, rather than up front for everyone. First met in Chapter 15.

-ldflags / -s -w / -X — Build-time flags that strip debug tables and stamp a value (the git commit) into a variable. Think of it like: Engraving the serial number at the factory. First met in Chapter 25.

Let’s Encrypt — The free certificate authority Caddy uses to issue HTTPS certificates automatically. First met in Chapter 27.

Let’s Go Further — Alex Edwards’ second Go book, about building a JSON API. This book borrows its structure and repeatedly compares choices against it. First met in Front matter.

libc / musl vs glibc — The two common C standard libraries on Linux; mixing them is a classic Alpine build failure — avoided entirely by pure-Go dependencies. First met in Chapter 25.

linter / staticcheck — A tool that flags likely mistakes and dead code beyond what the compiler rejects. Think of it like: The proof-reader after the spellchecker. First met in Chapter 26.

listmonk — Kailash Nadh’s open-source newsletter/mailing-list application, written in Go; cited as the model for keeping SQL in plain files. First met in Front matter.

liveness / readiness probe — Automatic checks an orchestrator makes: is the app alive, and is it ready for traffic? First met in Chapter 6.

load balancer — A machine in front of several copies of your app that spreads requests among them. Think of it like: The person at the head of the queue directing you to the next free till. First met in Chapter 4.

lock-in — Being unable to change a component later without rewriting your code. First met in Chapter 13.

log — A record of one event with full detail; expensive to aggregate but the only thing that explains why. Think of it like: The flight recorder transcript. First met in Chapter 18.

log level (debug/info/warn/error) — How important a log line is, so production can keep the loud ones and drop the chatty ones. First met in Chapter 3.

Loki / Dozzle — Tools for collecting and browsing container logs; assumed to be part of the reader’s existing stack. First met in Chapter 19.

lost update — The bug optimistic locking prevents: the second writer silently erases the first writer’s change. First met in Chapter 8.

LRU — Least Recently Used — a cache that evicts whatever hasn’t been touched for longest. First met in Chapter 14.

Lua script (Redis) — A tiny program the cache runs as one atomic step. First met in Chapter 14.

M

magic comment (-- name: X :one) — The comment above each query that tells sqlc the Go function’s name and how many rows it returns. Think of it like: The label on the jar that determines what’s printed on the tin. First met in Chapter 7.

mail relay — The server that actually accepts and forwards your outgoing mail. First met in Chapter 21.

Mailpit — A fake email server for development: it accepts mail and shows it in a web page instead of delivering it. Think of it like: A test inbox that catches everything so nothing escapes to real people. First met in Front matter.

Mailpit — A development SMTP server with a web inbox at :8025 that catches all outgoing mail. First met in Chapter 21.

make — Creates a ready-to-use slice, map or channel. First met in Chapter 4.

Makefile — A file of named shortcuts for long commands, run as make <name>. Think of it like: Speed-dial for your terminal. First met in Front matter.

map — A lookup table from keys to values, written map[K]V. Think of it like: A phone book: name in, number out. First met in Chapter 8.

marshal / unmarshal (serialize / deserialize) — Turning Go values into JSON text, and back. Think of it like: Packing and unpacking. First met in Chapter 8.

MaxBytesReader / body size cap — A hard limit on how many bytes a client may send, so nobody can post gigabytes into your memory. Think of it like: A letterbox too small for a parcel bomb. First met in Chapter 8.

max_connections — Postgres’s hard ceiling on simultaneous connections across every app instance, tool and backup job. First met in Chapter 6.

memory-hard / GPU-resistant — Designed to need lots of RAM, so attackers can’t run thousands of guesses in parallel on graphics cards. First met in Chapter 10.

method — A function attached to a particular type, so it can be called as thing.DoSomething(). Think of it like: An appliance’s own buttons rather than a separate remote. First met in Go in one sitting.

metric — A number your program reports continuously, cheap to store for years and instantly queryable. Think of it like: The car’s speedometer. First met in Chapter 18.

microservices — Splitting one program into many small independently deployed programs; rejected here in favour of one program. First met in Front matter.

middleware — A function that wraps a handler, doing something before and/or after it, and returns a handler again — so they stack. Think of it like: Airport layers: security, then passport, then gate, then the plane; in reverse on the way out. First met in Chapter 4.

middleware.RealIP — Chi middleware that replaces the proxy’s address with the real client IP from X-Forwarded-For — safe only behind a trusted proxy. First met in Chapter 27.

migrations / schema migrations — Numbered SQL files that change the database’s structure, applied in order so every copy of the database ends up identical. Think of it like: Numbered assembly-instruction steps — everyone builds the same shelf. First met in Front matter.

MIME sniffing — A browser guessing a response’s type from its contents rather than its declared type. First met in Chapter 23.

mock — A stand-in object that pretends to be a dependency; rejected here for the database, because it only proves you call functions you told yourself to call. Think of it like: Rehearsing with a cardboard cut-out. First met in Chapter 20.

module (Go) — One Go project, identified by a name that looks like a web address; that name is also the prefix for importing its own code. First met in Chapter 2.

monolith — One program that contains all the features, deployed as a unit. Think of it like: One restaurant kitchen instead of twelve food trucks. First met in Front matter.

MRR (Monthly Recurring Revenue) — The predictable revenue a subscription business bills every month; the number this whole billing system exists to grow. Think of it like: The rent roll, not the one-off sales. First met in absent from the book.

multi-stage build — A Dockerfile with a build stage containing the compiler and a final stage containing only the finished binary. Think of it like: Building the ship in the yard and shipping only the ship. First met in Chapter 25.

mutex — A lock ensuring only one goroutine touches shared data at a time; Go maps crash if written concurrently without one. Think of it like: The single key to the stationery cupboard. First met in Chapter 14.

N

NAT — Network address translation — the router layer that quietly drops long-idle connections, which is why idle connections are recycled. First met in Chapter 6.

nil — The “nothing here” value — an absent pointer, empty map, or missing error. Think of it like: An empty pigeonhole. First met in Chapter 8.

non-root / UID 65532 — Running the container as an unprivileged user so a break-in gains little. Think of it like: Giving the contractor a visitor pass, not the master key. First met in Chapter 25.

NOT NULL / nullable / NULLNULL means “no value at all”; NOT NULL forbids it for that column. Think of it like: An empty box versus a box containing the word “empty”. First met in Chapter 5.

O

offset paginationLIMIT 20 OFFSET 40 — skip 40 rows, take 20; simple, supports “jump to page 7”, slows down on deep pages. Think of it like: Counting pages from the front of the book every time. First met in Chapter 9.

ON DELETE CASCADE — Deleting a user automatically deletes their dependent rows — powerful and dangerous. Think of it like: Pulling one thread and the whole seam comes out. First met in Chapter 12.

:one / :many / :exec / :execrows — Returns exactly one row / a list / nothing / the count of rows affected. First met in Chapter 7.

one-shot migration container — A container whose whole job is to run migrations once and exit before the app starts. Think of it like: The stagehand who sets the scene and leaves. First met in Chapter 25.

OOM — Out Of Memory — the process being killed for using too much RAM. First met in Chapter 18.

OpenAPI — A standard YAML/JSON description of what an API accepts and returns — the machine-readable contract. Think of it like: The published menu with prices and allergens. First met in Chapter 24.

OpenTelemetry / distributed tracing — The industry standard for following one request across many services; unnecessary while there is only one binary. Think of it like: Following a parcel across several couriers. First met in Chapter 19.

operational drill — Deliberately breaking something in a controlled way to check the system degrades as designed. First met in Chapter 27.

optimistic locking / version column — Every row carries a counter; an update only succeeds if the counter still matches what you read, so the second of two simultaneous editors is told to retry. Think of it like: Two people editing one document: whoever saves second is told the page changed. First met in Chapter 8.

oracle (security) — Any behaviour that answers an attacker’s question for free — “does this email exist?”, “does this task id exist?” Think of it like: A witness who nods when you guess right. First met in Chapter 11.

orchestrator — Software that decides which containers run on which machines (Kubernetes, Nomad). Think of it like: The dispatcher for a fleet. First met in Chapter 6.

org / membership / role — The team-accounts feature sketched as the natural next project: many users sharing one tenant, with permissions. First met in Chapter 27.

origin — Scheme + host + port together, e.g. https://app.example.com — the unit CORS allows or denies. First met in Chapter 23.

ORM (Object-Relational Mapper) — A library that writes SQL for you from code objects; rejected here because you end up debugging SQL you never wrote. Think of it like: An over-helpful assistant who paraphrases your emails before sending them. First met in Front matter.

override / precedence — When two sources set the same setting, the rule for which wins — here, environment beats file. First met in Chapter 3.

P

package — A folder of Go files that share a name and are imported together; the unit of code organisation in Go. Think of it like: A chapter of a book — self-contained, referred to by name. First met in CC (implicit).

pagination — Returning results in numbered pages rather than all at once. First met in Chapter 9.

panic — Go’s “this should be impossible” crash; it unwinds the current goroutine and, unhandled, kills the program. Think of it like: The fire alarm — everything stops. First met in Chapter 4.

password reset — Replacing “what you know” with “what you can read in your inbox” — and revoking every existing session on success. First met in Chapter 22.

past_due / trialing / canceled / unpaid — Stripe subscription statuses: payment failed but retrying / free trial / ended / given up. First met in Chapter 16.

PATCH / partial update — Sending only the fields you want changed, leaving the rest alone. First met in Chapter 8.

PCI scope — The compliance burden that lands on anyone whose systems touch raw card numbers — avoided entirely by using hosted Checkout. First met in Chapter 15.

pending email — The new address parked in a separate column until a token sent to it confirms the swap. Think of it like: Not changing the nameplate until the new tenant’s key works. First met in Chapter 22.

PgBouncer — A separate connection-pooling proxy placed in front of Postgres; noted as a future trap, not used. First met in Chapter 6.

pg_dump / backup / restore test — Dumping the database to a file on a schedule, and — the step everyone skips — regularly restoring it somewhere to prove it works. Think of it like: A fire drill, not just a fire extinguisher. First met in Chapter 27.

pgx / pgx/v5 — The Go library that actually speaks PostgreSQL’s network language. Think of it like: The phone line to the database. First met in Front matter.

.PHONY — Tells make that a target is a command to run, not a file to build. First met in Chapter 5.

PII — Personally Identifiable Information — names, emails, addresses; logging it accumulates legal risk. First met in Chapter 19.

ping — One trivial round trip used to prove the connection actually works. Think of it like: Tapping the microphone. First met in Chapter 6.

pinning (to a commit SHA) — Depending on an exact immutable version rather than a moving tag, so third-party code can’t change under you. First met in Chapter 26.

plaintext (password) — The password as the user typed it — never stored, never logged. First met in Chapter 10.

pointer / & / * — A pointer is the address of a value rather than a copy of it; &x takes the address, *T in a type means “address of a T”. Think of it like: A street address versus a photocopy of the house. First met in Go in one sitting.

port (4000, 5432, 6379) — A numbered door on a machine; each server program listens on its own. Think of it like: Flat numbers at one street address. First met in Chapter 2.

PostgreSQL / Postgres — A mature open-source database that stores data in tables and answers questions written in SQL. Think of it like: A very strict, very reliable filing cabinet that only accepts precisely-worded requests. First met in Front matter.

pre-commit hook — A script Git runs before each commit that can refuse it — e.g. if it spots a secret. Think of it like: A bag check on the way out. First met in Chapter 3.

preflight / OPTIONS — A permission-asking request browsers send before certain cross-site calls; it carries no auth token, so it must be answered before your auth middleware. Think of it like: Phoning ahead to ask if you may visit. First met in Chapter 23.

preimage — The original input to a hash; “brute-forcing the preimage space” means guessing inputs until the hash matches. First met in Chapter 11.

premature optimization — Making something faster before you have evidence it is slow. First met in Chapter 9.

prepared statement — A query the database parses once and reuses, tied to a single connection. First met in Chapter 6.

price ID / product (Stripe) — Stripe’s identifiers for what you sell and what it costs; they differ between test and live mode, so they live in config. First met in Chapter 15.

primary key — The column whose value uniquely identifies each row. Think of it like: The membership number. First met in Chapter 5.

principal — Whoever a request acts as — the authenticated user. Idempotency stores must be per-principal or they leak. First met in Chapter 23.

projection — A local copy of another system’s state, kept only for speed and never treated as authoritative. Think of it like: A photocopy of the deeds. First met in Chapter 15.

Prometheus — A monitoring system that periodically visits your program and records its numbers over time. Think of it like: A nurse taking your vitals every fifteen seconds and keeping the chart. First met in Front matter.

PromQL (rate(), histogram_quantile()) — Prometheus’s query language for turning stored series into rates, ratios and percentiles. First met in Chapter 18.

provisioning — Actually granting a customer the thing they paid for. First met in Chapter 15.

psql — Postgres’s own command-line client for typing SQL directly. First met in Chapter 5.

Q

quantile / percentile / p99 — p99 = the latency that 99% of requests come in under; the number that describes your worst-served users. Think of it like: The slowest one customer in every hundred. First met in Chapter 18.

query planner — The part of Postgres that decides how to execute a query. First met in Chapter 9.

query string — The ?status=open&page=2 part of a URL, carrying optional parameters. First met in Chapter 9.

quota — A numeric cap on something countable, e.g. 100 active tasks on the free plan. First met in Chapter 17.

R

race / race condition — A bug that appears only when two things happen at the same time in the wrong order. Think of it like: Two people grabbing the last seat. First met in Chapter 10.

race detector (-race) — A Go build mode that finds concurrent access bugs while tests run. First met in Chapter 14.

rainbow table — A precomputed dictionary of hash→password, defeated entirely by salting. Think of it like: A cheat sheet of every answer, useless when everyone gets a different exam. First met in Chapter 10.

rate limiting — Capping how many requests a caller may make in a period. First met in Chapter 14.

read-your-own-writes — The guarantee that after you change something, you immediately see your own change. First met in Chapter 13.

ReadTimeout / WriteTimeout / IdleTimeout — Limits on how long a client may take to send, how long we may take to reply, and how long an unused connection may linger. Go’s defaults are no limit. First met in Chapter 2.

receiver — The (app *application) part before a method’s name: it names the value the method was called on. First met in Go in one sitting.

reconciliation — The painful process of finding and fixing disagreements between two systems that both think they’re right. First met in Chapter 15.

recover() — Catches a panic inside a deferred function and turns it back into an ordinary error. Think of it like: Catching the falling plate. First met in Chapter 4.

RED metrics — Rate, Errors, Duration — the three numbers that describe any endpoint’s health. Think of it like: Pulse, temperature, blood pressure. First met in Chapter 1.

redact — Deliberately blanking a sensitive value before logging or displaying it. Think of it like: The black marker on a released document. First met in Chapter 3.

Redis — The original in-memory key-value store that DragonflyDB imitates. First met in FM (via version table).

$ref / components / schemas — OpenAPI’s way of defining a shape once and referring to it from many places. First met in Chapter 24.

Referrer-Policy — Controls how much of the current URL is revealed when a user follows a link away. First met in Chapter 23.

reflection — A library’s ability to inspect your types at runtime; powerful, and hard to step through in a debugger. First met in Chapter 8.

registry / GHCR — A server that stores Docker images; GHCR is GitHub’s. Think of it like: The warehouse images are shipped from. First met in Chapter 26.

regression — A bug that reappears, or a feature that breaks something that used to work. First met in Chapter 20.

replay attack — Re-sending a captured valid message later to make it happen again. First met in Chapter 16.

request context — The per-request bag middleware uses to hand data (the logged-in user, the request ID) forward to handlers. Think of it like: The paperwork stapled to a job as it moves down the line. First met in Chapter 11.

resolver (entitlements) — The single function that turns a user ID into their current entitlements, so no handler decides plan logic for itself. Think of it like: One rulebook rather than twenty people’s memories. First met in Chapter 17.

resource_missing — Stripe’s error for “that object is already gone”, treated here as success so deletion is retryable. First met in Chapter 22.

RESP / Redis protocol — The wire format Redis speaks; DragonflyDB speaks it too, which is why clients and tools work unchanged. Think of it like: Two devices sharing one plug standard. First met in Chapter 13.

REST — A style of API where things (tasks, users) have addresses and HTTP verbs act on them. First met in Chapter 8.

Retry-After — A response header telling a rate-limited client how many seconds to wait. Think of it like: The “back in 10 minutes” sign. First met in Chapter 14.

RETURNING * — Ask Postgres to hand back the row it just wrote, ids and defaults included. First met in Chapter 7.

reverse proxy — A server in front of your app that receives all public traffic and forwards it inward, usually terminating HTTPS. Think of it like: The reception desk everyone must pass. First met in Chapter 18.

revoke — Making an existing credential stop working immediately. First met in Chapter 11.

roll forward — Fixing a bad schema change with a new migration rather than undoing the old one. First met in Chapter 5.

rollback — Going back to the previous version — here, redeploying the previous commit’s image tag. First met in Chapter 26.

route / router — A route is a pattern like /v1/tasks/{id}; the router is the code that matches an incoming request to the right handler. Think of it like: The mailroom sorting frame. First met in Chapter 2.

route pattern vs path/v1/tasks/{id} is the pattern (safe as a label); /v1/tasks/48291 is the path (never a label). First met in Chapter 18.

row lock / deadlock — Holding a row against other writers; a deadlock is two holders each waiting for the other’s row forever. Think of it like: Two people each holding the key the other needs. First met in Chapter 8.

runtime dependency vs build-time tool — A runtime dependency is compiled into the shipped binary; a build-time tool (sqlc, migrate, staticcheck) only runs on your machine or in CI. Think of it like: Ingredients versus kitchen equipment. First met in appD.

S

SaaS (Software as a Service) — Software you rent by the month over the internet instead of buying and installing once. Think of it like: Netflix instead of a DVD shelf. First met in Front matter.

salt — Random data mixed into each password hash so identical passwords produce different hashes. Think of it like: Giving every lock a different keyway. First met in Chapter 10.

SCA / 3-D Secure — European rules and the “confirm in your banking app” step required for many card payments; handled by Stripe. First met in Chapter 15.

Scalar — The JavaScript renderer that turns the YAML spec into a browsable documentation page. First met in Chapter 24.

SCAN / KEYS — Redis/Dragonfly commands that enumerate keys; KEYS blocks the server and must never be used in production. First met in Chapter 13.

schema — The shape of a database: which tables exist, which columns they have, what’s allowed in them. Think of it like: The blank form’s layout. First met in Chapter 5.

scope (token) — A label on a token saying what it may be used for: authentication, activation, password-reset, email-change. Think of it like: The ward a hospital pass opens. First met in Chapter 11.

scrape / pull-based — Prometheus visits your /metrics page on a schedule and reads current values; your app never sends anything. Think of it like: The meter reader calling round. First met in Chapter 18.

scratch — A completely empty base image; too empty here, because HTTPS calls to Stripe need CA certificates. First met in Chapter 25.

SDK — Software Development Kit — the vendor’s official library for calling their API from your language. First met in Chapter 15.

secret — A value that grants access if stolen: database passwords, Stripe keys, SMTP passwords. Never committed to Git. First met in Chapter 3.

security headers — Response headers that instruct browsers to behave more defensively. Think of it like: Safety instructions printed on the packaging. First met in Chapter 23.

sentinel error — A specific named error value you compare against, e.g. pgx.ErrNoRows. First met in Chapter 8.

sequential scan — Reading every row in a table because no usable index exists; gets linearly slower as the table grows. Think of it like: Reading the whole phone book to find one name. First met in Chapter 12.

service container — A real dependency (here Postgres) started alongside the CI job so tests hit the genuine article. First met in Chapter 26.

session — One logged-in period, represented here by one authentication token row. First met in Chapter 22.

SetNX — “Set this key only if it doesn’t exist” — an atomic way to claim a short-lived lock. Think of it like: Being first to sign the sheet. First met in Chapter 23.

SHA-256 — A fast standard hash; correct for hashing high-entropy tokens, wrong for hashing passwords. First met in Chapter 11.

shared_buffers — Postgres’s main memory cache setting, conventionally about 25% of RAM. First met in Chapter 27.

SIGINT / SIGTERM — Operating-system signals meaning “please stop”: SIGINT is Ctrl-C, SIGTERM is what Docker sends on every deploy. Think of it like: A tap on the shoulder rather than pulling the plug. First met in Chapter 4.

SIGKILL — The signal that kills a program instantly with no chance to clean up. Think of it like: Pulling the plug. First met in Chapter 21.

signature verification — Recomputing the fingerprint over the exact received bytes and comparing — which is why the raw body must not be re-parsed first. First met in Chapter 16.

signing secret / whsec_ — The shared secret used to verify webhook signatures; the stripe listen one differs from the dashboard one. First met in Chapter 16.

signing-key rotation — Periodically replacing the secret used to sign tokens, and the chore of supporting both during the change. First met in Chapter 11.

single-threaded / multi-threaded — Whether a program uses one CPU core at a time or many; Dragonfly uses many, Redis one per instance. Think of it like: One checkout lane versus several. First met in Chapter 13.

singleflight — A helper that lets only one goroutine do a duplicated piece of work while the rest wait and share the result. Think of it like: One person fetches coffee for the whole table. First met in Chapter 13.

slice — Go’s growable list of values of one type, written []T. Think of it like: A shopping list you can keep adding lines to. First met in Chapter 8.

sliding-window log — A more accurate limiter that remembers each request’s timestamp; more memory, no boundary burst. First met in Chapter 14.

slog / structured logging — Logging as labelled key=value fields instead of prose, so tools can filter and count them. Think of it like: A form-filled incident report instead of a handwritten diary entry. First met in Chapter 2.

slowloris — An attack that opens many connections and sends data one byte at a time to hold your server’s resources open. Think of it like: Standing at the ticket window and reading your order out one letter per minute. First met in Chapter 2.

SMTP — The internet’s protocol for sending email; slow and flaky compared to everything else this app does. First met in Chapter 21.

SMTP client — Code that speaks the internet’s email-sending protocol. First met in Front matter.

snake_case — The created_at naming style used in JSON and SQL, as opposed to Go’s CreatedAt. First met in Chapter 8.

soft limit — A limit where a small overshoot is harmless, so cheap enforcement is acceptable. First met in Chapter 17.

source of truth — The one system whose answer wins when two systems disagree — Stripe for billing, always. Think of it like: The official scoreboard. First met in Chapter 15.

spec-first vs code-first — Write the contract by hand and check the code against it, versus generating the contract from code comments. First met in Chapter 24.

spoof — Faking an identifying value to be treated as someone else. First met in Chapter 14.

SQL injection — The attack where user text pasted into a query becomes part of the query — the reason values go through $1 and sort keys through a whitelist. Think of it like: Someone writing “…and give me everything” in the name box of a form. First met in Chapter 9.

sqlc — A tool that reads your SQL files and writes matching Go code for you, so mistakes become build errors instead of crashes. Think of it like: A translator who checks your sentence is grammatical before you say it out loud. First met in Front matter.

sqlc diff — Regenerates the query code in memory and fails if what’s committed is out of date. First met in Chapter 26.

sqlc generate — The command that reads your SQL and rewrites the Go query code. Run it every time SQL changes. First met in Chapter 7.

SSH — The encrypted protocol for logging into a remote machine and running commands. First met in Chapter 26.

stack trace — The printed list of function calls that led to a crash. Think of it like: The breadcrumb trail back to where it went wrong. First met in Chapter 4.

stale — Cached data that no longer matches the truth. First met in Chapter 13.

stampede / thundering herd — A hot cached value expires and hundreds of simultaneous requests all hit the database at once. Think of it like: Everyone reaching the exit the moment the film ends. First met in Chapter 13.

standard library / stdlib — The code that ships with Go itself, needing no download. Think of it like: The tools already in the toolbox you bought. First met in Go in one sitting.

state machine — Code that tracks “what state are we in and what event moves us to the next” — deliberately avoided here in favour of upserts. First met in Chapter 16.

stateful token — A token that means nothing by itself; the server looks it up in its own table, so deleting the row logs you out instantly. Think of it like: A hotel key card the front desk can cancel. First met in Chapter 11.

static linking / CGO_ENABLED=0 — Building a binary with no external system-library dependencies, which is what allows a nearly-empty base image. Think of it like: Packing your own tent instead of relying on a hut being there. First met in Chapter 25.

status code — A three-digit number on every response saying how it went; 2xx worked, 4xx the caller was wrong, 5xx the server was wrong. First met in Chapter 2.

stderr / stdout — The two output streams every program has: normal output and error output. First met in Chapter 3.

Stripe — A company whose service takes card payments and manages subscriptions on your behalf. Think of it like: The card terminal in a shop — you never touch the card yourself. First met in Front matter.

Stripe Checkout — A payment page Stripe hosts for you; the customer is redirected there and bounced back afterwards, so card details never touch your server. Think of it like: Sending the customer to the bank’s counter rather than handling cash yourself. First met in Chapter 15.

Stripe Customer — Stripe’s record of one of your users, created lazily on their first upgrade click and joined to your row by stripe_customer_id. First met in Chapter 15.

Stripe Elements / PaymentIntents — Stripe’s lower-level building blocks for collecting cards yourself; the path not taken. First met in Chapter 15.

struct — A named group of related values kept together, each with its own name and type. Think of it like: A labelled box with compartments. First met in Go in one sitting.

struct tag (json:"title") — A note attached to a struct field in backticks telling libraries how to name it — here, what key it gets in JSON. Think of it like: A luggage label: the case is the same, the label says how to address it. First met in Chapter 7.

stub — A deliberately fake, minimal version of something written later, so the code compiles today. Think of it like: A cardboard cut-out on the set until the prop arrives. First met in Chapter 4.

stuffbin — Nadh’s tool for packing static files (images, templates) into a Go binary so it ships as one file. Think of it like: Vacuum-packing your luggage into the suitcase. First met in Front matter.

subscription — A recurring charge that renews until cancelled. First met in Chapter 15.

summary (Prometheus) — An older instrument computing percentiles inside your app; skipped here because results can’t be combined across instances. First met in Chapter 18.

sync.WaitGroup — A counter of outstanding background jobs, so shutdown can wait for them to finish. Think of it like: The head-count before the coach leaves. First met in Chapter 21.

systemd unit — The Linux file that describes how to start a background service; cited as what gets unwieldy with flag-only config. First met in Chapter 3.

T

table / row / column — A table is a grid of stored records; each row is one record, each column one named field. Think of it like: A spreadsheet sheet, its lines and its headings. First met in Chapter 5.

table-driven test — Go’s house style: a list of input/expected pairs looped over with a sub-test each. First met in Chapter 20.

taskd — The name of the program built in this book — a to-do list service. The trailing “d” is a Unix convention for a program that runs in the background (“daemon”). First met in Front matter.

TCP connection — The basic two-way network link between two programs, which HTTP messages travel over. Think of it like: The phone call; HTTP is what you say on it. First met in Chapter 2.

template (html/template) — A text file with {{.name}} placeholders that Go fills in. Think of it like: A mail-merge letter. First met in Chapter 21.

tenant / multi-tenant — A tenant is one customer’s data inside a shared system; multi-tenant means many customers share one database with strict separation. Think of it like: One apartment block, many locked flats, one landlord. First met in Chapter 12.

tenant filter / scoping — Putting AND user_id = $2 inside every query so the safe path is the only path. First met in Chapter 12.

test isolation — Making each test independent of every other, here by truncating tables and creating per-test users. Think of it like: Wiping the whiteboard between meetings. First met in Chapter 20.

test mode / live mode — Stripe’s two parallel worlds; test keys (sk_test_) and test cards move no real money. Think of it like: A flight simulator versus the aircraft. First met in Chapter 15.

the application struct — This book’s single struct holding the logger, config, database and cache; every handler is a method on it, which is how they all reach those things. Think of it like: The clipboard every member of staff carries. First met in Chapter 2.

ticker — A Go timer that fires repeatedly, driving the hourly cleanup loop. Think of it like: The hourly church bell. First met in Chapter 21.

tier / plan — A named package of features and limits at a price (Free, Pro, Business). Think of it like: Cinema ticket types: standard, premium, VIP. First met in Chapter 15.

time.Duration — Go’s type for a length of time, written "15m", 5 * time.Second. First met in Chapter 3.

timestamptz — A date-and-time that records its time zone; always use it instead of plain timestamp. First met in Chapter 5.

timing attack / side-channel — Learning a secret by measuring how long the answer takes rather than by reading it. Think of it like: Guessing the safe combination from the sound of the tumblers. First met in Chapter 11.

TLS / HTTPS — The encryption layer that makes web traffic private and tamper-evident; the s in https. Think of it like: A sealed, opaque envelope. First met in Chapter 1.

TLS termination — Decrypting HTTPS at the front door so the internal app can speak plain HTTP on a private network. Think of it like: Opening the diplomatic bag at reception. First met in Chapter 27.

token / bearer token — A random string that proves who you are; whoever holds (“bears”) it is treated as that user, so it must be protected like a password. Think of it like: A cinema ticket — nobody checks your name, only the ticket. First met in Chapter 11.

token bucket — A limiter that refills allowance at a steady rate and lets you spend a small burst at once. Think of it like: A jar refilled with tokens at one per second; each request spends one. First met in Chapter 14.

TOML — A plain-text settings-file format designed to be read by humans: key = value grouped under [section] headings. First met in Front matter.

transaction — A group of database statements that all take effect or none do. Think of it like: Moving money between accounts: both sides or neither. First met in Chapter 7.

transactional email — Automatic one-to-one email triggered by a user’s action — activation, password reset — as opposed to marketing mail. Think of it like: The receipt, not the newsletter. First met in Chapter 21.

TRUNCATE ... RESTART IDENTITY CASCADE — Empty these tables, reset their id counters, and follow foreign keys to dependants. First met in Chapter 20.

TTL (time to live) — How long a cached value is allowed to survive before it expires by itself. Think of it like: The use-by date. First met in Chapter 13.

type assertion (v.(T)) — Asking a general-purpose value “are you really a T?”, getting the T and a yes/no back. Think of it like: Checking ID at the door. First met in Chapter 11.

type-safe — Mistakes about what kind of value goes where are caught when you build, not when a user hits the bug. First met in Chapter 7.

tzdata — The world’s time-zone database, needed for correct local-time handling. First met in Chapter 25.

U

UNIQUE constraint — The database’s own guarantee that no two rows share a value — the only race-free way to prevent duplicate signups. First met in Chapter 10.

unique violation / error 23505 — Postgres’s code for “that value already exists”, translated here into a polite validation message. First met in Chapter 10.

unit test — A test of one small piece of logic in isolation, with no database or network. First met in Chapter 20.

Unix timestamp — A time expressed as seconds since 1 Jan 1970 — what time.Now().Unix() returns and what the window arithmetic divides. First met in Chapter 14.

upsell / CTA / funnel — Upsell = offering a bigger plan; CTA (call to action) = the button that invites it; funnel = the sequence of steps from visitor to paying customer. First met in Chapter 17.

upsert / ON CONFLICT DO UPDATE — Insert a row, or update it if it already exists — one statement, safe to repeat. Think of it like: “Add or replace.” First met in Chapter 15.

Uptime Kuma / Healthchecks.io — Self-hostable services that watch endpoints and notify you when they stop responding. First met in Chapter 27.

user enumeration — Letting an attacker discover which email addresses have accounts by watching how responses differ. First met in Chapter 10.

UUID — A 128-bit random identifier that is unique without a central counter, e.g. 9f1c…. First met in Chapter 5.

V

validation — Checking the contents of a request against the rules before touching the database, and reporting every problem at once. Think of it like: Proof-reading the whole form, not stopping at the first blank. First met in Chapter 8.

variadic (...) — A parameter that accepts any number of arguments; permitted... spreads a slice into one. First met in Chapter 8.

Vary: Authorization — A header telling caches that responses differ per user, so nobody is served someone else’s data. Think of it like: “Made to order — do not reuse”. First met in Chapter 11.

versioned API / /v1 — Putting a version number in every URL so a future incompatible /v2 can exist without breaking today’s callers. Think of it like: Keeping the old menu printed while you introduce the new one. First met in Chapter 2.

Viper — The most popular Go configuration library; koanf exists because its author found Viper too large. First met in Chapter 3.

VM (virtual machine) — A simulated computer running inside a real one. First met in Chapter 26.

volume — Storage that lives outside a container so data survives when the container is replaced. Think of it like: The external hard drive that survives the laptop. First met in Chapter 5.

VPS — Virtual Private Server — a rented virtual machine from a hosting provider, where this app is deployed. First met in Chapter 13.

W

webhook — A URL on your server that another company calls when something happens on their side — the reverse of you calling them. Think of it like: They ring your doorbell instead of you phoning them hourly. First met in Chapter 16.

whitelist / safelist — A closed list of allowed values; anything not on it is rejected. Safer than trying to list what’s banned. Think of it like: A guest list rather than a banned list. First met in Chapter 9.

window function / count(*) OVER() — A SQL feature that computes a value across the whole result and attaches it to every row — here, the total count without a second query. Think of it like: Printing “page 2 of 7” on every page as it comes off the press. First met in Chapter 9.

worker pool / bounded queue — A fixed number of workers pulling jobs off a length-limited list. First met in Chapter 21.

X

X-Content-Type-Options: nosniff — Forbids the browser from guessing that a response is really HTML and running it. First met in Chapter 23.

X-Forwarded-For / trusted proxy — The header a proxy adds naming the real client IP; only trustworthy when a proxy you control is the only route in. Think of it like: A “sent on behalf of” line — believable only from a known sender. First met in Chapter 14.

XSS — Cross-Site Scripting — getting a victim’s browser to run attacker-supplied code in the context of your site. First met in Chapter 23.

Y

YAML — An indentation-based text format for configuration and specs; used here for Compose, sqlc, Prometheus, workflows and OpenAPI. First met in Chapter 24.

Z

zero value — The value a Go variable has before you set it: 0, "", false, or nil. First met in Chapter 9.

zero-downtime deploy — Replacing a running version with no interruption, by starting the new one before stopping the old. Think of it like: Changing the tyre while the car keeps moving. First met in Chapter 26.

Zerodha — India’s largest stockbroker, run on a small number of simple monolithic services — cited as proof the simple approach scales. First met in Front matter.


For your notes — the fastest way to find out whether you actually understand a chapter is to close the book and define its five newest words out loud, in your own sentences. Anything you can only define by repeating the book’s wording is a word you have memorised, not learned.

Appendix H — Troubleshooting index

Every error this book can produce, collected from every chapter’s Common mistakes, indexed by what you actually see on your screen. 120 entries.

How to use it. Copy the distinctive part of your error message — usually the bit without your own filenames in it — and search this appendix for it. If it isn’t here, the chapter’s own Common mistakes section is the next place to look, then the checkpoint you last passed: whatever broke, broke after that.

Tip

Read the first error, not the last. One mistake in Go usually produces a cascade, and only the first line describes the real problem. The same is true of Postgres and of Docker Compose.


Index by symptom

What you see What it means Fix From
the installer finishes, then command not found: go in a terminal you already had open. that window read PATH when it opened, before Go existed. close it and open a new one. This applies to every install in this chapter. Before you begin
later, logger.Error(err) refusing to compile. slog’s methods take a message string followed by alternating key and value arguments, never an error on its own. logger.Error(err.Error()), or logger.Error("could not connect", "err", err). This catches everybody once; Chapter 2 writes the first one and about thirty later call sites follow. Before you begin
go: cannot find main module; see 'go help modules' you ran a go command in a folder with no go.mod above it — almost always the wrong directory. pwd to see where you are, ls to look for go.mod, cd to the right place. Before you begin
./hello.go:5:2: "os" imported and not used and ./hello.go:9:2: declared and not used: name Go refuses to compile code containing an unused import or unused local variable. Both lines above are real output from one run of a nine-line file. delete the unused line, or use the thing. It feels pedantic on day one and prevents a whole class of stale-code bug forever. Before you begin
listen tcp :4000: bind: address already in use something is already listening on port 4000 — usually a go run you started earlier and never stopped. Ctrl-C in the tab still running it. To hunt it down, lsof -i :4000 prints the process and kill <PID> ends it. You will meet this from Chapter 2 onward. Before you begin
nothing — the terminal sits there with no prompt after you start the server. the program is running and will not return until stopped. Correct behaviour, not a hang. open a second terminal tab for your curl commands and leave the first one serving. Before you begin
curl: (7) Failed to connect to localhost port 4000 after 0 ms: Couldn't connect to server (or ... Connection refused on Linux and WSL2). no program is listening on that port. Usually the server isn’t running, or it crashed on startup, or you’re curling the wrong port. look at the terminal where you started the server. Read its last line. How the web actually works
listen tcp :4000: bind: address already in use another process already holds port 4000 — nearly always an old go run in a forgotten tab. find that tab and press Ctrl-C. Failing that, lsof -i :4000 lists the process holding it. How the web actually works
{"error":"json: unknown field \"titel\""} with a 400 Bad Request. the JSON parsed fine, but you sent a key the server doesn’t recognise — a typo, almost always. Chapter 8 rejects unknown fields deliberately, so a typo fails loudly instead of being silently dropped. correct the key. How the web actually works
{"error":"you must be authenticated to access this resource"} with 401. you sent no Authorization header at all. add -H "Authorization: Bearer $TOKEN". If instead you see invalid or missing authentication token, the header was present but malformed — check for a missing Bearer prefix, or a token that is not 26 characters. How the web actually works
your shell mangles the JSON, or reports dquote> and hangs. quoting. Single quotes around the JSON, double quotes inside it: -d '{"title":"buy milk"}'. The other way round, the shell eats the quotes and curl sends something that is not JSON. press Ctrl-C, retype with the quotes that way round. How the web actually works
a two-line progress table above your output with percentages and speeds. nothing is wrong. That is curl’s progress meter, written to stderr. add -s if it bothers you. Book transcripts omit it. How the web actually works
cannot use err (variable of interface type error) as string value in argument to logger.Error slog’s Error takes a message string first, then key/value pairs. It does not take an error. app.logger.Error("cannot connect to database", "error", err). Standardise on that shape and your production JSON logs always have an error key you can search on. Chapter 3 calls this out as a pitfall for the same reason. Go in one sitting
panic: assignment to entry in nil map you declared a map with var m map[string]string and never created it. The zero value of a map is nil, and reading a nil map is fine but writing to one crashes. m := make(map[string]string), or a literal m := map[string]string{}. Go in one sitting
the prompt changed from taskd=# to taskd-# and nothing you type does anything. you forgot the semicolon; psql thinks your statement is unfinished. type ; and press Enter. SQL and databases in one sitting
FATAL: database "taksd" does not exist, or FATAL: role "nobody" does not exist. the database name or the username in your DSN is wrong — not the password, not the network. compare the DSN’s /name and user: parts against what docker compose created. SQL and databases in one sitting
UPDATE 50103 when you expected UPDATE 1. you forgot the WHERE clause and just rewrote the entire table. if you were inside BEGIN, run ROLLBACK; right now. If you were not, restore from backup — or, in this warm-up, delete the container and start again. This is why you type the WHERE before you type the verb. SQL and databases in one sitting
You’ll think: sqlc is a library my server needs at runtime, like chi. Chapter 1 — Introduction: what we’re building and why this shape
You’ll think: “monolith” means one big messy file. one deployable unit. Internally taskd has hard walls — internal/data, internal/cache, internal/mailer — and Chapter 2 puts them in place on day one. Organisation and deployment are separate questions. Chapter 1 — Introduction: what we’re building and why this shape
zsh: command not found: go (or bash: go: command not found). your shell searched every folder on its path and found no program called go. This is never a problem with your code — there is no code yet. install Go, or add its folder to PATH. Before you begin §6 walks through both. Chapter 1 — Introduction: what we’re building and why this shape
after something like go get github.com/sqlc-dev/sqlc, a message saying the module was found but does not contain the package you asked for. you tried to add a tool as if it were a library. go get records a dependency your program imports; sqlc is a program you run. go install instead, which builds the tool into $(go env GOPATH)/bin. Chapter 7 gives the exact line; there is nothing to install yet. Chapter 1 — Introduction: what we’re building and why this shape
./main.go:41:18: app.routes undefined (type *application has no field or method routes) you typed main.go and ran it before routes.go existed. write Step 4. Go compiles a whole package at once, so every referenced method must exist before anything runs. Chapter 2 — The skeleton: a server that answers
main.go:9:2: no required module provides package github.com/go-chi/chi/v5; to add it: go get github.com/go-chi/chi/v5 you imported chi without installing it. run exactly the command the error suggests, from the project root. Chapter 2 — The skeleton: a server that answers
go: cannot find main module; see 'go help modules' you’re in the wrong directory. There is no go.mod here or in any parent folder. cd back into taskd. Run pwd to see where you actually are, and ls go.mod to confirm. Chapter 2 — The skeleton: a server that answers
time=... level=ERROR msg="listen tcp :4000: bind: address already in use", then the program exits. something is already listening on port 4000 — almost always a copy of this server you forgot to stop. lsof -i :4000 lists the process and its PID; kill <PID> stops it. You will meet this error in twenty later chapters, so learn those two commands now. Chapter 2 — The skeleton: a server that answers
./main.go:12:2: declared and not used: logger you created a variable inside a function and never used it. Go treats that as a bug, not a warning. use it or delete it. The same rule applies to imports: "fmt" imported and not used. Chapter 2 — The skeleton: a server that answers
two lines. The first names a file and position and says config redeclared in this block; the second is indented and says other declaration of config, naming the other file. two files in the same package both declare type config. Go compiles the whole folder as one unit, so this is one name defined twice. delete the type config struct { port int; env string } block from main.go. Read both lines of the error before editing: Go lists the files in the order it read them, so the arrow can land on either file. The one to delete is always the old two-field version. Chapter 3 — Configuration and logging, the Nadh way
loading config.toml: (12, 23): parsing error: no value can start with m the TOML parser hit something it cannot read at that line and column — classically max_idle_time = 15m written without quotes. TOML has no concept of a duration; 15m is neither a number nor a string. quote it — max_idle_time = "15m". The two numbers are line and column in your file, so they shift if your spacing differs. Chapter 3 — Configuration and logging, the Nadh way
nothing at all. You set TASKD_APP_PORT=9999 (one underscore) and the server starts on 4000 anyway. the transform turned that name into the key app_port, which no line of config.go reads. koanf stored it happily; nothing asked for it. double underscore between the section and the key: TASKD_APP__PORT. There is no warning for this and never will be — koanf cannot know which keys you meant to exist. Chapter 3 — Configuration and logging, the Nadh way
cannot use err (variable of interface type error) as string value in argument to logger.Error you wrote logger.Error(err). slog’s first argument is the human-readable message, a string — the error goes in as a labelled field after it. app.logger.Error("could not load config", "error", err). Standardise on that shape and your JSON logs always have an error key you can search. Chapter 3 — Configuration and logging, the Nadh way
the server starts, prints addr=:0, and curl localhost:4000 refuses to connect. app.port is zero — usually because an environment variable held something that is not a number (TASKD_APP__PORT=4o00, with a letter instead of a zero), and k.Int returns 0 when it cannot convert. Port 0 tells the operating system “give me any free port”, so the server really is listening, at an address you did not choose. correct the value. Note the general shape of this bug: koanf converts on a best-effort basis and reports nothing. What you configure is what you get; what you mistype is a zero value. Chapter 3 — Configuration and logging, the Nadh way
nothing. No log lines, no listening port, and curl fails with curl: (7) Failed to connect to localhost port 4000. you wrote func() {...}() and left off the go. The function then runs on the main goroutine and blocks forever at s := <-quit, so ListenAndServe is never reached. put the go back. One missing word, and the program is a very expensive way to wait for Ctrl-C. Chapter 4 — A server that dies well: lifecycle and core middleware
the server dies the instant you start it, with panic: chi: all middlewares must be defined before routes on a mux. you put r.Use(...) after r.Route(...) — chi builds its routing tree when the first route is registered and refuses to have layers added afterwards, because the result would silently not apply to the routes already registered. move both r.Use lines above r.Route, as in the listing. Chapter 4 — A server that dies well: lifecycle and core middleware
./main.go:16:10: undefined: slog, repeated once for each line that mentions slog (your line and column numbers will differ). main.go uses *slog.Logger but no longer imports log/slog — the classic result of deleting import lines by eye when the server block moved out. the import block in Step 2 is the complete, correct one: flag, fmt, log/slog, os. Chapter 4 — A server that dies well: lifecycle and core middleware
./main.go:6:5: "net/http" imported and not used the opposite mistake — the server block left, its imports stayed. Go treats an unused import as an error, not a warning. delete net/http and time from main.go’s imports. They live in server.go now. Chapter 4 — A server that dies well: lifecycle and core middleware
panic: chi: all middlewares must be defined before routes on a mux, at startup. r.Use(...) was called after r.Route(...) or another route registration. both r.Use lines go at the top of routes(), before any route. Chapter 4 — A server that dies well: lifecycle and core middleware
a panicking handler still returns curl: (52) Empty reply from server, even though you wrote recoverPanic. most likely you called recover() directly instead of inside a deferred function. Outside a defer, recover() returns nil and catches nothing, with no complaint from the compiler. the defer func() { if err := recover(); ... }() shape in Step 3 is not stylistic. It is the only shape that works. Chapter 4 — A server that dies well: lifecycle and core middleware
fatal error: all goroutines are asleep - deadlock! every goroutine in the program is blocked waiting for something that can never arrive — typically a send on shutdownError with nothing left to receive it, or a receive with nothing left to send. check that block (6) receives exactly once from shutdownError and that the goroutine sends exactly once. One send, one receive. Chapter 4 — A server that dies well: lifecycle and core middleware
zsh: command not found: migrate (or bash: migrate: command not found). the install worked, but $(go env GOPATH)/bin is not on your PATH, so your shell cannot find the program it built. the two lines in Before you begin, §5.3 — append that directory to PATH in your shell’s startup file, then open a new terminal. Chapter 5 — PostgreSQL and migrations
Makefile:5: *** missing separator. Stop. the indented line at that line number begins with spaces, not a tab. Your editor almost certainly converted it for you. delete the leading whitespace and press Tab once. In VS Code, the status bar at the bottom right says Spaces: 4; click it and choose “Indent Using Tabs” for this file. Chapter 5 — PostgreSQL and migrations
a message ending Is the docker daemon running? (the socket path in the middle differs between machines). the docker command is installed but the background service it talks to is not running. start Docker Desktop and wait until its icon stops animating. Every command in this chapter needs it. Chapter 5 — PostgreSQL and migrations
a failure from docker compose up -d db ending in bind: address already in use. something else on your machine already holds port 5432 — usually a Postgres you installed and forgot, or a container from another project. stop the other one, or change the left number in ports: - "5432:5432" to something free such as "5433:5432" and update the port in .envrc and config.toml to match. Change only the left number; the right one is inside the container and must stay 5432. Chapter 5 — PostgreSQL and migrations
error: no change every migration in the folder has already been applied. This is success wearing a frightening word. nothing. The exit status is non-zero, which matters only if you script it. Chapter 5 — PostgreSQL and migrations
migrate create writes files numbered 000002 when you expected 000001. the folder already had a migration — usually because you ran the command twice. delete the unwanted, still-empty pair. Files that have never been applied anywhere are the only migrations you are ever allowed to delete. Chapter 5 — PostgreSQL and migrations
you changed POSTGRES_PASSWORD in docker-compose.yml, restarted, and the old password still works. those environment: values are read only when the data directory is created. Yours already exists, in the db-data volume. in development, docker compose down -v deletes the volume, and the next up starts fresh. Read the warning in the Pitfalls before you type that. Chapter 5 — PostgreSQL and migrations
level=ERROR msg="cannot connect to database" error="failed to connect to \user=taskd database=taskd`: … dial tcp 127.0.0.1:5432: connect: connection refused"` and the program exits with status 1. nothing is listening on port 5432. The database container is stopped. docker compose up -d db, wait a second or two, run again. Chapter 6 — Connecting with pgx/v5
error="context deadline exceeded" after a five-second pause. the host in your DSN never answered at all — not even to refuse. A wrong IP address, a firewall, or a database that is up but wedged. check the host and port in [db] dsn. docker compose ps tells you what is actually published on 5432. Chapter 6 — Connecting with pgx/v5
error="cannot parse `postgres//taskd@localhost/taskd`: failed to parse as keyword/value (invalid keyword/value)" pgxpool.ParseConfig rejected the DSN string itself. Nearly always a missing : in postgres://, or a stray space. compare the DSN against the one in Step 1, character by character. Chapter 6 — Connecting with pgx/v5
error="MaxSize must be >= 1" MaxConns arrived as 0. Since it comes from config, a missing or misspelled max_conns key gives you the zero value of an int32, which is 0, and the pool refuses. check the [db] block in config.toml — the key is max_conns, under [db], not maxconns and not at the top level. Chapter 6 — Connecting with pgx/v5
from go vet, a line naming db.go and ending the cancel function is not used on all paths (possible context leak) — or, if you wrote ctx, _ := instead, the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak. you created a context with a timeout and did not arrange to call its cancel. The timer behind it stays alive until it fires, holding memory. defer cancel() on the line after. Every time. go vet ./... catches this shape for free, which is one reason Chapter 26 (CI/CD) runs it on every push. Chapter 6 — Connecting with pgx/v5
zsh: command not found: sqlc (or bash: sqlc: command not found) the binary was built, but the folder it went into is not on your PATH — the list of directories your shell searches for commands (Before you begin, §on PATH). run go env GOPATH to see the folder, then add its bin subfolder to your PATH by putting export PATH=$PATH:$(go env GOPATH)/bin in your ~/.zshrc or ~/.bashrc, and opening a new terminal. migrate from Chapter 5 lives in the same folder, so fixing this fixes both. Chapter 7 — sqlc: SQL in, type-safe Go out
(in Chapter 11, if you skipped the first override): cannot use token.Expiry (variable of struct type time.Time) as pgtype.Timestamptz value in struct literal — with your own file, line and column in front of it. the generated InsertTokenParams.Expiry field is a pgtype.Timestamptz, because tokens.expiry is a NOT NULL timestamptz and no override told sqlc to use time.Time. add the unguarded - db_type: "timestamptz" / go_type: "time.Time" override to sqlc.yaml and run sqlc generate again. The error points at tokens.go and the cause is in sqlc.yaml, which is why it is worth fixing now, four chapters early. Chapter 7 — sqlc: SQL in, type-safe Go out
sql/queries/tasks.sql:1:1: invalid metadata: -- name; ":one" declares it returns exactly one row. $1..$4 become the one of your comment lines starts with -- name but is not a query name. sqlc points at line 1 of the file, not at the offending line, so the error location is no help — the message text is, because it quotes the line back to you. rewrap the prose so no line begins with name, then run sqlc generate again. Chapter 7 — sqlc: SQL in, type-safe Go out
q.CountTasks undefined (type *db.Queries has no field or method CountTasks) either you forgot to run sqlc generate after adding the query, or the query has no magic comment. A query with no -- name: line is silently skipped — sqlc does not warn you, it generates nothing for it, and the missing function surfaces as a Go compile error later, in a different file, with no mention of SQL. check the query has a -- name: X :mode line directly above it, then make sqlc. Chapter 7 — sqlc: SQL in, type-safe Go out
nothing. The build is green and the endpoint returns a 500 with column "x" does not exist in the server log. a migration ran and sqlc generate did not. Your Go is compiled against yesterday’s schema, so the compiler had nothing to object to. make db/migrations/up sqlc, then rebuild. Make the two-command form your reflex; the next section explains why it is the most valuable habit in the chapter. Chapter 7 — sqlc: SQL in, type-safe Go out
a client sends {"title":"a"}{"title":"b"} and gets {"error":"body must only contain a single JSON value"}. gate 3 did its job. Without it, the first document would be accepted and the second silently ignored — which is how a request that looks accepted saves the wrong data. nothing to fix; this is the feature working. Chapter 8 — CRUD done properly: JSON helpers, errors, validation
./tasks.go:98:24: app.q.UpdateTask undefined (type *db.Queries has no field or method UpdateTask) (your line number will differ). you added the SQL but did not regenerate, so Go is compiling against yesterday’s generated code. make sqlc, then build again. Chapter 7’s pitfall — “never change SQL without regenerating” — starts collecting its debts here. Chapter 8 — CRUD done properly: JSON helpers, errors, validation
these commands work, and yet curl -d sends Content-Type: application/x-www-form-urlencoded — the header for HTML form submissions, not JSON. readJSON never inspects the content type, so it succeeds by accident. Real clients send the right header, and Chapter 23 (Hardening the edge) adds code that cares about it. get into the habit now: curl -H "Content-Type: application/json" -d '{"title":"x"}' localhost:4000/v1/tasks. Chapter 8 — CRUD done properly: JSON helpers, errors, validation
everything looks fine, but the server log has http: superfluous response.WriteHeader call from ... and a task you expected to be rejected is in the database. an error branch is missing its return. The client got the 422 (the first WriteHeader wins and the second is discarded), and then the handler carried on and ran the insert anyway. add the return. Then check the others: grep -n -A1 'Response(w, r' cmd/api/*.go and confirm every hit is followed by a return. Chapter 8 — CRUD done properly: JSON helpers, errors, validation
./cmd/api/helpers.go:126:35: undefined: url you added the functions but not the import. Go has no implicit lookup — a file that names a package must import it. (Every Go error starts with file:line:column; yours will name whatever line your code landed on, so match on the message after the colons, not the numbers.) add "net/url" and the internal/validator line to the import block above. An editor with gopls installed does this on save. Chapter 9 — Listing at scale: filtering, sorting, pagination
./cmd/api/tasks.go:141:22: app.q.ListTasks undefined (type *db.Queries has no field or method ListTasks) — with whatever line number your call happens to sit on. you wrote the SQL but did not regenerate. internal/db still holds Chapter 8’s four functions. make sqlc, then rebuild. If sqlc itself errors, read its message: it points at the line in tasks.sql it could not parse. Chapter 9 — Listing at scale: filtering, sorting, pagination
./cmd/api/tasks.go:141:22: app.q.ListTasks undefined (type *db.Queries has no field or method ListTasks) the SQL exists but internal/db was not regenerated, so the typed Go function does not exist yet. make sqlc. Make this reflex automatic: every edit to a .sql file is followed by make sqlc before go build. Chapter 9 — Listing at scale: filtering, sorting, pagination
./cmd/api/tasks.go:150:15: cannot use status (variable of type string) as *string value in struct literal you passed the plain string into a field sqlc declared as a pointer, because sqlc.narg plus emit_pointers_for_null_types makes nullable parameters *string. wrap it — Status: nilIfEmpty(status). Chapter 9 — Listing at scale: filtering, sorting, pagination
200 OK with {"metadata":{},"tasks":[]} on every request, even though psql shows plenty of rows. an empty filter is reaching SQL as '' rather than NULL. The idiom’s first branch ($1::text IS NULL) is false, so the second (status = '') runs, and no task has an empty status. every optional filter goes through nilIfEmpty. There is no error message for this one — it is silent, and it is the single most infuriating bug in the chapter. Chapter 9 — Listing at scale: filtering, sorting, pagination
the migration fails with a line containing type "citext" does not exist (SQLSTATE 42704). CREATE TABLE ran before CREATE EXTENSION, or the extension line was omitted. put CREATE EXTENSION IF NOT EXISTS citext; above CREATE TABLE in the same file, clear the half-applied migration with the migrate tool’s force flag, and re-run. Chapter 10 — Users and passwords
./validator.go:5:11: undefined: regexp you added EmailRX but the file still has Chapter 8’s single-line import "slices". widen it to the bracketed block with "regexp" and "slices", as in Step 3. Chapter 10 — Users and passwords
crypto/bcrypt: hashedSecret too short to be a bcrypted password, at login in Chapter 11, long after this chapter looked fine. the hash was stored in a text column and something converted the bytes on the way in or out, so the stored value is no longer a valid bcrypt hash. the column must be bytea. This is the entire reason the schema uses it. Chapter 10 — Users and passwords
a 500 on a duplicate signup, with duplicate key value violates unique constraint "users_email_key" (SQLSTATE 23505) in your log. the errors.As / 23505 branch is missing or misspelled, so a normal user mistake fell through to serverErrorResponse. restore the switch from Step 5. Leaking that text to a client is worse than the 500 — it names your table and constraint. Chapter 10 — Users and passwords
The wrong lesson to take from Chapter 10 is “SHA-256 is bad”. SHA-256 is excellent — for Chapter 11 — Stateful tokens: authentication without JWT drama
cannot use token.Expiry (variable of type time.Time) as pgtype.Timestamptz value in struct literal your sqlc.yaml is missing the timestamptz → time.Time override for NOT NULL columns; by default the pgx driver maps every timestamptz to pgtype.Timestamptz. go back to Chapter 7 (sqlc), add the unguarded db_type: "timestamptz" override alongside the nullable: true one, and run make sqlc again. Chapter 11 — Stateful tokens: authentication without JWT drama
./middleware.go:44:11: undefined: sha256 you added the middleware but not its imports. Go refuses to compile a file that uses a package it did not import — there is no implicit lookup. add "crypto/sha256", "errors", "strings", "github.com/jackc/pgx/v5" and the data/db internal packages to middleware.go’s import block. Your editor’s Go plugin will do this on save if gopls is installed. Chapter 11 — Stateful tokens: authentication without JWT drama
{"error":"invalid or missing authentication token"} when you are certain the token is right. the header shape is wrong. curl -H "Authorization: $TOKEN" — without the word Bearer — is the most common version; parts[0] != "Bearer" rejects it. curl -H "Authorization: Bearer $TOKEN". Try the broken version once on purpose so you recognise the message later. Chapter 11 — Stateful tokens: authentication without JWT drama
the same invalid or missing authentication token, after copying the token out of the JSON output. you captured the quote characters or a trailing newline, so the string is 27 or 28 characters and len(parts[1]) != 26 rejects it. The message does not say so, which is why this one costs people twenty minutes. take only the 26 characters between the quotes, or use jq -r as shown in Step 8. Chapter 11 — Stateful tokens: authentication without JWT drama
panic: missing user value in request context in the server log, and a 500 with the server encountered a problem and could not process your request at the client. exactly what it says, and it is the design working. A handler called contextGetUser on a route that authenticate never wrapped. check that r.Use(app.authenticate) is registered on the router that owns the route. This is not a bug to work around by returning nil — the panic is the alarm. Chapter 11 — Stateful tokens: authentication without JWT drama
nothing — no error at all. if you write sqlc.narg('user_id') by mistake, the parameter becomes a *int64, nil is a legal value, and WHERE user_id = NULL matches no rows for anybody. sqlc.arg('user_id'). Required things get arg; optional things get narg. Chapter 12 — Ownership: making it multi-tenant
cmd/api/tasks.go:47:44: undefined: db.GetTaskParams (your numbers will differ) you edited the Go handlers before running make sqlc, so the type you are naming does not exist yet. run make sqlc first, always. SQL is the source; Go is downstream of it. Chapter 12 — Ownership: making it multi-tenant
cmd/api/tasks.go:47:44: undefined: db.GetTaskParams (your numbers will differ) you wrote the Go change before regenerating. The type genuinely does not exist yet. make sqlc, then go build ./.... SQL first, Go second, every time. Chapter 12 — Ownership: making it multi-tenant
cannot use id (variable of type int64) as db.GetTaskParams value in argument to app.q.GetTask exactly what it says — this call site has not been tenant-scoped yet. db.GetTaskParams{ID: id, UserID: user.ID}, with user := app.contextGetUser(r) above it. This error is a feature; it is the security checklist printing itself. Chapter 12 — Ownership: making it multi-tenant
make sqlc failing with a complaint about parameter $2 in UpdateTask. The exact wording depends on your sqlc version; it will name the query and the parameter. $2 is now claimed twice — by title in the SET list and by user_id in the WHERE — because the new predicate went in without renumbering the rest. copy the full UpdateTask from Step 2. Order is $1 id, $2 user_id, $3..$7 the five fields, $8 version. Chapter 12 — Ownership: making it multi-tenant
error: Dirty database version 4. Fix and force version. migration 4 failed partway, so migrate has locked itself and wants a human. This is deliberate. repair the SQL file, then migrate -path ./migrations -database $TASKD_DB_DSN force 3 to say “we are back at version 3”, then make db/migrations/up. In development, docker compose down -v and starting fresh is also honest. Chapter 12 — Ownership: making it multi-tenant
no configuration file provided: not found you are not in the folder that holds docker-compose.yml. cd to the project root — the folder with go.mod in it — and run the command again. Chapter 13 — Caching with DragonflyDB
./cmd/api/tasks.go:NN:17: undefined: fmt you added the code but not the import. Go will not guess. add "encoding/json" and "fmt" to the import block at the top of tasks.go. Chapter 13 — Caching with DragonflyDB
no error — but filter results look wrong sometimes. You ask for ?status=done and get the whole list back, or a ?search=milk result shows up under a different search term. a parameter is missing from the fingerprint string. Anything left out stops distinguishing two questions, so two genuinely different queries share one cache entry and whichever ran first wins for the next sixty seconds. the format string takes six values and every one of them is read from the query string above it. Count them: status, priority, search, f.Sort, f.Page, f.PageSize. If you ever add a seventh filter to this handler, it goes in here too, in the same commit. Chapter 13 — Caching with DragonflyDB
no error at all. The build is green, the tests pass, and your new task is missing from GET /v1/tasks for up to sixty seconds. you forgot one of the three call sites — almost always deleteTaskHandler, because its early return is easy to slip past. grep -n InvalidateUser cmd/api/tasks.go should print three lines. Exercise 1 turns this into an automatic check. Chapter 13 — Caching with DragonflyDB
fatal error: concurrent map writes two goroutines wrote to the same Go map at the same instant, and the runtime killed the process to stop it corrupting itself. guard every read and write of that map with a mutex — the next half of this step. Chapter 14 — Rate limiting: per-instance, then distributed
The failure this code has, and why we keep it: if customer.New succeeds and the UPDATE Chapter 15 — Stripe I: tiers, customers, checkout
at startup, level=WARN msg="stripe.secret_key is empty; billing endpoints will fail", then a 500 from the checkout endpoint. exactly what it says. The config key is empty, so every SDK call is unauthenticated. fill in secret_key in config.toml, or export TASKD_STRIPE__SECRET_KEY, and restart. The lesson is bigger than the bug: read your boot logs. The original author put that line there specifically so this failure would explain itself. Chapter 15 — Stripe I: tiers, customers, checkout
cannot use custID (variable of type string) as *string value in struct literal you wrote Customer: custID where the SDK wants a *string. Customer: stripe.String(custID). The SDK insists on pointers so that it can distinguish a field you did not set (nil) from a field you set to empty ("") — the same reason Chapter 8’s PATCH handler uses pointers for optional fields. Chapter 15 — Stripe I: tiers, customers, checkout
a 500, with a log line containing No such price: 'price_1Abc...' the key and the price id come from different worlds — a sk_test_ key looking for a price that only exists in live mode, or vice versa. It reads like your bug and is not. keys and price ids travel as a matched set. Copy all three from the same mode, in one sitting. Chapter 15 — Stripe I: tiers, customers, checkout
undefined: itoa helpers.go never got the two-line helper from Chapter 8 (CRUD done properly). add func itoa(i int64) string { return strconv.FormatInt(i, 10) } to cmd/api/helpers.go, with strconv in that file’s imports. Chapter 15 — Stripe I: tiers, customers, checkout
make sqlc failing with a complaint about subscriptions — the exact wording depends on your sqlc version, and it will name the table. sqlc reads migrations/ to learn the table shapes, and it cannot find the table your query mentions. The migration file is missing, misnamed, or was written after the query. migration first, query second, make sqlc third. The file must be in migrations/ and end .up.sql; sqlc reads the folder, not the database. Chapter 15 — Stripe I: tiers, customers, checkout
every event failing, and {"error":"you must be authenticated to access this resource"} in the stripe listen output. the webhook route ended up inside the authenticated group. move the r.Post("/stripe/webhook", …) line above r.Group(func(r chi.Router) { … }), not inside it. Chapter 16 — Stripe II: webhooks, the source of truth
a --> line for customer.subscription.created, then a <-- line reporting status 500. In the API log, an error line whose message is no rows in result set. nothing is broken. stripe trigger invents a new Stripe customer, which no row in your users table is paired with, so GetUserByStripeCustomerID finds nobody. none needed — you have proved signature verification and the ledger both work. Use the real checkout path to see a subscriptions row appear. Chapter 16 — Stripe II: webhooks, the source of truth
every series on your graphs labelled route="unmatched" and status="0", and every duration essentially zero. No error, no warning, no crash. you recorded the metrics before next.ServeHTTP instead of after. The route had not been matched ("""unmatched"), the handler had not written a status (chi’s wrapper returns 0 until something writes one), and no time had passed. move every line from route := down to below next.ServeHTTP(ww, r). This is the single most common mistake in this chapter and it produces plausible-looking wrong data, which is worse than a crash. Chapter 18 — Prometheus: metrics that answer questions
panic: duplicate metrics collector registration attempted something registered a metric name that was already registered — most often calling MustRegister twice (in main and again in a test helper), or a promauto variable declared in two files. register each collector exactly once. If you need metrics inside tests, build a private registry with prometheus.NewRegistry() instead of touching the default one. Chapter 18 — Prometheus: metrics that answer questions
on Linux without Docker Desktop, the Prometheus targets page shows taskd as DOWN with an error like Get "http://host.docker.internal:4000/metrics": dial tcp: lookup host.docker.internal: no such host. plain Docker Engine on Linux does not define that name; it is a Docker Desktop convenience. add this to the prometheus service in docker-compose.yml and recreate the container: yaml extra_hosts: ["host.docker.internal:host-gateway"] host-gateway is a Docker keyword resolving to the host’s address on the bridge network. Chapter 18 — Prometheus: metrics that answer questions
cmd/api/metrics.go:12:5: undefined: promauto the import block is missing the promauto package, or you typed the module path without the sub-package. the path is github.com/prometheus/client_golang/prometheus/promauto. The module is client_golang; the packages live underneath it. Chapter 18 — Prometheus: metrics that answer questions
panic: inconsistent label cardinality: "taskd_http_request_duration_seconds" has 3 variable labels named [...] but 2 values [...] were provided WithLabelValues was given a different number of arguments than the metric has label names. Our histogram declares method, route, status. pass three values, in that order. This panics at the call, not at startup, so a rarely taken branch can hide it — which is a reason to keep label lists short. Chapter 18 — Prometheus: metrics that answer questions
graphs where every route is unmatched and every status is 0. the recording lines run before next.ServeHTTP. move them after. Reread the second diagram in §5. Chapter 18 — Prometheus: metrics that answer questions
Prometheus memory climbing steadily, then the container disappearing; docker compose ps shows it exited, and docker inspect reports OOMKilled: true. cardinality explosion. Something is labelling with an unbounded value. find it with curl -s localhost:4000/metrics | grep -c '^taskd_' — if that number grows with your traffic instead of staying flat, you have found the bug. Then audit every WithLabelValues call. Chapter 18 — Prometheus: metrics that answer questions
the request ID in the log, but the customer’s error page has a different one. something in front of the app is minting its own ID and not passing it on, or two requests were made (the browser retried). configure the proxy to set X-Request-ID and forward it; our middleware reuses an inbound one, which is exactly what that branch is for. Chapter 19 — Logging that pays rent
Makefile:52: *** missing separator. Stop. — with whatever line number your new target landed on. a recipe line under a target begins with spaces instead of a real tab character. make has required a literal tab there since 1976 and has never bent on it. delete the leading whitespace on that line and press Tab once. In VS Code, “Convert Indentation to Tabs” on the Makefile, and add "files.associations": {"Makefile": "makefile"} so the editor stops helpfully inserting spaces. Chapter 20 — Testing what matters
the whole suite passes in half a second, including TestTaskLifecycle. almost certainly nothing ran. Without TASKD_TEST_DSN, t.Skip fires and Go reports the package as ok. A green suite that tested nothing is the most dangerous output in this chapter. run with -v and look for --- SKIP. Then set the DSN with make test/int. Chapter 20 — Testing what matters
your development data has vanished. TASKD_TEST_DSN pointed at taskd, not taskd_test, and TRUNCATE did exactly what it was told. there is no fix; re-run your migrations and re-register your test users. Then check the database name at the end of the DSN, every single time. This is why the test database has a different name rather than a different server: one wrong character is easier to spot in .../taskd_test?sslmode=disable than in a port number. Chapter 20 — Testing what matters
cmd/api/tasks_test.go:11:9: undefined: application, under a header line reading # github.com/yourname/taskd/cmd/api_test [github.com/yourname/taskd/cmd/api.test], and then FAIL github.com/yourname/taskd/cmd/api [build failed]. Nothing runs. your test file declares package main_test instead of package main — the _test on the end of the package name in that header is the tell. The suffix creates an external test package that can only see exported identifiers, and application is unexported. change the first line to package main. Same rule in internal/data: use package data. Chapter 20 — Testing what matters
go test ./... reports ok but your new test never appears with -v. the file is not named *_test.go, or the function is not named TestSomething, or its signature is not exactly func TestSomething(t *testing.T). Go does not warn about any of these; it does not see a test at all. check all three. test_tasks.go is not tasks_test.go. Chapter 20 — Testing what matters
--- FAIL: TestTaskLifecycle on a run that passed a minute ago, with no code change. leftover rows, or a test that depends on another test having run first. make sure every DB-touching test calls newTestApplication(t) — the TRUNCATE lives there and nowhere else. Then check you are not asserting on an id you did not create in this test. Chapter 20 — Testing what matters
a suite that takes twenty seconds and spends all of it doing nothing visible. bcrypt at cost 12. Each password hash costs roughly a quarter of a second by design, and every registerAndLogin does one hash to register and one to log in. the package-level cost variable in Pitfalls, below. Chapter 20 — Testing what matters
everything still works, including /v1/tasks for an unactivated user. the /tasks routes are still registered in the outer group — the paste moved the middleware but not the routes. the r.Route("/tasks", ...) block must be inside the braces of the group that calls r.Use(app.requireActivatedUser). Count the closing braces. Chapter 21 — Background work and transactional email
./main.go:88:24: cfg.smtp undefined (type config has no field or method smtp) the [smtp] block is in config.toml but the Go struct never gained a matching field. TOML and Go do not talk to each other; koanf only knows the keys you ask it for. Step 3 — all three pieces: the TOML block, the struct field, the k.String/k.Int lines. Chapter 21 — Background work and transactional email
internal/mailer/mailer.go:13:12: pattern templates: no matching files found //go:embed templates ran at compile time and there is no templates folder next to mailer.go — or it exists but is empty. mkdir -p internal/mailer/templates and create activation.tmpl in it (Step 5). An empty folder is not enough; embed needs at least one file. Chapter 21 — Background work and transactional email
in the server log, msg="sending activation email" with an error mentioning dial tcp 127.0.0.1:8025: connect: connection refused, roughly 1.5 seconds after registering. you pointed the mailer at Mailpit’s web port. 8025 serves HTML to browsers; it does not speak SMTP. port = 1025 in the [smtp] block. The 1.5 seconds is your three retries at half a second apart, working exactly as designed. Chapter 21 — Background work and transactional email
nothing wrong at all in development. In production, emails that “sent successfully” according to the logs never arrive, or arrive minutes later. you used r.Context() inside the background closure. The context is cancelled the instant the handler returns, so the SMTP dial is cancelled too — but sometimes it has already succeeded, which is why it looks fine locally. background work captures values, never r. If it needs a context, it builds one with context.Background(). Chapter 21 — Background work and transactional email
Ctrl-C prints shutting down server and waiting for background tasks, and then the process sits there until you kill it. something infinite joined the WaitGroup — most likely app.background(app.janitor) instead of go app.janitor(). the janitor is a plain go call. Only work that finishes may be counted. Chapter 21 — Background work and transactional email
./routes.go:22:26: app.createActivationTokenHandler undefined (or the same for inactiveAccountResponse / sendActivationEmail). a route or middleware references a function that has not been written yet. Steps 12 and 13. Go compiles the whole package at once, so an unwritten function fails the build even if nothing ever calls it at runtime. Chapter 21 — Background work and transactional email
the reset email never arrives, and the log shows a background error mentioning the context being canceled. the closure used r.Context() instead of building its own — the request context is canceled the instant the handler returns. capture plain values, as above. This is Chapter 21’s pitfall and it catches everyone once. Chapter 22 — Password reset and the account lifecycle
cannot use input.NewEmail (variable of type string) as *string value in struct literal you passed the string where the generated params struct wants a pointer, because the column is nullable. PendingEmail: &input.NewEmail. Chapter 22 — Password reset and the account lifecycle
Did not find any relations. you are connected to an empty database — the migrations never ran here. make db/migrations/up, then \dt again. Appendix C — The complete final schema
Makefile:1: .envrc: No such file or directory followed by make: *** No rule to make target '.envrc'. Stop. the Makefile’s first line is include .envrc, and that file does not exist. It is gitignored, so it never arrives with a clone — you create it. create .envrc in the repository root with your database URL: export TASKD_DB_DSN='postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable' Appendix I — Cheat sheets
invalid metadata: -- name; ... some comment line in a .sql file starts with -- name but isn’t a query name — usually a prose comment that wrapped onto a new line. rewrap the comment so no line begins with -- name. Appendix I — Cheat sheets

When the message isn’t in this table

  1. Is it your code or the tools? Comment out the last thing you changed and run again. If the error persists, it wasn’t your change.
  2. Is the state stale? make sqlc after touching SQL, make db/migrations/up after a new migration, docker compose ps to confirm what’s actually running.
  3. Is it really the error you’re reading? Scroll up. Go prints errors in the order it found them, and the top one is the parent.
  4. Compare with the reference. Appendix E is the final main.go, Appendix F the final routes.go, Appendix A the final tree. Diffing against them finds a missing wire fast.
  5. Reproduce it small. A ten-line program that fails the same way is worth an hour of staring.

For your notes — keep your own version of this table in learnings/. Your errors repeat, and the second time you hit one you want your own words, not mine.

Appendix I — Cheat sheets

Everything you will look up more than once, on as few pages as possible. This is reference, not teaching: each entry assumes you met the idea in the chapter named beside it.


1. This project’s own commands

Every one of these is a target in the Makefile (Appendix B). Run them from the repository root.

Command What it does Chapter
make help list every target 5
make run/api run the server from source 5
make db/psql open a SQL prompt on the dev database 5
make db/migrations/new name=add_widgets create an empty up/down migration pair 5
make db/migrations/up apply every pending migration 5
make sqlc regenerate internal/db from the SQL files 7
make sqlc/vet run sqlc’s lint rules over the queries 7
make test unit tests, with the race detector 20
make test/int tests against a real test database 20
make audit format check, vet, staticcheck, govulncheck, sqlc diff, tests 26
make build/api static binary in bin/api, version stamped from git 25
make docker/build build the production image 25
Common mistake

You’ll see: Makefile:1: .envrc: No such file or directory followed by make: *** No rule to make target '.envrc'. Stop. It means: the Makefile’s first line is include .envrc, and that file does not exist. It is gitignored, so it never arrives with a clone — you create it. Fix: create .envrc in the repository root with your database URL: export TASKD_DB_DSN='postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable'


2. The shell

Command Meaning
pwd which folder am I in
ls / ls -la what’s here / everything, with detail
cd dir / cd .. / cd ~ go in / go up / go home
mkdir -p a/b create folders, parents included
cat file print a file
less file page through a file (q quits)
grep -rn "text" . find text in every file below here, with line numbers
Ctrl-C stop the program running in the foreground
Ctrl-D end of input (quits psql, a shell, a REPL)
command & run in the background
Tab complete the name you started typing
previous command

3. Go tooling

Command What it does
go run ./cmd/api compile to a temp file and run it
go build ./... compile everything; write nothing but errors
go test ./... run every test
go test -race -count=1 ./... tests, with the race detector, no cached results
go test -run TestName ./cmd/api one test
go vet ./... catch suspicious-but-legal code
go mod init github.com/you/proj start a module
go get github.com/x/y@latest add or upgrade a dependency
go mod tidy add what’s used, remove what isn’t
gofmt -l . list files whose formatting is wrong (should print nothing)
go install tool@version install a command-line tool into $(go env GOPATH)/bin
go env GOPATH where installed tools land
Tip

If a tool you installed with go install is “not found”, $(go env GOPATH)/bin is not on your PATH. Add it to your shell profile once and forget it.


4. Go syntax you’ll type most

// declare
var x int              // zero value: 0
x := 5                 // declare + infer, only inside functions
const version = "1.0"

// function with the Go error convention
func doThing(id int64) (Task, error) {
    if id < 1 {
        return Task{}, errors.New("id must be positive")
    }
    return Task{ID: id}, nil
}

// the rhythm: check every error, immediately
t, err := doThing(1)
if err != nil {
    return fmt.Errorf("doing thing: %w", err)   // %w keeps the original for errors.Is/As
}

// struct + JSON tags + method with a pointer receiver
type Task struct {
    ID    int64  `json:"id"`
    Title string `json:"title"`
}

func (t *Task) Rename(s string) { t.Title = s }

// slices and maps
xs := []string{"a", "b"}
xs = append(xs, "c")
for i, v := range xs { _ = i; _ = v }

m := map[string]int{"a": 1}
v, ok := m["a"]        // ok is false if the key is absent

// cleanup runs when the function returns, whatever the path
defer pool.Close()

// interface satisfaction is implicit: having the method IS the declaration
type Handler interface{ ServeHTTP(http.ResponseWriter, *http.Request) }

Pointers in one line: &thing is “the address of thing”; *Thing in a type is “a pointer to a Thing”; everything holding the same pointer sees the same value, not a copy.


5. Concurrency

go doWork()                        // run concurrently, returns immediately

ch := make(chan error)             // a typed pipe
ch <- err                          // send (blocks until received)
e := <-ch                          // receive (blocks until sent)

var wg sync.WaitGroup              // wait for a group of goroutines
wg.Add(1)
go func() { defer wg.Done(); work() }()
wg.Wait()

var mu sync.Mutex                  // one goroutine at a time
mu.Lock()
defer mu.Unlock()

select {                           // whichever is ready first
case v := <-ch:  use(v)
case <-ctx.Done(): return ctx.Err()
}

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

The rule that prevents most bugs: every HTTP request already runs in its own goroutine. Anything shared between requests needs a mutex, a channel, or a database.


6. curl against taskd

# health, with headers
curl -i localhost:4000/v1/healthcheck

# register, then activate with the token from the email
curl -i -X POST localhost:4000/v1/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","password":"pa55word1234"}'

curl -i -X PUT localhost:4000/v1/users/activated \
  -H "Content-Type: application/json" -d '{"token":"THE-TOKEN"}'

# get a bearer token
curl -i -X POST localhost:4000/v1/tokens/authentication \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"pa55word1234"}'

export TOKEN=... # the "token" field from that response

# the product
curl -i -X POST localhost:4000/v1/tasks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"title":"Write chapter 8","priority":"high"}'

curl -s "localhost:4000/v1/tasks?search=chapter&sort=-created_at&page=1&page_size=20" \
  -H "Authorization: Bearer $TOKEN"

curl -i -X PATCH localhost:4000/v1/tasks/1 \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"completed":true}'

curl -i -X DELETE localhost:4000/v1/tasks/1 -H "Authorization: Bearer $TOKEN"
Flag Meaning
-i include the response headers
-s silent: no progress meter
-X METHOD use this HTTP method
-H "K: V" send a header
-d '...' send this body (implies POST unless -X says otherwise)
-o file write the body to a file
-w '%{http_code}\n' print just the status code

7. Status codes taskd returns

Code Meaning here Sent by
200 OK success, answer in the body reads, updates
201 Created new thing exists create task, register user
202 Accepted accepted, finishing in the background registration email
204 No Content success, nothing to say delete
400 Bad Request the body isn’t valid JSON at all readJSON
401 Unauthorized missing or wrong credentials auth middleware
402 Payment Required your plan doesn’t include this entitlements
403 Forbidden authenticated, but not allowed (e.g. not activated) activation gate
404 Not Found no such route, or not yours router, ownership
405 Method Not Allowed right path, wrong verb router
409 Conflict someone else changed it first optimistic locking
422 Unprocessable Entity valid JSON, invalid content validator
429 Too Many Requests rate limit limiter
500 Internal Server Error our fault serverErrorResponse, panics
503 Service Unavailable dependency down / shutting down healthcheck

8. psql and SQL

make db/psql                 # or: docker compose exec db psql -U taskd -d taskd
At the psql prompt What it does
\dt list tables
\d tasks describe the tasks table (columns, indexes, constraints)
\di list indexes
\x toggle expanded (one field per line) output
\timing show how long each query takes
\q quit
SELECT id, title, completed FROM tasks WHERE user_id = 1 ORDER BY created_at DESC LIMIT 10;
INSERT INTO tasks (user_id, title) VALUES (1, 'hello') RETURNING id, created_at;
UPDATE tasks SET completed = true, version = version + 1 WHERE id = 1 RETURNING version;
DELETE FROM tasks WHERE id = 1;

SELECT count(*) FROM tasks;
EXPLAIN ANALYZE SELECT * FROM tasks WHERE user_id = 1;   -- is the index used?

BEGIN;  ...  COMMIT;    -- or ROLLBACK;

9. Migrations (golang-migrate)

make db/migrations/new name=add_widgets   # creates 0000NN_add_widgets.{up,down}.sql
make db/migrations/up                     # apply everything pending

migrate -path ./migrations -database $TASKD_DB_DSN down 1     # undo the last one
migrate -path ./migrations -database $TASKD_DB_DSN version    # where am I
migrate -path ./migrations -database $TASKD_DB_DSN force 4    # clear a "dirty" state
Warning

force does not run anything — it only overwrites the recorded version. Use it after you have fixed the database by hand, never as a way to skip a failing migration.


10. sqlc

make sqlc        # regenerate internal/db from migrations + sql/queries
make sqlc/vet    # lint the queries
sqlc diff        # fail if the generated code is stale (this is what CI runs)

The magic comments at the top of each query decide what Go you get:

Annotation Generated signature returns
-- name: GetTask :one (Task, error)
-- name: ListTasks :many ([]Task, error)
-- name: DeleteTask :exec error
-- name: MarkPaid :execrows (int64, error) — rows affected
Common mistake

You’ll see: invalid metadata: -- name; ... It means: some comment line in a .sql file starts with -- name but isn’t a query name — usually a prose comment that wrapped onto a new line. Fix: rewrap the comment so no line begins with -- name.


11. Docker and Compose

Command What it does
docker compose up -d start everything in the background
docker compose up -d db cache start only these services
docker compose ps what’s running, and is it healthy
docker compose logs -f api follow one service’s logs
docker compose exec db psql -U taskd -d taskd run a command inside a running container
docker compose down stop and remove containers (keeps named volumes)
docker compose down -v …and delete the volumes. This deletes your database.
docker compose restart api restart one service
docker build -t taskd:dev . build the image from the Dockerfile
docker images list images and their sizes

12. Configuration and environment overrides

Settings live in config.toml; any value can be overridden by an environment variable. The rule (Chapter 3):

config.toml key        environment variable
─────────────────      ─────────────────────────────
app.port          →    TASKD_APP__PORT
db.dsn            →    TASKD_DB__DSN
stripe.secret_key →    TASKD_STRIPE__SECRET_KEY

  prefix TASKD_ + section + DOUBLE underscore + key, all upper case

Environment always wins over the file. Secrets never go in the committed file.


13. Debugging recipes

Situation First move
Compile error you can’t parse read the first error only; the rest are usually its children
panic: with a stack trace read from the top: the first line of your code in the trace is where to look
Handler returns 500, no detail check the server log — the error text goes there, never to the client (Chapter 8)
“It works in curl but not in the browser” CORS (Chapter 23). Look at the browser console, not the server
Data is missing, no error did the migration run? make db/migrations/up, then \dt in psql
Query is slow EXPLAIN ANALYZE it in psql; look for Seq Scan on a big table
Test passes alone, fails in a suite shared state. Run with -race; check for a package-level variable
Server won’t start: “address already in use” something is already on port 4000: lsof -i :4000, then kill it
Container “unhealthy” docker compose logs <service> — the healthcheck output tells you why

For your notes — the three commands worth memorising before anything else: make run/api, make db/migrations/up, docker compose ps. Most of your stuck moments in this book resolve by running one of them and reading what it says.

Appendix J — Answers, solutions and the final exam

Where the answers live

Every quiz answer and every exercise solution is printed in the chapter it belongs to, inside a collapsed Answers block directly after the questions. They are not moved here, deliberately: an answer three hundred pages away from its question is an answer nobody reads.

This appendix is the map, plus one thing the chapters cannot give you — a test that spans the whole book.

Chapter Quiz questions
Before you begin 8
How the web actually works 8
Go in one sitting 8
SQL and databases in one sitting 8
Chapter 1 — Introduction: what we’re building and why this shape 8
Chapter 2 — The skeleton: a server that answers 8
Chapter 3 — Configuration and logging, the Nadh way 8
Chapter 4 — A server that dies well: lifecycle and core middleware 8
Chapter 5 — PostgreSQL and migrations 8
Chapter 6 — Connecting with pgx/v5 8
Chapter 7 — sqlc: SQL in, type-safe Go out 8
Chapter 8 — CRUD done properly: JSON helpers, errors, validation 8
Chapter 9 — Listing at scale: filtering, sorting, pagination 8
Chapter 10 — Users and passwords 8
Chapter 11 — Stateful tokens: authentication without JWT drama 8
Chapter 12 — Ownership: making it multi-tenant 8
Chapter 13 — Caching with DragonflyDB 8
Chapter 14 — Rate limiting: per-instance, then distributed 8
Chapter 15 — Stripe I: tiers, customers, checkout 8
Chapter 16 — Stripe II: webhooks, the source of truth 8
Chapter 18 — Prometheus: metrics that answer questions 8
Chapter 19 — Logging that pays rent 8
Chapter 20 — Testing what matters 8
Chapter 21 — Background work and transactional email 8
Chapter 22 — Password reset and the account lifecycle 8

Total: 200 quiz questions across 25 chapters.


How to actually use a quiz

Answering a question right after reading the section that answers it measures almost nothing — the answer is still in your short-term memory. Two habits fix that:

  1. Answer the quiz the next day, before re-reading anything. What survives a night’s sleep is what you have actually learned.
  2. Write the answer down before opening the Answers block. An answer you thought was right and a written answer are different things, and only one of them can be checked.
Remember this

If you can explain why the wrong options are wrong, you understand the topic. If you can only recognise the right one, you have memorised it.


The final exam

Twenty-five questions spanning the whole book. No lookups on the first pass — write your answers down, then check. Aim to sit it a week after finishing the book, not the same day.

(The exam and its worked answers are assembled once every chapter is finalised — see the build note in the repository README.)

For your notes — track your exam score and, more importantly, which part of the book your wrong answers cluster in. That part is the one to re-read.

Reader Settings
Color Theme
Reading Font
Text Size
Page Measure / Width
Reading Mode