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.
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.
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.
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:
- 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.
- 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.
- 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.
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:
- Read the chapter once, all the way through, without typing anything. You are just meeting
the ideas.
- Read it again, typing every code block and running every checkpoint.
- 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.
- 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).
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.
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 |
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”.
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:
- Docker Desktop needs a toggle: Settings → Resources → WSL Integration → enable for
Ubuntu. Without it, docker is not found inside your Ubuntu shell.
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.
- 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.
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.
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.
| 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 |
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).
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.
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.
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
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.
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
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
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.
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
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):
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:
package main — this file belongs to the package main, the one Go treats as a runnable
program rather than a library.
import "fmt" — pull in the standard library’s formatting package. Import something you don’t
use and Go refuses to compile, deliberately.
func main() — where the program starts. Exactly one per program.
- A tab-indented body. Format-on-save handles that for you.
go run .
Real output:
hello from Go
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.
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:
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
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.
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.
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 }:
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.
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
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.
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.
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.
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.
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.
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
go run . gives go: cannot find main module. Most likely cause, and the first command you
run to check?
command not found: sqlc appears right after go install ...sqlc... reported no errors. Was
the install broken?
- A compile error says
./cmd/api/main.go:47:12. What are the 47 and the 12?
- Your file is 30 lines and the error is
./main.go:31:1: syntax error: unexpected EOF, expected }. Where do you look?
curl -i localhost:4000/v1/healthcheck prints curl: (7) Failed to connect.... Name the two
most likely causes.
- Why does this book insist on WSL2 for Windows readers rather than PowerShell?
make says *** missing separator. Stop. after you copy a Makefile out of a PDF. Which single
character is wrong?
docker compose version succeeds. Does that mean Docker can run a database for you?
Answers
- 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.
- 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.
- Line 47, column 12 — the 12th character on that line. The column tells you which identifier on
a long line the compiler objects to.
- 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.
- 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.
- 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.
- The indent. GNU make requires one real Tab at the start of each recipe line; spaces produce
exactly that error.
- 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.
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.
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.
- 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.
- Silence means success. Unix tools print nothing when they work; unexpected text is the
signal.
- 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.
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.
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.
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
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.
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.
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.
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.
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:
- 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.
- 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.
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:
- The request line —
POST /v1/tasks HTTP/1.1. Three fields separated by spaces: the
method (what kind of operation), the path (which resource), the protocol version.
- Headers — one per line,
Name: value. Facts about the request rather than the request
itself.
- A blank line — the marker that says “headers finished, body begins”. Not decoration.
Without it the server cannot tell where headers stop.
- 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.
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.
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 |
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.
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.
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
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.
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:
- 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.
"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”.
"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:
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
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.
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
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.
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
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:
- 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.
- Nobody in between can change the bytes. Tampering is detected.
- 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:
- Resolve.
localhost becomes 127.0.0.1 from a local file. In production this is a real
DNS lookup (Chapter 27).
- 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.
- Write. curl formats the method, path, headers, blank line, and body as text and sends it.
- 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.
- 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.
- 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.
- Handle. Decode the JSON (400 on failure), validate it (422 on failure), check the plan’s
quota (402 on failure), then insert a row.
- Database. Postgres returns the created row, including the
id and version it assigned.
- Respond.
writeJSON sets Location, sets Content-Type, writes status 201, writes body.
In that order, because after the status nothing else can be set.
- Return. The response text goes back down the same connection that carried the request.
- 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
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.
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.
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.
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.
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.
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
- 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?
- You run
curl localhost:4000/v1/healthcheck and get Failed to connect. Name two different
causes, and the one place you would look first.
- Which of
GET, POST, PUT, DELETE are idempotent? Why does the answer matter to
Chapter 23?
- A client sends
{"title":""} to POST /v1/tasks with a valid token. Which status code comes
back, and why is it not 400?
- In an HTTP message, what separates the headers from the body, and what happens if it is
missing?
- Why does taskd return 404 rather than 403 when you ask for a task belonging to another user?
- You see
HTTP/1.1 200 OK but the body is {"error":"..."}. What has the developer done
wrong?
- What does
Content-Type: application/json actually change about the bytes in the body?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
- A server is a process that holds a port and waits. “Connection refused” means absent, not
broken — check the terminal where you started it.
- 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.
- 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.
- Safe means “changes nothing”; idempotent means “twice equals once”.
POST is neither, which
is the entire reason idempotency keys exist in Chapter 23.
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.
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:
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.
-
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
-
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
-
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
-
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
-
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
-
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.
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.
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.
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.
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.
package main
import "fmt"
const version = "0.1.0"
type config struct {
port int
env string
}
func main() {
var cfg config
fmt.Printf("zero config: %+v\n", cfg)
cfg.port = 4000
cfg.env = "development"
addr := fmt.Sprintf(":%d", cfg.port)
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.
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
package main
import "fmt"
type Tier string
const TierPro Tier = "pro"
func main() {
var pageSize int = 20
var limit int32 = int32(pageSize)
var enabled bool
var name string
var hash []byte
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:
- 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.
- 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.
- 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.
package main
import (
"fmt"
"strconv"
)
func itoa(i int64) string { return strconv.FormatInt(i, 10) }
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.
fmt.Printf and fmt.Sprintf take a template with % placeholders. The book uses six of them.
package main
import "fmt"
func main() {
fmt.Printf("%d\n", 4000)
fmt.Printf("%s\n", "development")
fmt.Printf("%q\n", "development")
fmt.Printf("%v\n", []string{"open", "done"})
fmt.Printf("%+v\n", struct{ Page int }{3})
fmt.Printf("%T\n", int64(7))
fmt.Printf("100%% cached\n")
}
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.
package main
import (
"errors"
"fmt"
"os"
)
func loadConfig(path string) error {
_, 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)
fmt.Println("is not-exist?", errors.Is(err, os.ErrNotExist))
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.
%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.
package main
import (
"errors"
"fmt"
)
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 {
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.
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
struct — a named group of related values kept together, each with its own name and type. A
labelled box with compartments.
package main
import (
"encoding/json"
"fmt"
"time"
)
type Token struct {
Plaintext string `json:"token"`
Hash []byte `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.
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.
package main
import "fmt"
type Filters struct{ Page, PageSize int }
func (f Filters) Offset() int32 { return int32((f.Page - 1) * f.PageSize) }
func (f *Filters) NextPage() { f.Page++ }
func main() {
f := Filters{Page: 3, PageSize: 20}
fmt.Println("offset:", f.Offset())
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:
package main
import "fmt"
type Filters struct{ Page int }
func (f Filters) NextPageBroken() { f.Page++ }
func main() {
f := Filters{Page: 1}
f.NextPageBroken()
fmt.Println("page:", f.Page)
}
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.
package main
import (
"log/slog"
"os"
)
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"}
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.
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
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.
package main
import "fmt"
type Task struct{ Title, Notes string }
func main() {
a := Task{Title: "original"}
b := a
p := &a
b.Title = "copy edited"
p.Title = "pointer edited"
fmt.Println(a.Title, "|", b.Title, "|", p.Title)
var missing *Task
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”.
package main
import (
"encoding/json"
"fmt"
)
type Task struct{ Title, Notes string }
func main() {
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
}
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
slice — Go’s growable list of values of one type, written []T. A shopping list you can keep
adding lines to.
package main
import (
"encoding/json"
"fmt"
)
func main() {
statuses := []string{"open", "doing", "done"}
fmt.Println(len(statuses), statuses[0], statuses)
tasks := make([]string, 0, len(statuses))
for i, s := range statuses {
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
map — a lookup table from keys to values, written map[K]V. A phone book: name in, number
out.
package main
import "fmt"
func main() {
errs := make(map[string]string)
errs["title"] = "must be provided"
if _, exists := errs["title"]; !exists {
errs["title"] = "must not be more than 500 bytes long"
}
fmt.Println(errs, len(errs) == 0)
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 idiom — value, 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.
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
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
plaintext := "SIU4EQPHHUB6D5RQ3TPCJTPWLE"
hash := sha256.Sum256([]byte(plaintext))
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++ { }
for i, v := range collection { }
for condition { }
for { }
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.
package main
import "fmt"
func plan(tier string) int {
switch tier {
case "pro":
return 10_000
case "business":
return -1
default:
return 100
}
}
func main() {
for _, t := range []string{"free", "pro", "business"} {
fmt.Println(t, plan(t))
}
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.
package main
import "fmt"
type envelope map[string]any
func describe(v any) string {
switch x := v.(type) {
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))
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.
package main
import (
"fmt"
"strings"
)
func main() {
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"))
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.
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
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.
package main
import (
"fmt"
"io"
"os"
"strings"
)
func greet(w io.Writer, name string) { fmt.Fprintf(w, "hello %s\n", name) }
func main() {
greet(os.Stdout, "stdout")
var b strings.Builder
greet(&b, "builder")
fmt.Print(b.String())
fmt.Println(io.Discard != nil)
}
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.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
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()
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.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
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)
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)
h = announce("recoverPanic", h)
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.
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.
package main
import "fmt"
func withLoop() {
for i := 1; i <= 3; i++ {
defer fmt.Println("deferred", i)
}
fmt.Println("loop finished")
}
func main() {
fmt.Println("open pool")
defer fmt.Println("close pool")
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:
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.
- 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
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.
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 {
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.
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
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.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
)
func main() {
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
package main
import (
"fmt"
"time"
)
func main() {
shutdownError := make(chan error)
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutine: draining requests")
shutdownError <- nil
}()
fmt.Println("main: serving traffic")
err := <-shutdownError
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.
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)
go func() {
defer wg.Done()
clients["1.2.3.4"]++
}()
}
wg.Wait()
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.
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()
defer mu.Unlock()
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.
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.
package main
import (
"fmt"
"time"
)
func main() {
done := make(chan string)
go func() { time.Sleep(300 * time.Millisecond); done <- "query finished" }()
select {
case msg := <-done:
fmt.Println(msg)
case <-time.After(100 * time.Millisecond):
fmt.Println("timed out first")
}
}
timed out first
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
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.
package main
import (
"context"
"fmt"
"time"
)
func slowQuery(ctx context.Context) error {
select {
case <-time.After(2 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
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.
package main
import (
"context"
"fmt"
)
type contextKey string
const requestIDKey = contextKey("request_id")
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "9f2c1ab30e77")
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.
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.
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()
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
package main
import (
"fmt"
"slices"
)
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...))
fmt.Println(PermittedValue("urgent", Statuses...))
fmt.Println(PermittedValue(402, 200, 201, 404))
}
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.
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{}
}
return Metadata{
CurrentPage: page, PageSize: pageSize, FirstPage: 1,
LastPage: int((total + int64(pageSize) - 1) / int64(pageSize)),
TotalRecords: total,
}
}
package pagination
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)
}
})
}
}
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. |
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.
var cfg config — what is in cfg.port immediately afterwards, and why is that not a bug?
- You write
func (f Filters) NextPage() { f.Page++ }, call f.NextPage(), and f.Page doesn’t
change. What’s wrong?
- In Chapter 8’s PATCH handler, why is the input struct’s
Title field a *string rather than a
string?
- What is the difference between
fmt.Errorf("loading: %w", err) and
fmt.Errorf("loading: %v", err), and which one breaks errors.Is?
- 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?
- 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?
- 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.
- Why does
taskd pass r.Context() into every database call, and what specifically goes wrong in
a background goroutine that does the same?
Answers
-
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.
-
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.
-
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.
-
%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.
-
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.
-
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.
-
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.
-
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 you —
int 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
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:
-
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.
-
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.
-
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.
-
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.
-
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.
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.
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.
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 |
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.
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.
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
);
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.
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.
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.
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.
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:
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;
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:
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 true,
version integer NOT NULL DEFAULT 1
);
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:
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.
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:
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'.
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);
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.
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.
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.
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:
- 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.
- 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.
- 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:
users is the root. Every other table with a person in it points here.
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.
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.
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.
stripe_events stands alone. It records which Stripe webhook ids have already been
processed, so a redelivered event is a no-op (Chapter 16).
- 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 |
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
notes is text NOT NULL DEFAULT '' but due_at is plain timestamptz. What is the
difference in what those two columns can say?
- You run
SELECT * FROM tasks WHERE due_at = NULL; and get zero rows, though you know four
tasks have no due date. Why?
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.
- Why does Chapter 9 end its
ORDER BY with id ASC when it has already sorted by created_at?
- What does
count(*) OVER() give you that a separate SELECT count(*) query does not?
idx_tasks_user_id indexes tasks(user_id). Name one query it makes fast and one cost it
imposes.
- A user is deleted. Without any Go code running, what else disappears, and what mechanism does
it?
- taskd never writes
BEGIN or COMMIT. Is that a bug? Defend your answer in two sentences.
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
- When a request arrives, what decides which of your functions answers it?
- How does your Go code ask the database a question, and what happens when the question is
wrong?
- Which library actually speaks to PostgreSQL over the network?
- 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.
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.
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 |
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:
func showTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "task %s", id)
}
func showTask(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
fmt.Fprintf(w, "task %s", id)
}
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.
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. |
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.
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:
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.
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.
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. |
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.
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.
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. |
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)
└─────────────────┘
- client — anything calling the API: a browser, a phone app,
curl, another company’s server.
- 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.
:4000 — the port taskd listens on. A port is a numbered door on a machine.
- taskd — everything this book writes, in one process: chi picks the handler, auth decides who
is calling, quotas decide whether they may.
- PostgreSQL — the permanent record, reached through pgx using SQL that sqlc turned into Go.
- 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.
- Stripe — we call it to start a checkout; it calls us back with webhooks (Chapters 15, 16).
- 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).
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.
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
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. |
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.
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
- Name the four decisions this chapter makes, and one reason for each answer.
- chi is called “still
net/http”. What specific, checkable fact does that claim rest on?
- 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.
- Why was
lib/pq rejected, and why is that the cheapest of the four decisions to reverse?
- What does “modular monolith” mean, and what condition would make you split it?
- What does
/v1 buy you, given that there is no /v2 and may never be one?
- In the system diagram, which box calls Stripe, which direction does Prometheus’s arrow point, and
which two boxes does this book not write?
- 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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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:
- 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?
- 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.
- 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.
- “Monolith” describes deployment, not tidiness. Internal walls and a single binary are
compatible, and this book has both.
- 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.
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. |
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.
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:
package main
var logger *slog.Logger
var cfg config
func healthcheckHandler(w http.ResponseWriter, r *http.Request) {
logger.Info("healthcheck")
}
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.
type application struct {
config config
logger *slog.Logger
}
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
app.logger.Info("healthcheck")
}
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.
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.
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 │ │
curl opens a TCP connection to port 4000 on your machine.
- It sends the bytes of an HTTP request: a method, a path, some headers, a blank line.
- 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.
- chi looks at
GET plus /v1/healthcheck, finds the match, and calls
app.healthcheckHandler(w, r).
- The handler sets one header and writes the body.
- 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.
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.
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.
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
"time"
)
const version = "0.1.0"
type config struct {
port int
env string
}
type application struct {
config config
logger *slog.Logger
}
func main() {
var cfg config
cfg.port = 4000
cfg.env = "development"
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
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)
}
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 = 4000 — var 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).
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.
“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.
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func (app *application) routes() http.Handler {
r := chi.NewRouter()
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
}
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:
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)
}
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.
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 │
└──────────────────────────────────────────────┘
package main
import (
"fmt"
"net/http"
)
func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
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
curl -i localhost:4000/v1/healthcheck
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.
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
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.
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
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)
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.
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.
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.
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.
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
- Why can’t someone else’s project import your
internal/data package? Is that a convention or a
rule?
- What does
go mod init github.com/yourname/taskd actually create, and what else does that name
get used for?
- In
func (app *application) healthcheckHandler(...), what do the parts before the function name
do? Name both jobs.
- What is Go’s default value for
ReadTimeout, and what does that default mean in practice?
- Which of the three timeouts is the one that stops a slowloris attack, and why not the other two?
srv.ListenAndServe() is followed immediately by logger.Error(err.Error()) with no
if err != nil. Why is that correct here?
- When a request for
GET /v1/healthcheck arrives, what does chi do with it?
- 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
-
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.
-
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.
-
(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.
-
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.
-
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.
-
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.
-
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.
-
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
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
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)
}
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
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/nope
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:
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:
- 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.
internal/ is enforced by the compiler, not by etiquette. Everything under it stays yours to
change forever.
- 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.
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.
- 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.
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.
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.
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.
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
- The first load fills the table from
config.toml. Every setting now has a value.
- 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.
- Nothing “chooses” between the two. The second write lands on top of the first.
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.
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.
[app]
env = "development"
port = 4000
[log]
level = "debug"
[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 |
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.
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:
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:
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:
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.
func loadConfig(path string) (config, error) {
k := koanf.New(".")
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
return config{}, fmt.Errorf("loading %s: %w", path, err)
}
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.
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.
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")
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”.
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:
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() {
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}
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)
}
func newLogger(cfg config) *slog.Logger {
var lvl slog.Level
_ = lvl.UnmarshalText([]byte(cfg.logLevel))
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.
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
TASKD_APP__PORT=9999 go run ./cmd/api
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.
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
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
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
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
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)
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.
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.
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.
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.
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.
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.
“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.
One more, which bites in Chapter 5 (PostgreSQL and migrations): that chapter creates a file
exporting TASKD_DB_DSN — single 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
loadConfig calls k.Load twice. Which call wins when both produce the same key, and which line
of code decides that?
- Why is the separator in
TASKD_DB__DSN a double underscore instead of a dot?
max_idle_time = "15m" is quoted in the TOML file. What turns it into a duration, and what
happens if you remove the quotes?
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?
- Why must
cfg.db.dsn never appear in a log line?
- 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?
- When are JSON logs better than text logs, and who is the beneficiary?
- What is the
-config flag for, given that config.toml is already the default — and which later
chapter cashes it in?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
shutdown_timeout = "30s"
shutdownTimeout time.Duration
cfg.shutdownTimeout = k.Duration("app.shutdown_timeout")
logger.Info("starting server", "addr", srv.Addr, "env", cfg.env,
"shutdown_timeout", cfg.shutdownTimeout)
Verify both layers:
go run ./cmd/api
TASKD_APP__SHUTDOWN_TIMEOUT=5s go run ./cmd/api
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
func redactDSN(dsn string) string {
u, err := url.Parse(dsn)
if err != nil {
return "***"
}
if _, hasPassword := u.User.Password(); hasPassword {
u.User = url.UserPassword(u.User.Username(), "redacted")
}
return u.String()
}
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 != "",
)
}
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:
- 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.
- 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.
__ 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.
- 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.
- 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.
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 |
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?” |
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.
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:
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}
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.
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.
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 │
▼ ▼ ▼
serve() starts a second goroutine whose only job is to wait for a signal.
- That goroutine blocks on
<-quit, possibly for weeks. It costs a few kilobytes to sit there.
- When a signal arrives it wakes, logs, and builds a context that expires in 30 seconds — the
drain budget.
- It calls
srv.Shutdown(ctx), which closes the listener at once and then waits for running
handlers.
- 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.
- 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.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func (app *application) serve() error {
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),
}
shutdownError := make(chan error)
go func() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
s := <-quit
app.logger.Info("shutting down server", "signal", s.String())
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
shutdownError <- srv.Shutdown(ctx)
}()
app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.env)
err := srv.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
return err
}
if err := <-shutdownError; err != nil {
return err
}
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.
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:
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}
err = app.serve()
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
func newLogger(cfg config) *slog.Logger {
var lvl slog.Level
_ = lvl.UnmarshalText([]byte(cfg.logLevel))
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))
}
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.
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.
package main
import (
"fmt"
"net/http"
"time"
)
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
app.serverErrorResponse(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
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)
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 != nil — recover() 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.
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:
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)
}
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():
func (app *application) routes() http.Handler {
r := chi.NewRouter()
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
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.
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:
r.Get("/panic", func(w http.ResponseWriter, r *http.Request) {
panic("boom")
})
Now run the two experiments:
go run ./cmd/api
curl -i localhost:4000/v1/panic
curl -i localhost:4000/v1/healthcheck
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
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.
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
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 $?
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)
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.
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.
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.
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.
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
- In one sentence, and including its signature, what is a middleware?
srv.ListenAndServe() returns http.ErrServerClosed. Did the shutdown succeed?
- Why does the signal-waiting code need its own goroutine? What breaks if you delete the
go?
make(chan os.Signal, 1) — what does the 1 do, and why isn’t it 0?
- Why is
defer cancel() there, given that the context expires by itself after 30 seconds anyway?
- Why does
logRequest write its log line after next.ServeHTTP rather than before?
- Your slowest legitimate request takes 45 seconds. What goes wrong on deploy, and which numbers
would you change?
- A request panics. With
recoverPanic registered first, how many log lines does that request
produce, and which?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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:
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
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)
})
}
r.Use(app.recoverPanic)
r.Use(app.logRequest)
r.Use(app.serverHeader)
Verify:
curl -sD- -o /dev/null localhost:4000/v1/healthcheck | grep -i '^server:'
curl -sD- -o /dev/null localhost:4000/v1/nothing-here | grep -i '^server:'
(-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:
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.
- 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.
- Panic recovery goes outermost, because a recovery only catches what it wraps, and the most
likely source of an unexpected panic is another middleware.
recover() works only inside a deferred function. That constraint, not style, is why the
defer func() { ... }() shape exists.
- 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.
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.
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.
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.
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 |
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.)
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.)
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│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
- Three databases are already at version 1; the freshly created CI database is at version 0.
- One command —
migrate up — runs on each of them.
- The three at version 1 run only
000002. The empty one runs 000001 and then 000002.
- 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.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: taskd
POSTGRES_PASSWORD: pa55word
POSTGRES_DB: taskd
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskd -d taskd"]
interval: 5s
timeout: 3s
retries: 10
volumes:
db-data:
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.
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.
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.
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.
include .envrc
.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 taskd — exec runs a command inside an
already-running container. psql is Postgres’s own client, -U the user, -d the database.
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.
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:
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
);
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.
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:
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');"
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)
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.
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.
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.
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.
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.
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
- In one sentence: what is a migration, and what does the number in its filename buy you?
- What is in the
schema_migrations table, and why does its existence make migrate up safe to
run repeatedly?
notes is NOT NULL DEFAULT '' but due_at is nullable. Argue for both choices in one sentence
each.
- 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?
- You delete the
healthcheck: block. What still works today, and what breaks in Chapter 25?
- What would break if the primary key were
bigint GENERATED BY DEFAULT AS IDENTITY instead of
GENERATED ALWAYS?
version integer NOT NULL DEFAULT 1 is read by no code in this chapter. Why write it now?
- 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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
CREATE INDEX idx_tasks_status ON tasks (status);
ALTER TABLE tasks ADD CONSTRAINT tasks_title_not_empty CHECK (title <> '');
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';"
docker compose exec db psql -U taskd -d taskd -c \
"INSERT INTO tasks (title) VALUES ('');"
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';"
(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;"
migrate -path ./migrations -database $TASKD_DB_DSN force 1
make db/migrations/up
docker compose exec db psql -U taskd -d taskd -tAc \
"SELECT version, dirty FROM schema_migrations;"
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:
- 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.
- 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.
- 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.
- Read a DSN left to right: scheme, user, password, host, port, database, options. You will read
them for the rest of your career.
- 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:
- 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.
- Possibly a TLS handshake. In production the connection is encrypted, which costs another
round trip or two and some cryptography.
- Authentication. Postgres asks who you are; your program proves it with the password from the
DSN. More round trips.
- 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.
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).
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.
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.
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.
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.
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.
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.
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:
- Three requests are mid-query, each holding one connection for as long as its query runs.
- Twenty-two connections are idle — open, authenticated, doing nothing, ready in microseconds.
- Requests 4 and 5 arrived when nothing was free. They wait; they are not refused.
- 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.
- 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:
[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.
package main
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func openDB(cfg config) (*pgxpool.Pool, error) {
poolCfg, err := pgxpool.ParseConfig(cfg.db.dsn)
if err != nil {
return nil, err
}
poolCfg.MaxConns = cfg.db.maxConns
poolCfg.MaxConnIdleTime = cfg.db.maxIdleTime
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
return nil, err
}
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.
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.
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.
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.
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)
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}
err = app.serve()
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
func newLogger(cfg config) *slog.Logger {
var lvl slog.Level
_ = lvl.UnmarshalText([]byte(cfg.logLevel))
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.
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.
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.
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"
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w,
`{"status":"available","database":%q,"environment":%q,"version":%q}`,
dbStatus, app.config.env, version)
}
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 != nil — app.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.
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.
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.
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
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)
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.
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.
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.
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.
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:
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
- What, exactly, does a connection pool save? Name the work that stops happening per request.
pgxpool.NewWithConfig returned a nil error. Why is Ping still necessary?
- 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?
- What does
MaxConnIdleTime protect you from? Why does the problem it solves appear after a
quiet period rather than during traffic?
- 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?
- In the healthcheck, what would change if the timeout’s parent were
context.Background() instead
of r.Context()?
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?
- 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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.)
-
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.
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
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"
}
status := http.StatusOK
if dbStatus == "down" {
status = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
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
docker compose stop db
curl -s -o /dev/null -w '%{http_code}\n' localhost:4000/v1/healthcheck
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:
- 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.
- 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.
- 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.
- 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.
- 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:
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:
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.
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, ...)
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.
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.
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:
- Write plain SQL in
sql/queries/*.sql, with a magic comment naming each query.
- Run
sqlc generate.
- 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.
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.
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.
version: "2"
sql:
- engine: "postgresql"
schema: "migrations"
queries: "sql/queries"
gen:
go:
package: "db"
out: "internal/db"
sql_package: "pgx/v5"
emit_json_tags: true
emit_pointers_for_null_types: true
overrides:
- db_type: "timestamptz"
go_type: "time.Time"
- 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:
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:
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 *time.Time `json:"due_at"`
Version int32 `json:"version"`
}
With both overrides, which is the file you typed above:
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:
err = app.q.InsertToken(r.Context(), db.InsertTokenParams{
Hash: token.Hash, UserID: token.UserID,
Expiry: token.Expiry, Scope: token.Scope,
})
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.
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.
Create the folder and the file:
mkdir -p sql/queries
INSERT INTO tasks (title, notes, priority, due_at)
VALUES ($1, $2, $3, $4)
RETURNING *;
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.
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.
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.
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” |
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:
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:
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.
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.
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.
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.
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.
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:
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.
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.
"github.com/yourname/taskd/internal/db"
type application struct {
config config
logger *slog.Logger
db *pgxpool.Pool
q *db.Queries
}
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.
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.
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)
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}
app.q = db.New(pool)
err = app.serve()
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
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.
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”.
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.
.PHONY: sqlc
sqlc:
sqlc generate
.PHONY: sqlc/vet
sqlc/vet:
sqlc vet
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.
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.
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.
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.
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
- Where does sqlc learn the shape of the
tasks table, and does it connect to your database to do
it?
sqlc.yaml has two timestamptz overrides. What does each one cover, and what exactly breaks if
you delete the first?
- You add a query to
sql/queries/tasks.sql but forget the -- name: line. What does
sqlc generate print?
GetTask takes (ctx, id int64) while CreateTask takes (ctx, arg CreateTaskParams). Why the
difference?
- A
:one query matches no rows. What does the generated function return, and which package does
that value come from?
- Why is
SELECT * acceptable in a sqlc query but risky in hand-written scanning code?
- Your colleague deletes
internal/db/ from Git, arguing “it’s generated, it doesn’t belong in the
repo”. Give two concrete things that break.
- 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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
(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.
-
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
SELECT count(*) FROM tasks;
SELECT * FROM tasks ORDER BY created_at DESC LIMIT 5;
make sqlc
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:
.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.
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.
- What is
DBTX, and which two concrete types satisfy it?
- Why does
CreateTask take a CreateTaskParams struct rather than four separate arguments?
- Where did the
json:"created_at" tags come from?
GetTask returns (Task, error). What error does it return for an id that does not exist, and
which package is that error from?
- 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
-
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.
-
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).
-
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.
-
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.
-
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:
- 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.
- 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.
- 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.
$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.
- 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.
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.
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.
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:
- A body size cap (
http.MaxBytesReader), or someone POSTs you a memory bomb.
DisallowUnknownFields, or clients typo titel, get silent 200s, and file bugs against
you.
- 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.
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 |
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.)
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.
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 |
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.
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
└───────────────────────────────────────────────┘
- Decode turns bytes into a Go struct. Anything wrong here is the client’s fault.
- Validate checks the contents against business rules. The body parsed; the answers were
wrong.
- Query talks to Postgres. Anything wrong here is our fault or the database’s.
- 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.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
type envelope map[string]any
func (app *application) writeJSON(w http.ResponseWriter, status int,
data envelope, headers http.Header) error {
js, err := json.Marshal(data)
if err != nil {
return err
}
js = append(js, '\n')
for key, value := range headers {
w.Header()[key] = value
}
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 headers — range walks a collection. Over a map it hands you each key
and value in turn, in no guaranteed order.
w.Header()[key] = value — http.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 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.
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
maxBytes := 1_048_576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
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)
default:
return err
}
}
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.
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.
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.
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.
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
}
func itoa(i int64) string { return strconv.FormatInt(i, 10) }
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.
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”:
package main
import (
"fmt"
"net/http"
)
func (app *application) logError(r *http.Request, err error) {
app.logger.Error(err.Error(), "method", r.Method, "path", r.URL.Path)
}
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 {
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
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.
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.
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.
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 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:
package main
import (
"context"
"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"
}
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)
}
}
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.
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.
package validator
import "slices"
type Validator struct {
Errors map[string]string
}
func New() *Validator {
return &Validator{Errors: make(map[string]string)}
}
func (v *Validator) Valid() bool { return len(v.Errors) == 0 }
func (v *Validator) AddError(key, message string) {
if _, exists := v.Errors[key]; !exists {
v.Errors[key] = message
}
}
func (v *Validator) Check(ok bool, key, message string) {
if !ok {
v.AddError(key, message)
}
}
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.”
generics — PermittedValue[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.
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:
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 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.
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.
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) {
var input struct {
Title string `json:"title"`
Notes string `json:"notes"`
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
}
if input.Priority == "" {
input.Priority = "none"
}
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
}
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
}
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.
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.
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:
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)
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.
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:
DELETE FROM tasks WHERE id = $1;
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.
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.
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.
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
}
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):
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.
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:
var input struct {
Version *int32 `json:"version"`
}
expected := task.Version
if input.Version != nil {
expected = *input.Version
}
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:
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.
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
curl -d '{"title":""}' localhost:4000/v1/tasks
curl -X PATCH -d '{"status":"done"}' localhost:4000/v1/tasks/1
curl localhost:4000/v1/tasks/999
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) |
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.
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 |
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
- Why does
writeJSON marshal into a byte slice before touching the ResponseWriter, instead of
encoding straight into it?
DisallowUnknownFields turns a client’s typo into a 400. Whose bug was it before that line
existed, and whose bug was it reported as?
- 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?
- A client sends
{"title":""} to POST /v1/tasks. Which status comes back, and why is it not
the other obvious candidate?
GET /v1/tasks/abc returns 404. Trace the two function calls that produce it, and say why the
book prefers 404 to 400 here.
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?
- 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?
- 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
-
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.
-
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.
-
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”.
-
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).
-
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.
-
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.
-
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.
-
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
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)
}
r.Put("/{id}/status", app.setTaskStatusHandler)
Verify:
curl -s -H "Content-Type: application/json" -d '{"title":"status drill"}' \
localhost:4000/v1/tasks
curl -s -X PUT -H "Content-Type: application/json" \
-d '{"status":"done"}' localhost:4000/v1/tasks/2
curl -s -X PUT -H "Content-Type: application/json" \
-d '{"status":"banana"}' localhost:4000/v1/tasks/2
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H "Content-Type: application/json" \
-d '{"status":"done"}' localhost:4000/v1/tasks/999999
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
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:
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"`
}
and use it when supplied:
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:
curl -s -H "Content-Type: application/json" -d '{"title":"lost update"}' \
localhost:4000/v1/tasks
curl -s localhost:4000/v1/tasks/4
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:
- 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.
- 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.
- 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.
- 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.
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 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.
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”.
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.
There are two ways to hand out pages, and they fail in different directions.
Offset pagination — LIMIT 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 pagination — WHERE 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.
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.
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.
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”.
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 filters — status 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.
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.
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”.
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.
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:
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.
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.
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
r.URL.Query() parses the text after the ? into url.Values.
- Two small readers pull typed values out of it, substituting defaults for anything missing.
- Validation happens before any database work — a bad request costs no query.
Filters converts a human page number into the LIMIT and OFFSET SQL wants.
- One query returns both the page and the total.
- 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.
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.
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.
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.
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:
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/yourname/taskd/internal/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.
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.
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.
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.
::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.
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.
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:
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.
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.
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
}
tasks := make([]db.Task, 0, len(rows))
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):
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 rows — range 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.
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:
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)
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.
go build ./cmd/api && echo BUILD-OK
You should see BUILD-OK.
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.
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.
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
}
curl -s 'localhost:4000/v1/tasks?page=2&page_size=20' | jq '.tasks | length'
You should see 5.
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.
curl -s 'localhost:4000/v1/tasks?sort=password' | jq -c '.error'
You should see {"sort":"invalid sort value"}.
curl -s 'localhost:4000/v1/tasks?page_size=10000000' | jq -c '.error'
You should see {"page_size":"must be a maximum of 100"}.
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)
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.
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.
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.
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.
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.
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
- Give one advantage of offset pagination over keyset, and one of keyset over offset.
- What exactly does
count(*) OVER() count — the rows on this page, or something else? And what
does it save you?
- What does the
n in sqlc.narg stand for, and what does it change about the generated Go
struct?
- Why can
ORDER BY $1 never work, in any database, with any library? Answer from what a bind
parameter is.
- Delete the trailing
id ASC from the query. Nothing errors. What goes wrong, and when does the
user notice?
- Suppose
nilIfEmpty were changed to always return &s, even for the empty string. The code
compiles. What does GET /v1/tasks return, and why?
CalculateMetadata(101, 1, 20) — what is LastPage, and why is the + pageSize - 1 there?
- A caller sends
?page_size=10000000. Trace what happens, and say why this is described as a
security control rather than input tidiness.
Answers
-
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.
-
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.
-
“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.
-
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.
-
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.
-
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.
-
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.
-
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:
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,
CASE WHEN sqlc.arg('sort')::text = '-title' THEN title END DESC,
id ASC
LIMIT sqlc.arg('page_limit') OFFSET sqlc.arg('page_offset');
and in cmd/api/tasks.go:
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
docker compose exec db psql -U taskd -d taskd -c "TRUNCATE 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
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
sort -u /tmp/ids.txt | wc -l
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:
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:
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:
dueBefore := app.readTime(qs, "due_before", v)
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'
curl -s 'localhost:4000/v1/tasks' | jq '.metadata.total_records'
curl -s 'localhost:4000/v1/tasks?due_before=nonsense' | jq -c '.error'
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:
- 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.
- Anything that cannot be a bind parameter goes through a safelist. Never interpolate user
input into an
ORDER BY. Ever.
- 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.
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.
- 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.
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 |
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.
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.
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:
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 true,
version integer NOT NULL DEFAULT 1
);
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 bytea — bytea 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:
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: citext → string, bytea → []byte, integer → int32. 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× |
~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.
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.
package data
import (
"errors"
"golang.org/x/crypto/bcrypt"
"github.com/yourname/taskd/internal/validator"
)
type Password struct {
plaintext *string
Hash []byte
}
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
}
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) <= 72 — len 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.
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.
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) }
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.
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).
INSERT INTO users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, created_at, name, email, activated, version;
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.
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:
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.
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
}
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":
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:
r.Route("/v1", func(r chi.Router) {
r.Get("/healthcheck", app.healthcheckHandler)
r.Post("/users", app.registerUserHandler)
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)
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.
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.
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.
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.
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.
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.
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.
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
- Your database is stolen. What, precisely, does the attacker now know about a user’s password?
- Why is SHA-256 the wrong choice for hashing a password, given that it is a respected hash?
- Two users choose the same password. Why do their
password_hash values differ, and what attack
does that defeat?
- Why 72, and why bytes rather than characters?
CreateUser lists its RETURNING columns; GetUserByEmail uses SELECT *. Why?
- A colleague replaces the
errors.As block with a SELECT before the INSERT. Their tests pass.
Name the failure and when it appears.
- What would break if
Password.plaintext were renamed to Plaintext?
Matches returns (false, nil) for a wrong password rather than an error. Why is that right?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
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:
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:
- Passwords are never stored, only hashed. Login decrypts nothing — there is nothing to
decrypt. It re-hashes what was typed and compares.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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":[…]} ────| |
- The password is checked once, at login, and the slow bcrypt comparison happens only there.
- The 26-character plaintext exists in exactly one response body, ever. It is never written to
the database and never logged.
- On every later request the server hashes what the client sent and looks up that. The
plaintext never reaches Postgres.
- 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
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:
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 KEY — bytea 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".
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.
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.
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) {
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,
}
t.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).
EncodeToString(randomBytes)
hash := sha256.Sum256([]byte(t.Plaintext))
t.Hash = hash[:]
return t, nil
}
What this code says, line by line
-
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.
-
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.
-
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.
-
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.
-
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.
-
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 A–Z and 2–7. 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.
-
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.
-
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.
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:
INSERT INTO tokens (hash, user_id, expiry, scope)
VALUES ($1, $2, $3, $4);
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();
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.
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.
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:
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.
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.
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.
Same message (“invalid authentication credentials”) for unknown email and wrong password —
don’t hand attackers an oracle.
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):
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 |
— |
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.
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.
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.
package main
import (
"context"
"net/http"
"github.com/yourname/taskd/internal/db"
)
type contextKey string
const userContextKey = contextKey("user")
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))
}
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
-
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.
-
var AnonymousUser = &db.GetUserForTokenRow{} — one shared, empty user object with a fixed
address in memory. The & makes it a pointer.
-
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”.
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?”
-
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, ...).
-
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.
type assertion — v, 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.
-
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.
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
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.
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Authorization")
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
r = app.contextSetUser(r, AnonymousUser)
next.ServeHTTP(w, r)
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" || len(parts[1]) != 26 {
app.invalidAuthenticationTokenResponse(w, r)
return
}
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
-
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.
-
r.Header.Get("Authorization") — reads the header. Get returns "" when the header is
absent, so the empty string covers “no header at all”.
-
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”.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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.
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
TOKEN=FKF6...
curl -H "Authorization: Bearer $TOKEN" localhost:4000/v1/tasks
curl localhost:4000/v1/tasks
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.
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
- 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.
- 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.
- The third returns
200 with a "tasks" array and a "metadata" object — Chapter 9’s listing
response.
- The fourth returns
401 with
{"error":"you must be authenticated to access this resource"}.
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.
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.
curl -i -s localhost:4000/v1/tasks | head -1
You should see a line beginning HTTP/1.1 401 Unauthorized.
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.)
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- In one sentence, why does this book not use JWTs — and name the one situation where a JWT is
the right answer.
- What does
crypto/rand give you that math/rand does not, and what is the visible symptom if
you use the wrong one?
- Why is the token 26 characters long? Where does that number come from?
- Chapter 10 said SHA-256 is the wrong hash for passwords. This chapter uses SHA-256 for tokens.
Reconcile the two in one rule.
- Why does
t.expiry > now() live in the SQL WHERE clause instead of an if statement in Go?
authenticate lets a request with no Authorization header through. Isn’t that a security
hole? What would break if it returned 401 instead?
- 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?
contextGetUser panics instead of returning an error. Give the argument for that choice, and
say what the reader sees when it fires.
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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”.
-
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:
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
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)
}
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:
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:
- 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.
- A JWT’s strength — no lookup needed — is exactly its weakness: no revocation. Anything that
restores revocation restores the lookup. Choose statefulness on purpose.
- 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.
- Identification and gating are two different middleware.
authenticate tolerates anonymity;
requireAuthenticatedUser refuses it. Separating them is what makes later gates cheap to add.
- 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.
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.
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:
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:
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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:
INSERT INTO tasks (user_id, title, notes, priority, due_at)
VALUES ($1, $2, $3, $4, $5) RETURNING *;
WHERE id = $1 AND user_id = $2 ...
WHERE user_id = sqlc.arg('user_id') AND (sqlc.narg('status') ... )
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.
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.
INSERT INTO tasks (user_id, title, notes, priority, due_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
SELECT * FROM tasks
WHERE id = $1 AND user_id = $2;
DELETE FROM tasks WHERE id = $1 AND user_id = $2;
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 *;
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');
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.
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.
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.
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)
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{
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:
user := app.contextGetUser(r)
task, err := app.q.GetTask(r.Context(), db.GetTaskParams{ID: id, UserID: user.ID})
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.
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,
})
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(),
})
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.
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
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)
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:
curl -s -H "Authorization: Bearer $A" localhost:4000/v1/tasks | jq '.tasks | length'
curl -s -H "Authorization: Bearer $B" localhost:4000/v1/tasks | jq '.tasks | length'
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $B" localhost:4000/v1/tasks/$TASK_A
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
-H "Authorization: Bearer $B" localhost:4000/v1/tasks/$TASK_A
curl -s -H "Authorization: Bearer $A" localhost:4000/v1/tasks | jq '.tasks | length'
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.
-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.
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.
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.
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).
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)
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.
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.
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.
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.
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.
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.
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.
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
- Bob asks for Alice’s task and gets a 404. Which line of Go code decided that?
- Explain “fails closed” using this chapter’s two designs.
- 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?
- Why is
CREATE INDEX idx_tasks_user_id in the same migration as the column, rather than “later,
if it gets slow”?
go build ./... is green. Is tenancy done? Justify your answer.
- 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?
CountActiveTasks is added in this chapter but called by nothing. Why now?
- A colleague proposes returning
403 Forbidden for another user’s task, arguing it is “more
honest”. Give the counter-argument in two sentences.
Answers
-
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.
-
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.
-
(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.
-
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.
-
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.
-
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.
-
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.
-
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
set -euo pipefail
BASE=${BASE:-http://localhost:4000}
reg() {
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:
INSERT INTO tasks (user_id, title)
SELECT (SELECT min(id) FROM users), 'seed ' || g
FROM generate_series(1, 10000) AS g;
ANALYZE tasks;
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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
0–9 and a–f,
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.
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 |
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.)
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.
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.)
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.
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│
└──────────────────┘
- The handler builds a key that identifies this user asking this exact question.
- It asks the cache for that key.
- On a hit the stored bytes are written straight to the client and the handler returns. No SQL
runs, no JSON is built.
- 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.
- 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.
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.
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.
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.
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.
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() }
func (c *Cache) version(ctx context.Context, userID int64) (int64, error) {
return c.rdb.IncrBy(ctx, fmt.Sprintf("u:%d:ver", userID), 0).Result()
}
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
}
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()
}
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 0–9a–f. 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.
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.
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.
"golang.org/x/sync/singleflight"
"github.com/yourname/taskd/internal/cache"
type application struct {
config config
logger *slog.Logger
db *pgxpool.Pool
q *db.Queries
cache *cache.Cache
sfGroup singleflight.Group
}
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.
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)
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")
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)
}
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.
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.
"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.
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.
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)
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
}
}
}
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
}
tasks := make([]db.Task, 0, len(rows))
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
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.
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.
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.
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
curl -sH "Authorization: Bearer $TOKEN" -D- localhost:4000/v1/tasks | grep X-Cache
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
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.
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.
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.
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
- Define cache-aside in one sentence, and say which component does the deciding.
- 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?
- 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.
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?
- What does
IncrBy(ctx, "u:42:ver", 0) do, and why is it better here than a GET?
ReadTimeout is 200 ms while DialTimeout is 2 s. Explain the difference in reasoning.
- 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?
- 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
-
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.
-
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.
-
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”.
-
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.
-
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.
-
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.
-
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.
-
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
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
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() {
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"
}
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
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.
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.
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:
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:
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.
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:
- 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.
- 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.
- 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”.
- 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.
- 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.
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.
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.
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:
- A per-IP, in-memory token bucket protecting the unauthenticated endpoints (login, register)
from brute force.
- 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.
“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:
- Record a
lastSeen timestamp alongside each client’s bucket.
- Run a background loop — a janitor — that wakes up periodically and deletes entries nobody
has used in a while.
- 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.
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:
- The bucket starts full: six tokens, so the first six requests all go through immediately.
- Request seven arrives a millisecond later. The bucket is empty. It is refused.
- Half a second later there is one token again (two per second), so one request goes through.
- A caller making two requests a second, forever, never sees a refusal. That is the steady rate.
- 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
- The three global middlewares run for everything, including the healthcheck. They catch panics,
log, and work out who you are without gating anybody.
/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.
- 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.
- 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.
package main
import "fmt"
func main() {
counters := make(map[string]int)
done := make(chan bool)
for i := 0; i < 4; i++ {
go func() {
for j := 0; j < 10000; j++ {
counters["hits"]++
}
done <- true
}()
}
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.
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.
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:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
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()
counters["hits"]++
mu.Unlock()
}
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.
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.
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:
"net"
"sync"
"golang.org/x/time/rate"
Now the middleware itself. Take it slowly — the shape is unusual, and the shape is the lesson.
func (app *application) rateLimitIP(next http.Handler) http.Handler {
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
var (
mu sync.Mutex
clients = make(map[string]*client)
)
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)}
}
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.
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.Minute — time.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.
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.
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.
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.
func (app *application) routes() http.Handler {
r := chi.NewRouter()
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.Group(func(r chi.Router) {
r.Use(app.rateLimitIP)
r.Post("/users", app.registerUserHandler)
r.Post("/tokens/authentication", app.createAuthTokenHandler)
})
r.Group(func(r chi.Router) {
r.Use(app.requireAuthenticatedUser)
r.Use(app.rateLimitUser)
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.
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.
package cache
import (
"context"
"fmt"
"time"
)
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
}
if n == 1 {
c.rdb.Expire(ctx, bucket, window+time.Second)
}
return n <= int64(limit), nil
}
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.
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:
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
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:
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:
limit := 3
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.
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:
limit := 60
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
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.
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
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?
- 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.
- 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?
- Why is
allowed := clients[ip].limiter.Allow() captured into a variable, with the if !allowed
check placed after mu.Unlock() rather than before it?
- 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.
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.
- 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.
/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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
set -euo pipefail
HOST=${HOST:-localhost:4000}
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
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:
if _, ok := clients[ip]; !ok {
clients[ip] = &client{limiter: rate.NewLimiter(2, 6)}
}
clients[ip].lastSeen = time.Now()
allowed := clients[ip].limiter.Allow()
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
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
`)
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
}
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:
- 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.
- A Go map written by two goroutines at once kills the process —
fatal 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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
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:
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:
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.
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:
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.
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.
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:
- Product and Price are things you create once in Stripe’s dashboard. We never store them; we
store only the price ids, in config.
- Customer is created by our code, lazily, on the first upgrade click.
- Key 1 is
users.stripe_customer_id — our row pointing at their object.
- 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.
- 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.
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:
- Go to Products, add a product named
taskd Pro.
- Give it a recurring price of
$9, billed monthly. “Recurring” is the word that makes it a
subscription rather than a one-off charge.
- 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.
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_.
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.
[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.
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.
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
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.
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.
package data
type Tier string
const (
TierFree Tier = "free"
TierPro Tier = "pro"
TierBusiness Tier = "business"
)
type Entitlements struct {
MaxActiveTasks int
RatePerMinute int
Priorities bool
Search bool
}
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".
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.
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.
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.
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.
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.
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.
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.
UPDATE users SET stripe_customer_id = $2 WHERE id = $1;
SELECT * FROM users WHERE stripe_customer_id = $1;
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();
SELECT * FROM subscriptions WHERE user_id = $1;
DELETE FROM subscriptions WHERE user_id = $1;
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.
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”.
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.
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:
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 "".
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.
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.
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"
)
func (app *application) ensureStripeCustomer(r *http.Request,
user *db.GetUserForTokenRow) (string, error) {
full, err := app.q.GetUserByEmail(r.Context(), user.Email)
if err != nil {
return "", err
}
if full.StripeCustomerID != nil {
return *full.StripeCustomerID, nil
}
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.
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:
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
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(custID),
LineItems: []*stripe.CheckoutSessionLineItemParams{{
Price: stripe.String(priceID), Quantity: stripe.Int64(1),
}},
SuccessURL: stripe.String(app.config.stripe.successURL),
CancelURL: stripe.String(app.config.stripe.cancelURL),
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.
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.
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:
cfg, err := loadConfig(*configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
logger := newLogger(cfg)
stripe.Key = cfg.stripe.secretKey
if stripe.Key == "" {
logger.Warn("stripe.secret_key is empty; billing endpoints will fail")
}
pool, err := openDB(cfg)
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.
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.
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.
r.Route("/billing", func(r chi.Router) {
r.Post("/checkout", app.createCheckoutHandler)
})
In context, the authenticated group now looks like this:
r.Group(func(r chi.Router) {
r.Use(app.requireAuthenticatedUser)
r.Use(app.rateLimitUser)
r.Route("/tasks", func(r chi.Router) {
})
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.
Later chapters add more billing routes, and they belong inside this same sub-router —
r.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
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
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
SELECT * FROM subscriptions;
What you should see: the six column headings, then (0 rows).
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.
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.
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.
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.
ls internal/db/billing.sql.go
go build ./... && echo BILLING-BUILDS
You should see the path printed, then BILLING-BUILDS.
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.
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.
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)
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.
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.
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.
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.
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.
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
- Why does Stripe host the payment page rather than us? Give the answer in terms of what lands on
our servers.
- A colleague suggests creating the Stripe Customer during registration, “so it’s ready”. Give two
concrete costs of that.
- Why is the Free tier the absence of a subscription rather than a $0 Stripe subscription?
- Plan definitions live in code; price ids live in config. What is the rule that separates them?
- 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.
- You complete a test payment and
SELECT * FROM subscriptions returns (0 rows). Is this a bug?
What exactly is missing?
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?
- Read this line and say what breaks:
params := &stripe.CheckoutSessionParams{Customer: custID}
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
const (
TierFree Tier = "free"
TierPro Tier = "pro"
TierTeam Tier = "team"
TierBusiness Tier = "business"
)
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:
[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
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
-d '{"tier":"pro"}' localhost:4000/v1/billing/checkout
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
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:
- 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.
- 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.
- 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.
- Things that differ between environments are config; things that differ between releases are
code. Price ids are config. Plan definitions are code.
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:
- Prove the sender knew a secret only Stripe and we know.
- Do nothing the second time the same message arrives.
- 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.
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.
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.
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.
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:
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.
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.
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 |
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:
- Something happens inside Stripe. Stripe builds an event object and signs it.
- 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.
- The handler reads the body as bytes, capped, and checks the signature. A failure ends here.
- The event ID goes into the ledger. If it was already there, we are done; reply 200.
- 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.
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) {
payload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 65536))
if err != nil {
app.badRequestResponse(w, r, err)
return
}
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
}
rows, err := app.q.InsertStripeEvent(r.Context(), event.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if rows == 0 {
w.WriteHeader(http.StatusOK)
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
}
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)
}
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.Type — event.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:
package main
import "net/http"
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.
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.
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?
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
}
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)
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.
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.
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.
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
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:
[stripe]
secret_key = "sk_test_..."
webhook_secret = "whsec_..."
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.
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.
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
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.
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),
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:
portalsession "github.com/stripe/stripe-go/v78/billingportal/session"
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.
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):
- Register the real endpoint URL (
https://yourdomain/v1/stripe/webhook) in the Stripe dashboard
and select the four event types from section 4.4.
- Copy that endpoint’s signing secret — a different
whsec_ from the CLI’s — and supply it as
TASKD_STRIPE__WEBHOOK_SECRET.
- 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
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:
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.
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
- In one sentence, what is a webhook, and which direction does the call go?
- What does a valid HMAC signature prove? Name one thing it does not prove, and what Stripe adds
to cover it.
- Why can this handler not use
readJSON, when every other handler in taskd does?
InsertStripeEvent returns 1. Then the same event is delivered again. What does it return, and
what does the handler do next?
- 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?
- You return
500 to Stripe for an event type you do not recognise. Describe what happens over the
next week.
- 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?
- Give both reasons the webhook route sits outside the authenticated group and outside the per-IP
rate limiter.
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
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:
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.
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:
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:
SELECT u.id FROM users u
LEFT JOIN subscriptions s ON s.user_id = u.id
WHERE s.user_id IS NULL
LIMIT 1;
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:
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:
- 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.
- 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.
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.
- 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.
- 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:
if tier == "pro" || tier == "business" {
}
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.
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.
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.
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.
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 |
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.
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.
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) └───────────────┘
- Four call sites ask the same question and get the same
Entitlements struct back.
- The resolver tries Dragonfly first, because it will be asked on nearly every request.
- On a miss it asks Postgres for the subscription row and applies the tier-resolution rule.
- 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.
func (c *Cache) Delete(ctx context.Context, key string) {
_ = c.rdb.Del(ctx, key).Err()
}
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.
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
}
}
}
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)
}
}
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))
}
}
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) == nil — Unmarshal 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.
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.
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).
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:
limit := 60
Replace it with two lines:
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:
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.
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):
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:
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.
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 *.
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.
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.
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
func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Notes string `json:"notes"`
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
}
if input.Priority == "" {
input.Priority = "none"
}
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)
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
}
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
}
}
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)
}
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
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
}
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
}
}
}
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
}
tasks := make([]db.Task, 0, len(rows))
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.
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.
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.
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:
r.Get("/plan", app.showPlanHandler)
In context, the billing sub-router now reads:
r.Route("/billing", func(r chi.Router) {
r.Post("/checkout", app.createCheckoutHandler)
r.Post("/portal", app.createPortalHandler)
r.Get("/plan", app.showPlanHandler)
})
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.
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
- In one sentence each: what is a tier, and what is an entitlement? Why does taskd need both
words?
- Name the three subscription statuses that grant a paid tier. Why is
past_due among them, and
what eventually removes a user from it?
- The resolver caches for 60 seconds and the webhook deletes the key. Why is either one alone not
good enough?
- 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?
- 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?
- Why
402 rather than 403? Answer in terms of what a client program should do next.
- Chapter 14 left
limit := 60 in rateLimitUser. What replaced it, and how many other lines of
that function changed?
- 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
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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:
type Entitlements struct {
MaxActiveTasks int
RatePerMinute int
Priorities bool
Search bool
Webhooks bool
}
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.
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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 │ │
│ └────────────────────┘ │ │ └─────────────────────┘ │
└──────────────────────────┘ └───────────────────────────┘
- Your handlers update instruments in memory. That is all they do — no network, no I/O.
- Every 15 seconds Prometheus makes an ordinary HTTP GET to
/metrics.
- The client library walks its registry and renders every current value as text.
- Prometheus stores the values with a timestamp, and stores
up=1 because the scrape worked.
- 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.
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"})
)
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 convention —
taskd_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.
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.
func (app *application) metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpRequestsInFlight.Inc()
defer httpRequestsInFlight.Dec()
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
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.
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:
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
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.
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)
}
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()))
}
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.
defer pool.Close()
logger.Info("database connection pool established")
prometheus.MustRegister(newPoolStatsCollector(pool))
Add the import:
"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.
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.
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
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().ServeHTTP — metricsHandler() 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.
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.
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.
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()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
w.Write(b)
return
}
}
}
js := body.([]byte)
if app.cache != nil && key != "" {
app.cache.Set(r.Context(), key, js, 60*time.Second)
}
cacheOps.WithLabelValues("miss").Inc()
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.
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.
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.
scrape_configs:
- job_name: taskd
scrape_interval: 15s
static_configs:
- targets: ["host.docker.internal:4000"]
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.
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.
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.
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
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.
go build ./... && echo METRICS-BUILDS
You should see METRICS-BUILDS.
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.
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'
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.
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)
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.
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.
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.
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.
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
- 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?
- Why does this book skip Prometheus summaries?
- 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.
- Define label cardinality in one sentence, then name one label that would destroy this setup and
estimate the series count it would create.
route is a label and its values are URLs. Why is that safe here, when §4.5 says paths are
fatal?
- 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?
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?
- Prometheus shows
up == 0 for taskd while curl localhost:4000/v1/healthcheck from your laptop
returns 200. Name two possible causes.
Answers
-
(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.
-
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.
-
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.
-
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.)
-
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.
-
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.
-
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.
-
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:
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:
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()
Verify:
go build ./... && make run/api
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:
}, []string{"method", "route", "status", "path"})
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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 0–9 and a–f. 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.
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:
- 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.
- 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.
- 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.
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.
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.
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
- The middleware looks for an inbound
X-Request-ID header.
- If there isn’t one, it makes six random bytes and turns them into twelve hex characters.
- It writes the ID back as a response header — this is the part the customer sees.
- 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.
- 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.
const requestIDKey = contextKey("request_id")
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.
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.
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
chimw "github.com/go-chi/chi/v5/middleware"
)
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.
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.
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.
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)
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))
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.
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 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:
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:
func (app *application) logError(r *http.Request, err error) {
app.logger.Error(err.Error(),
"method", r.Method,
"path", r.URL.Path,
"id", app.requestID(r.Context()),
)
}
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.
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.
- 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.
- 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.
- 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:
next.ServeHTTP(ww, r.WithContext(ctx))
level := slog.LevelInfo
switch r.URL.Path {
case "/v1/healthcheck", "/metrics":
level = slog.LevelDebug
}
app.logger.LogAttrs(ctx, level, "request",
)
Because a three-line insertion into the middle of a function is exactly the change beginners get
wrong, here is the whole function afterwards:
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)
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))
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.
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
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.
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"}
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:
jq 'select(.id == "a450dfbc1f27")'
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. |
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.
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.
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
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:
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.
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
- Why is the
request log line written after next.ServeHTTP rather than before it?
- You move
w.Header().Set("X-Request-ID", reqID) to the line after next.ServeHTTP. The code
compiles. What breaks, and why?
slog.Int("bytes", ww.BytesWritten()) — bytes of what, exactly?
- A colleague changes
crypto/rand to math/rand because “it’s faster and the tests pass”. Give
two concrete reasons to change it back.
- 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.
- Production runs the JSON handler and development the text handler. Why not use JSON in both and
have one format to learn?
- What does
app.logger.Enabled(ctx, slog.LevelDebug) protect you from, given that Debug lines
are dropped at info anyway?
- This snippet compiles and runs. What appears in the log, and why?
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
-
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.
-
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.
-
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.
-
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.
-
(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.
-
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.
-
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.
-
"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
jq 'select(.status >= 500)' taskd.log
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.
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
}
ctx := context.WithValue(r.Context(), requestIDKey, reqID)
lf := &logFields{}
ctx = context.WithValue(ctx, logFieldsKey, lf)
app.logger.LogAttrs(ctx, level, "request",
slog.Int64("user_id", lf.userID),
)
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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?
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.
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 |
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.
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 |
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.
“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:
- Test code lives in files ending
_test.go. The compiler excludes them from your real binary,
so test code never ships to production.
- 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.
- 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.
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.
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.
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 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.
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.
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.
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)
_, 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:
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.
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 = dsn — config 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.
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.
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.
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.
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 + path — httptest 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”:
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.
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.
func TestTaskLifecycle(t *testing.T) {
app := newTestApplication(t)
srv := httptest.NewServer(app.routes())
defer srv.Close()
token := registerAndLogin(t, srv, "a@example.com")
res := doJSON(t, srv, "POST", "/v1/tasks", token,
`{"title":"integration test"}`)
assertStatus(t, res, http.StatusCreated)
token2 := registerAndLogin(t, srv, "b@example.com")
res = doJSON(t, srv, "GET", "/v1/tasks", token2, "")
assertBodyContains(t, res, `"tasks":[]`)
res = doJSON(t, srv, "GET", "/v1/tasks/1", token2, "")
assertStatus(t, res, http.StatusNotFound)
}
With the file header:
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
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 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.
.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.
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.
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.
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
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)
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.
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.
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.
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.
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.
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.
var bcryptCost = 12
func (p *Password) Set(plaintext string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcryptCost)
}
func TestMain(m *testing.M) {
bcryptCost = bcrypt.MinCost
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.
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.
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.
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
- You have thirty minutes and no tests. Which three things do you test first, and why those?
- 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.
- What does
httptest.NewServer(app.routes()) exercise that calling app.createTaskHandler(w, r)
directly does not?
- What exactly does
t.Helper() change? What would you notice if you deleted it from doJSON?
- 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?
- Why can the assertion
assertBodyContains(t, res, “tasks”: []) never pass, and what is the
general lesson?
-race found nothing. Name two different reasons that might be true, only one of which is good
news.
- Your test suite reports 100% coverage. Name two serious bugs that could still be in the code.
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
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)
}
before, err := app.q.GetTask(ctx, db.GetTaskParams{ID: 1, UserID: user.ID})
if err != nil {
t.Fatal(err)
}
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)
}
_, 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
package main
import (
"net/http/httptest"
"strings"
"testing"
)
func TestReadJSONTriage(t *testing.T) {
app := &application{}
tests := []struct {
name string
body string
want string
}{
{"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 == "":
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
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")
res = doJSON(t, srv, "PATCH", "/v1/tasks/1", tokenB, `{"title":"stolen"}`)
assertStatus(t, res, http.StatusNotFound)
res = doJSON(t, srv, "DELETE", "/v1/tasks/1", tokenB, "")
assertStatus(t, res, http.StatusNotFound)
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:
- 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.
- 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.
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.
- 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.
- 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
403 → 200 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).
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.
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:
- Signup got slow. The person who clicked “Create account” is looking at a spinner for as long
as the mail relay feels like taking.
- 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.
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.
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 goroutine — go 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. |
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.
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.
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.
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.
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.
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│ │
│◀────────────────────────┤ │ │
- Registration writes two rows and answers
202 Accepted — “taken, still processing”.
- The email is sent by a separate goroutine that outlives the request.
- The token in that email is the credential. Presenting it flips
activated to true.
- 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:
ALTER TABLE users ALTER COLUMN activated SET DEFAULT false;
CREATE INDEX idx_tokens_user_id ON tokens (user_id);
What this code says, line by line
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.
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.
- “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:
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 see — migrate 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.
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.
mailpit:
image: axllent/mailpit
ports:
- "8025:8025"
- "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.
[smtp]
host = "localhost"
port = 1025
username = ""
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:
smtp struct {
host string
port int
username, password, sender string
}
Then the loader lines. At the bottom of loadConfig, after the cfg.stripe.* assignments:
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
- The nested anonymous struct —
smtp 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.
username, password, sender string — three fields of the same type declared on one line.
Identical to writing them on three lines.
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.
- 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.
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.
package mailer
import (
"bytes"
"embed"
"html/template"
"time"
mail "github.com/wneessen/go-mail"
)
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))
}
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)
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.
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.
//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.
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.
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.
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 <script>. That protection is why the import is html/template and not
text/template.
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.
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
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.
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.
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”.
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.
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.
- 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.
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.
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.
type application struct {
config config
logger *slog.Logger
db *pgxpool.Pool
q *db.Queries
cache *cache.Cache
sfGroup singleflight.Group
wg sync.WaitGroup
mailer *mailer.Mailer
}
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)
}
app := &application{
config: cfg, logger: logger, db: pool,
q: db.New(pool), cache: appCache, mailer: m,
}
go app.janitor()
What this code says, line by line
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.
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.
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.
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.
- 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:
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.
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:
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
- 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.
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.
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.
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
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.
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.
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.
- 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”.
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.
http.StatusAccepted — 202, not 201. Precise HTTP for “created, processing continues”.
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.
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:
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.
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
}
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
}
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
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.
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.
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.
- 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.
_ = 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.
- 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.
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.
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:
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{"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:
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.
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
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.
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.
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.
- 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.
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.
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.
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.
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.
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:
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)
return
}
next.ServeHTTP(w, r)
})
}
What this code says
- 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.
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.
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:
r.Post("/users", app.registerUserHandler)
r.Post("/tokens/authentication", app.createAuthTokenHandler)
r.Post("/tokens/activation", app.createActivationTokenHandler)
r.Put("/users/activated", app.activateUserHandler)
r.Group(func(r chi.Router) {
r.Use(app.requireAuthenticatedUser)
r.Group(func(r chi.Router) {
r.Use(app.requireActivatedUser)
})
})
What this code says
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.
- 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.
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.
- “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.
- 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.
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:
DELETE FROM tokens WHERE expiry < now();
DELETE FROM stripe_events WHERE received_at < now() - interval '30 days';
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:
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
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.
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.
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.
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.
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.
- 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.
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.
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:
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:
token := registerAndLogin(t, app, srv, "a@example.com")
...
token2 := registerAndLogin(t, app, srv, "b@example.com")
What this code says
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.
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.
- The helper now takes
*application because that is where the pool lives. This is the whole
reason the signature changed.
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.
That 403 → 200 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)
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.
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.
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.
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().
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.
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
- Why can’t
registerUserHandler send the activation email itself and then return?
- Name the four rungs of the background-work ladder, and the one-sentence criterion for jumping to
the top one.
- What exactly does
app.wg.Add(1) count, and what would go wrong if it were called inside the
goroutine instead of before it?
background() has two deferred functions. What disaster does each one prevent, and why is their
registration order load-bearing?
- Registration returns
202 instead of 201. What is the difference, in one sentence each?
- Why is the janitor started with
go app.janitor() rather than app.background(app.janitor)?
- 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?
- Existing users in the database stay activated after migration
000006. Why does that require no
UPDATE statement?
Answers
-
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.
-
(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).
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
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:
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
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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 |
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:
- The new address goes into a separate column,
pending_email. The live email is untouched.
- A confirmation token goes to the new address.
- 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.
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.
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:
- 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.
- 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.
- 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:
ALTER TABLE users ADD COLUMN pending_email citext;
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 citext — citext 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.
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.
SELECT * FROM users WHERE id = $1;
UPDATE users SET password_hash = $2, version = version + 1 WHERE id = $1;
UPDATE users SET email = $2, version = version + 1
WHERE id = $1
RETURNING id, created_at, name, email, activated, version;
UPDATE users SET pending_email = $2 WHERE id = $1;
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;
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.
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.
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.
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.
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.
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
}
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.
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
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.
“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
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 {
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
}
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.
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 { ... }.
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.
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":
app.errorResponse(w, r, http.StatusConflict,
"that email address is now in use; start the change again")
case errors.Is(err, pgx.ErrNoRows):
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.
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
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
}
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)
return
}
}
if user.StripeCustomerID != nil {
if _, err := customer.Del(*user.StripeCustomerID, nil); err != nil {
app.logger.Warn("stripe customer delete", "error", err)
}
}
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.
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.
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 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.
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.
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(app.rateLimitIP)
r.Post("/tokens/password-reset", app.createPasswordResetTokenHandler)
r.Put("/users/password", app.resetPasswordHandler)
r.Put("/users/email", app.confirmEmailChangeHandler)
})
r.Group(func(r chi.Router) {
r.Use(app.requireAuthenticatedUser)
r.Put("/me/password", app.changePasswordHandler)
r.Put("/me/email", app.changeEmailHandler)
r.Delete("/me", app.deleteAccountHandler)
r.Group(func(r chi.Router) {
r.Use(app.requireActivatedUser)
})
})
})
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.
case "customer.subscription.deleted":
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.
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.
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
- Why is the password-reset TTL 45 minutes when the activation TTL is 72 hours?
- A reset succeeds. Which rows disappear from the
tokens table, and why does it matter that it
is more than one scope?
- 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?
- Why does the email-change flow need a
pending_email column at all? What exactly could an
attacker do without it?
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?
- 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.
- What does
isStripeMissing returning true allow the caller to do that it otherwise could
not?
- 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
-
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.
-
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.
-
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.
-
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.
-
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”.
-
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.
-
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.
-
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
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
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:
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:
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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:
- a security-headers middleware;
- CORS with an explicit origin allow-list — config-driven, off by default;
- 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
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.
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. |
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.
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 │
└─┴─┴─┴──────────────────────────────────────────────────┘
secureHeaders sets three headers and calls the next layer. It cannot fail and cannot refuse.
- 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.
- Everything from
recoverPanic down is the stack you already had.
idempotent is not in the global chain. It wraps two specific routes, inside the rings that have
already established who the user is.
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.
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.
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
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.
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.
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.
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.
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
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:
[cors]
trusted_origins = []
Then the struct field, next to the smtp block Chapter 21 added:
cors struct{ trustedOrigins []string }
Then the loader line, at the end of loadConfig:
cfg.cors.trustedOrigins = k.Strings("cors.trusted_origins")
What this code says, line by line
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.
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.
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.
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
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
if len(...) > 0 — len 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.
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.
AllowedOrigins — the allow-list, straight from config. Never * (see Pitfalls).
AllowedMethods — the HTTP verbs a browser may use cross-origin. OPTIONS is in the list
because the preflight is itself an OPTIONS request.
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.
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.
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.
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:
[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.
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.
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.
func (c *Cache) SetNX(ctx context.Context, key string, ttl time.Duration) (bool, error) {
return c.rdb.SetNX(ctx, key, 1, ttl).Result()
}
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
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.
.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.
- 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.
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.
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)
return
}
user := app.contextGetUser(r)
storeKey := fmt.Sprintf("idem:%d:%s:%s", user.ID, r.URL.Path, key)
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
}
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 {
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:
"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.
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
}
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.
- The storage format is a status code, one space, then the body:
201 {"task":{...}}. Human
readable, no encoding library, and inspectable from redis-cli.
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.
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?”
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.
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.
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.
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 {
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")
ww.Status() — the status code the handler used, which the wrapper recorded on the way past.
< 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.
strconv.Itoa(ww.Status()) + " " — the integer as text, plus the delimiter space.
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.
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.
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.
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.
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:
r.Route("/tasks", func(r chi.Router) {
r.With(app.idempotent).Post("/", app.createTaskHandler)
r.Get("/", app.listTasksHandler)
r.Get("/{id}", app.showTaskHandler)
r.Patch("/{id}", app.updateTaskHandler)
r.Delete("/{id}", app.deleteTaskHandler)
})
r.Route("/billing", func(r chi.Router) {
r.With(app.idempotent).Post("/checkout", app.createCheckoutHandler)
r.Post("/portal", app.createPortalHandler)
r.Get("/plan", app.showPlanHandler)
})
What this code says
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.
- The listing and read routes are untouched. A
GET is naturally repeatable; wrapping it would
spend a cache round trip to protect nothing.
- 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.
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
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.
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.
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
- What does
X-Content-Type-Options: nosniff prevent, and why does it matter for an API that only
ever returns JSON?
- 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.
- Why does a CORS preflight carry no
Authorization header, and what breaks if the CORS middleware
is mounted below the authentication middleware?
- The idempotency middleware returns immediately if the header is absent. Name two things that
design buys, and one thing it gives up.
storeKey is idem:<userID>:<path>:<key>. For each of the three parts, say what goes wrong if
you remove it.
- Why are 5xx responses not stored while 4xx responses are? Answer from the client’s point of view.
- Two requests with the same key arrive one millisecond apart. Trace what each one receives, and
name the mechanism that decides which is which.
SetNX returns (false, nil) and (false, someError) in two different situations. What does
each mean, and why does the code treat them differently?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
(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 "/":
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
set -euo pipefail
: "${TOKEN:?set TOKEN first}"
K="race-$RANDOM"
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.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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:
- A documentation website renders itself from your file.
- Other people generate typed client code in their language from your file.
- 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.
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.
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.
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.
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.
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.
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.
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.
/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}
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.
/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.
$ref — OpenAPI’s pointer. $ref: "#/components/schemas/Task" means “the definition living
at components → schemas → Task in this file”. It is the spec’s version of not repeating
yourself.
Step 4 — /v1/tasks/{id}: path templates and shared parameters
/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.
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.
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.
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.
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.
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.
package main
import (
"embed"
"net/http"
)
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.
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.
r.Get("/docs", app.docsPageHandler)
r.Route("/v1", func(r chi.Router) {
r.Get("/healthcheck", app.healthcheckHandler)
r.Get("/openapi.yaml", app.openapiHandler)
})
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.
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.
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.
package main
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
var undocumented = map[string]bool{
"/metrics": true, "/docs": true, "/v1/openapi.yaml": true,
"/v1/stripe/webhook": true,
}
func TestEveryRouteIsDocumented(t *testing.T) {
spec, err := docsFS.ReadFile("docs/openapi.yaml")
if err != nil {
t.Fatal(err)
}
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 == "" {
return nil
}
needle := fmt.Sprintf("\n %s:", route)
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.
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.
curl -s localhost:4000/v1/openapi.yaml | head -1
curl -s -o /dev/null -D - localhost:4000/docs | grep -i '^content-type'
go test ./cmd/api -run TestEveryRouteIsDocumented
make test
What you should see
openapi: 3.1.0
- a line containing
text/html; charset=utf-8
- an
ok line for github.com/yourname/taskd/cmd/api
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)
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.
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.
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.
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.
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
- In one sentence, what is an OpenAPI file?
- Give one genuine advantage of code-first generation, and one genuine advantage of spec-first
authoring.
TestEveryRouteIsDocumented is green. Name two kinds of documentation error it cannot detect.
- Why does the test read the spec through
docsFS rather than opening
cmd/api/docs/openapi.yaml from disk?
- Four routes are in the
undocumented map. Which are they, and what do the reasons have in
common?
- What does
$ref: "#/components/schemas/Task" mean, and what would break if you deleted the
Task entry under components?
- The needle is
fmt.Sprintf("\n %s:", route). Why the leading newline and two spaces, and why
the trailing colon?
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
-
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.
-
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.
-
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.
-
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.
-
/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.
-
“The definition at components → schemas → Task 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.
-
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}:.
-
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):
/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
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.
package main
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
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 == "" {
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 == "" {
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
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:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ...
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.
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.
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.
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.
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
- Stage 1 downloads modules and compiles. It is heavy and it is temporary.
COPY --from=build names the only thing allowed to cross the gap.
- Stage 2 starts from an image that has never heard of Go.
- 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 │
└─────────────────────────────────────────────┘
db is not “started”, it is healthy — Postgres has answered pg_isready.
migrate runs only then, applies the migrations, and exits.
api waits for db healthy, cache healthy, and migrate exited with status 0.
- 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:
const version = "0.1.0"
Put this in its place:
var version = "dev"
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.
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 Makefile — GIT_DESC
goes near the top with the other variables, the target goes at the bottom with the others.
GIT_DESC = $(shell git describe --always --dirty --tags 2>/dev/null || echo dev)
.PHONY: build/api
build/api:
CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=${GIT_DESC}' \
-o bin/api ./cmd/api
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.
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.
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /bin/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /bin/api /bin/api
COPY config.toml /config.toml
EXPOSE 4000
USER nonroot
ENTRYPOINT ["/bin/api", "-config", "/config.toml"]
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.
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.
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.
“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
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:dev — tag 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.
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.
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.
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:
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.
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:
.PHONY: docker/build
docker/build:
docker build -t taskd:${GIT_DESC} --build-arg VERSION=${GIT_DESC} .
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.
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.
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.
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.
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.
ports:
- "5432:5432"
ports:
- "6379:6379"
- "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.
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.
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.
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:
- Docker builds the
api image, printing one line per Dockerfile instruction.
db and cache start; for a few seconds nothing else happens while their healthchecks run.
migrate starts, prints either a line per applied migration or no change, and exits.
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
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.)
- 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.
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 |
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.
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
- What exactly does a multi-stage build save, and where does the saving show up?
- Why does
COPY go.mod go.sum ./ come before COPY . . and not after?
- You change one line in
internal/data/tasks.go and rebuild. Which Dockerfile instructions
re-run?
- What does
CGO_ENABLED=0 buy, and which error tells you that you forgot it?
USER nonroot — what class of problem does it reduce, and what does it not prevent?
- Why does the app not run its own migrations at boot?
depends_on: migrate: condition: service_completed_successfully — what exactly is Compose
waiting for?
- Why is
stop_grace_period set to 35 seconds rather than 30, and what would go wrong at 10?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
- Build once so the cache is warm.
- Change one line in a Go file (a comment will do), rebuild, and time it.
- Swap
COPY . . above COPY go.mod go.sum ./ and RUN go mod download, change another line,
rebuild, and time that.
- Put the Dockerfile back.
Solution
docker build -t taskd:dev .
echo "// cache experiment" >> cmd/api/healthcheck.go
time docker build -t taskd:dev .
Now edit the Dockerfile so stage 1 reads:
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 .
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
make docker/build
docker compose up -d db cache
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
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. |
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.
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.
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. |
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.
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.
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”.
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:
- 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.
- The six gates run in order on one throwaway machine. The first failure stops the job.
- 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.
- A push to
main — which is what a merge is — additionally starts release.yml.
build-push builds the image from Chapter 25’s Dockerfile and pushes it to GHCR under two names.
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.
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.
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):
.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 ./...
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.
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.
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.
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.
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
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.
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.
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 .
gofmt -w .
Then clean up the deliberate mess:
rm cmd/api/spare.go
make audit
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.
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
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.
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.
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:
image: ghcr.io/yourname/taskd:${TAG:-latest}
ports:
- "4000:4000"
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
volumes:
db-data:
What changed, and why
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.
${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.
- 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.
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.
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.
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:
sudo adduser --disabled-password --gecos "" deploy
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).
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:
sudo mkdir -p /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys
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:
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:
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:
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. |
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:
- 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.
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:
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.
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.
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
- 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?
- 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?
sqlc diff fails in CI on a branch where nobody touched any Go file. What did the author do, and
what is the fix?
- The test job would still be green if you deleted the
env: TASKD_TEST_DSN: ... block from the
last step. Why is that dangerous?
- 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?
- What exactly does
needs: build-push prevent, given that both jobs are in the same file?
- 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.
- What does
environment: production do on its own, and what does it enable?
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
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
printf 'package main\n\nfunc spare( ) {}\n' > cmd/api/spare.go
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 .
gofmt -w .
gofmt -l .
rm cmd/api/spare.go
make audit
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
git status
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:
.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:
- 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:
- 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.
- 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.
- 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.
- 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.
- 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".
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.
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.
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:
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.
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:
- The browser resolves
api.yourdomain.com to your server’s IP through DNS, and opens a TLS
connection to port 443.
- 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.
- Caddy decides what to do with the path.
/metrics gets a 404 and goes no further. Everything
else is forwarded to api:4000 — api is the Compose service name, which Docker’s internal
DNS resolves to the container.
- taskd handles the request exactly as it always has. It does not know or care that TLS happened.
- Postgres, Dragonfly and Prometheus are reachable only from inside this private network. Nothing
on the internet can open a connection to any of them.
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.
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.
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.
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
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.
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.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: taskd
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: taskd
volumes:
- db-data:/var/lib/postgresql/data
command:
- "postgres"
- "-c"
- "shared_buffers=1GB"
- "-c"
- "max_connections=100"
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:
image: ghcr.io/yourname/taskd:${TAG:-latest}
env_file: .env
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
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- 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
ports:
- "127.0.0.1:9090:9090"
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. |
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/
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:
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.
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()
r.Use(chimw.RealIP)
r.Use(secureHeaders)
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.
RealIP writes a bare IP address into RemoteAddr — 203.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.
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.
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
nano /opt/taskd/.env
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.
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:
- Create the new credential alongside the old one (Stripe, your mail provider and most services
allow several active keys).
- Put it in
/opt/taskd/.env.
docker compose up -d api — Compose recreates only the container whose configuration changed.
- 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.
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.
- 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.
- 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.
- 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.
- 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.
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.
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
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
docker compose exec -T db \
pg_dump -U taskd -d taskd -Fc --no-owner --no-privileges > "$OUT.part"
mv "$OUT.part" "$OUT"
rsync -a "$DEST/" backups@your-storage-host:taskd/
find "$DEST" -name 'taskd-*.dump' -mtime +14 -delete
curl -fsS -m 10 --retry 3 "$HEALTHCHECK_URL" > /dev/null
What this script says, line by line
set -euo pipefail — e: 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
set -euo pipefail
cd /opt/taskd
LATEST=$(ls -1t /opt/taskd/backups/taskd-*.dump | head -1)
echo "restoring $LATEST"
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"
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.
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.
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.
groups:
- name: taskd
interval: 30s
rules:
- 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"
- 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"
- 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"
- 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"
- 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.
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.
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
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. |
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
- 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?
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?
- Why is
handle /metrics { respond 404 } in the Caddyfile rather than a change to routes.go —
and does Prometheus still get its data?
- Name the five alerts, and for each one say what you would do first at 3 a.m.
- Why does Dragonfly need no backup? Name the chapter whose policy made that true, and the exact
phrase in the code that encodes it.
- 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.
- 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?
- Which drill verifies which chapter’s promise? Match: kill the cache / kill the database / deploy
under load / restore a backup.
Answers
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:
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
docker compose exec -T db psql -U taskd -d taskd -c "SELECT count(*) FROM tasks;"
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
docker compose exec -T db psql -U taskd -d taskd -c "DROP TABLE tasks CASCADE;"
docker compose exec -T db psql -U taskd -d taskd -c "SELECT count(*) FROM tasks;"
docker compose exec -T db pg_restore -U taskd -d taskd --no-owner --no-privileges \
< ./backups/drill.dump
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:
- 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.
- 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.
- 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.
- 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.
- 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.
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:
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.
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.
- 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.
- 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.
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.
include .envrc
GIT_DESC = $(shell git describe --always --dirty --tags 2>/dev/null || echo dev)
.PHONY: help
help:
@grep -E '^
.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
.PHONY: sqlc
sqlc:
sqlc generate
.PHONY: sqlc/vet
sqlc/vet:
sqlc vet
.PHONY: test
test:
go test -race -count=1 ./...
.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 ./...
.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 ./...
.PHONY: build/api
build/api:
CGO_ENABLED=0 go build -ldflags='-s -w -X main.version=${GIT_DESC}' \
-o bin/api ./cmd/api
.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):
export TASKD_DB_DSN='postgres://taskd:pa55word@localhost:5432/taskd?sslmode=disable'
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} 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 |
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.
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:
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.
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
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
go mod graph | wc -l
go mod why github.com/x/y
go version -m bin/api
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:
- Attack surface. Their bug is your CVE.
govulncheck in make audit exists for this.
- Upgrade work. Someone has to move you off v78 when v79 lands.
- Build time and image size. Small here, decisive in a monorepo.
- 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.
- Understanding. You are responsible for behaviour you did not write and may not have read.
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
go mod tidy
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:
- 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.
- 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.
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.
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"
)
var version = "dev"
type application struct {
config config
logger *slog.Logger
db *pgxpool.Pool
q *db.Queries
cache *cache.Cache
sfGroup singleflight.Group
wg sync.WaitGroup
mailer *mailer.Mailer
}
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)
stripe.Key = cfg.stripe.secretKey
if stripe.Key == "" {
logger.Warn("stripe.secret_key is empty; billing endpoints will fail")
}
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")
prometheus.MustRegister(newPoolStatsCollector(pool))
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)
}
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)
}
app := &application{
config: cfg,
logger: logger,
db: pool,
q: db.New(pool),
cache: appCache,
mailer: m,
}
go app.janitor()
err = app.serve()
if err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}
func newLogger(cfg config) *slog.Logger {
var lvl slog.Level
_ = lvl.UnmarshalText([]byte(cfg.logLevel))
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.
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:
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.
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.
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.
- 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
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func (app *application) routes() http.Handler {
r := chi.NewRouter()
r.Use(secureHeaders)
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,
}))
}
r.Use(app.recoverPanic)
r.Use(app.metricsMiddleware)
r.Use(app.logRequest)
r.Use(app.authenticate)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
app.notFoundResponse(w, r)
})
r.Get("/metrics", metricsHandler().ServeHTTP)
r.Get("/docs", app.docsPageHandler)
r.Route("/v1", func(r chi.Router) {
r.Get("/healthcheck", app.healthcheckHandler)
r.Get("/openapi.yaml", app.openapiHandler)
r.Post("/stripe/webhook", app.stripeWebhookHandler)
r.Group(func(r chi.Router) {
r.Use(app.rateLimitIP)
r.Post("/users", app.registerUserHandler)
r.Post("/tokens/authentication", app.createAuthTokenHandler)
r.Post("/tokens/activation", app.createActivationTokenHandler)
r.Put("/users/activated", app.activateUserHandler)
r.Post("/tokens/password-reset", app.createPasswordResetTokenHandler)
r.Put("/users/password", app.resetPasswordHandler)
r.Put("/users/email", app.confirmEmailChangeHandler)
})
r.Group(func(r chi.Router) {
r.Use(app.requireAuthenticatedUser)
r.Use(app.rateLimitUser)
r.Put("/me/password", app.changePasswordHandler)
r.Put("/me/email", app.changeEmailHandler)
r.Delete("/me", app.deleteAccountHandler)
r.Group(func(r chi.Router) {
r.Use(app.requireActivatedUser)
r.Route("/tasks", func(r chi.Router) {
r.With(app.idempotent).Post("/", app.createTaskHandler)
r.Get("/", app.listTasksHandler)
r.Get("/{id}", app.showTaskHandler)
r.Patch("/{id}", app.updateTaskHandler)
r.Delete("/{id}", app.deleteTaskHandler)
})
r.Route("/billing", func(r chi.Router) {
r.With(app.idempotent).Post("/checkout", app.createCheckoutHandler)
r.Post("/portal", app.createPortalHandler)
r.Get("/plan", app.showPlanHandler)
})
})
})
})
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.
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.
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:
- The router file is the only complete map of a service’s attack surface; read it first in any
codebase.
- 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?
- 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.
- 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.
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/rand — crypto/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.As — Is 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 / NULL — NULL 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 pagination — LIMIT 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.
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
- 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.
- 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.
- 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.
- 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.
- 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 |
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 |
| 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 |
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
var x int
x := 5
const version = "1.0"
func doThing(id int64) (Task, error) {
if id < 1 {
return Task{}, errors.New("id must be positive")
}
return Task{ID: id}, nil
}
t, err := doThing(1)
if err != nil {
return fmt.Errorf("doing thing: %w", err)
}
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
func (t *Task) Rename(s string) { t.Title = s }
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"]
defer pool.Close()
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()
ch := make(chan error)
ch <- err
e := <-ch
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); work() }()
wg.Wait()
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
select {
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
curl -i localhost:4000/v1/healthcheck
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"}'
curl -i -X POST localhost:4000/v1/tokens/authentication \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"pa55word1234"}'
export TOKEN=...
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
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;
BEGIN; ... COMMIT;
9. Migrations (golang-migrate)
make db/migrations/new name=add_widgets
make db/migrations/up
migrate -path ./migrations -database $TASKD_DB_DSN down 1
migrate -path ./migrations -database $TASKD_DB_DSN version
migrate -path ./migrations -database $TASKD_DB_DSN force 4
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
make sqlc/vet
sqlc diff
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 |
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:
- Answer the quiz the next day, before re-reading anything. What survives a night’s sleep is
what you have actually learned.
- 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.
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.