The Shape of Data · Nº 06

The Shape of Data

A field guide to relational modeling and SQL

A schema is not storage — it is a contract the database enforces on every write, from every code path, forever. Application code, especially AI-generated application code, keeps that contract only approximately: it validates here and forgets there, and re-implements in a race what a constraint enforces atomically. This guide teaches the relational model as invariant enforcement — modeling, normalization, indexes, the SQL canon, transactions — so you can read a schema the way you would read a contract: for what it actually guarantees.

Module 01 The schema as contract

Most people meet a database as a place to put things. That framing survives right up to the first production incident, at which point it becomes obvious that the schema was never storage — it was the last line of defense for every assumption your product makes about the world. A column declared NOT NULL is a claim that a row without that value is not a fact about your domain; a UNIQUE constraint is a claim that two such rows cannot both be true; a foreign key is a claim that a reference to something that does not exist is meaningless. The database enforces those claims on every write, from every code path, forever.

This module establishes the lens the rest of the guide uses. For any rule your business believes, there is exactly one question worth asking: which layer enforces it? The database can enforce it perfectly, the application can enforce it approximately, or nobody enforces it and you will find out during an audit. That question is why the relational model is a review skill and not database trivia — because generated code answers it accidentally, and you have to answer it on purpose.

The one gate every write passes

Consider a rule as boring as they come: a cellar entry's quantity is never negative. In a working product, rows arrive in that table from at least three places — the web handler when a user records a purchase, the batch importer when someone uploads a spreadsheet from their old cellar app, and the admin script an engineer runs to fix a support ticket at 11pm. Application-layer validation protects the paths where somebody remembered to call it. That is not a slight against your team; it is arithmetic. Three code paths, written at different times by different people (and increasingly by different models), each carrying its own copy of the rule.

A constraint collapses that arithmetic. Every one of those paths issues an INSERT, and every INSERT passes through the same gate. The rule is stated once, in the schema, and there is no path around it — not the ORM, not the raw psql session, not the migration script, not the code your assistant generated at 2am while you reviewed the diff at speed.

Comparison: three application code paths each validating separately, one missing its check, versus the same three paths converging on a single schema gateValidation in the applicationValidation in the schemaweb handlerchecks qty >= 0batch importerno checkadmin scriptchecks qty >= 0tableno constraintquantity = -3stored, and now truethe rule is as strong as its weakest callerweb handlerbatch importeradmin scriptCHECK gatequantity >= 0stated onceerror 23514rejected, from any paththe rule is as strong as the gate
Figure 1.1 — Three doors or one gate. When the rule lives in application code, it is enforced only where a caller remembered it, so the importer's omission becomes stored data that the rest of the system now treats as fact. When the rule lives in the schema, every path converges on one gate, and the omission surfaces as error 23514 at write time instead of as a support ticket a quarter later.
From your other domain

A constraint is strict liability; an application check is a policy memo. The memo tells everyone what to do and depends on compliance; strict liability makes the outcome impossible to reach regardless of intent or diligence. When you review a schema, you are reading which rules were drafted as liability and which were merely circulated.

The load-bearing idea

An invariant is a statement that must hold for every row, on every write, forever. Schema design is the practice of deciding which of your domain's invariants get enforced by the one component that sees every write — and being explicit about the rest instead of hoping.

Keys: identity as an invariant

A primary key is the enforced answer to "which row is this?" — unique and not null, checked on every write. The interesting decisions start immediately after. This guide's running schema is a wine-cellar product: users, wines, vintages, cellar entries, tasting notes, recommendations. Its wines table could plausibly be keyed by (producer, name), which is what the domain actually considers identity. Instead it carries a generated bigint:

CREATE TABLE wines (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  producer text NOT NULL,
  name     text NOT NULL,
  region   text NOT NULL,
  country  text NOT NULL,
  UNIQUE (producer, name)
);

Both keys are present, and that is the point. The surrogate key exists for referencing: it is small, immutable, and cheap to carry in a foreign key on 2.1 million cellar entries. The natural key — declared as UNIQUE (producer, name) — is the actual invariant, the statement that Château Montrose the Saint-Estèphe label appears once in this table and not four times because four importers spelled it differently. Teams that adopt surrogate keys and then drop the natural key have not simplified anything; they have deleted the rule and kept the plumbing. You can find this failure in the wild by looking for tables where the only unique constraint is on id: every row is unique in the least useful possible sense.

The same pattern holds one level down. Vintages carry UNIQUE (wine_id, year): Montrose 2016 is one row (id 3187), not one row per person who entered it. This is where identity modeling starts to earn its keep, because "a wine" and "a wine of a particular year" are genuinely different things — a subject m2 takes up in full.

Where NULL surprises you

Three-valued logic governs everything involving NULL: a comparison against NULL is neither true nor false but unknown, and WHERE keeps only rows where the predicate is true. So WHERE rack != 'A' silently drops every entry whose rack is NULL, and UNIQUE permits many NULL rows, because two unknowns are not known to be equal. Neither behavior is a bug; both are why a nullable column is a modeling decision — declare it only when "unknown" is a state your domain genuinely has.

Foreign keys: no orphans, ever

A foreign key encodes one invariant: every reference resolves. In the running schema, tasting_notes.vintage_id REFERENCES vintages(id) means there is no such thing as a note about a vintage that does not exist. Without the constraint, that sentence becomes aspirational, and the failure mode is quieter than people expect. Orphaned rows do not throw errors. They join to nothing, so an inner join drops them — your tasting-activity report simply returns a smaller number, and the number looks plausible, and nobody notices until an auditor asks why the count in the export disagrees with the count in the product.

The teams that skip foreign keys rarely say "we do not want referential integrity." They say the ORM manages relationships, or that foreign keys slow down deletes, or that they will add them later once the model settles. The first is a claim that application code will do perfectly what it does approximately; the second is true and almost always irrelevant at product scale; the third has an observable half-life, because by the time the model settles the data already violates the constraint you now want to add.

Declaring the constraint forces a decision you were making anyway, just implicitly: what happens to children when the parent goes away?

BehaviorWhat it doesWhere it belongs in the running schema
ON DELETE RESTRICTRefuses the parent delete while children existvintages.wine_id — deleting a wine that people own bottles of is a mistake, and the error is the point
ON DELETE CASCADEDeletes the children with the parentcellar_entries.user_id and tasting_notes.user_id — the rows are the user's own record, and account deletion should take them
ON DELETE SET NULLKeeps the child, nulls the referenceOnly where the column is genuinely nullable and an orphaned child is still meaningful — rare here, and never on a NOT NULL column

Notice that the cascade choice on user deletion is not a database preference; it is your data-retention policy, written in DDL. That is the recurring theme: the schema is where a policy stops being a document and becomes a mechanism.

The constraint toolbox

Four tools cover nearly everything, and the useful way to learn them is not by definition but by the anomaly each one makes unrepresentable. Here is the running schema's cellar_entries, the table that records that a user owns bottles:

CREATE TABLE cellar_entries (
  id                   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id              bigint  NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  vintage_id           bigint  NOT NULL REFERENCES vintages(id) ON DELETE RESTRICT,
  quantity             integer NOT NULL CHECK (quantity >= 0),
  purchase_price_cents integer NOT NULL CHECK (purchase_price_cents > 0),
  purchased_on         date    NOT NULL,
  rack                 text,
  slot                 smallint
);

NOT NULL on user_id says an entry that belongs to nobody is not a fact. CHECK (quantity >= 0) is the oversell backstop — whatever the reservation code does, the row cannot record negative bottles, which means a concurrency bug in m6's territory degrades into a failed transaction rather than corrupted accounting. CHECK (purchase_price_cents > 0) catches the data-entry class of error where a form submits an empty string and the ORM helpfully coerces it to zero. And UNIQUE, which this table does not use but wines and vintages do, is the one people most often try to reimplement.

Postgres also offers exclusion constraints, which generalize uniqueness to any comparison operator — the canonical use is preventing overlapping ranges, such as two bookings of the same rack slot over overlapping date ranges via EXCLUDE USING gist. You will not need it often; know it exists so you recognize the problem shape when it appears.

Anti-pattern — application-layer enforcement of what constraints do better

Symptom: a validate_unique_email() helper that runs SELECT 1 FROM users WHERE email = $1, and inserts if the result is empty. Why it fails: two requests can both run the SELECT before either runs the INSERT — the check and the act are not atomic, and no amount of care in one process fixes it, because the two requests may be in different processes on different machines. Corrective: declare UNIQUE, attempt the insert, and handle error 23505 as a documented outcome of your API. The database is the only component that sees all concurrent writers, so it is the only component that can arbitrate.

Flowchart of an INSERT passing through NOT NULL, CHECK, UNIQUE and foreign-key gates, with the SQLSTATE error code emitted at each rejectionINSERTNOT NULLis it present?CHECKis it sane?UNIQUEis it the only one?FOREIGN KEYdoes it resolve?row23502user_id missing23514quantity = -323505Montrose 2016 twice23503vintage 9999 absentevery write, from every code path, passes all four gateseach SQLSTATE is a contract your API can translate into a specific, honest error response
Figure 1.2 — The gates and their receipts. An INSERT is checked for presence, then sanity, then uniqueness, then resolvability, and a violation at any gate produces a specific SQLSTATE rather than a bad row. Those codes are worth knowing by sight — 23502, 23514, 23505, 23503 — because handling them is how the application turns an enforced invariant into a useful message.

What the database cannot enforce

The lens is only honest if it has a boundary. Constraints are row-scoped: a CHECK can see the row being written and nothing else. So an invariant like "a user on the free plan may hold at most 50 bottles in total" is outside the toolbox — it depends on the sum of other rows, which may be changing concurrently. Same for "a recommendation must reference a vintage the user does not already own" and "at least one cellar member must have the owner role." These are real invariants, and pretending a constraint covers them is worse than knowing it does not.

The options for cross-row invariants are: a transaction that reads and writes atomically at a sufficient isolation level (m6 makes this precise, including exactly how the naive version fails); a trigger that runs inside the writing transaction; a materialized helper column protected by its own constraint; or application logic that you accept is best-effort. Each is a real answer. "We assume the API layer handles it" is not one, because that is a statement about the code paths that exist today.

Which leads to the review discipline this module exists to install. For every business rule your product believes, write down the layer that enforces it. The output looks like this, and "nobody" is a finding, not an omission:

Business ruleEnforced byStrength
Every cellar entry belongs to a real userFOREIGN KEY + NOT NULLTotal
Bottle quantity is never negativeCHECK (quantity >= 0)Total
One row per wine per vintage yearUNIQUE (wine_id, year)Total
Free-plan cellars stay under 50 bottlesApplication check on the purchase pathBest-effort — races, and the importer skips it
Deleted accounts leave no tasting dataON DELETE CASCADETotal, and it is the retention policy
From your other domain

That table is a controls matrix. A constraint is a preventive control with a property most controls lack: it produces its own evidence. The absence of violating rows is not a sampled assertion but a structural consequence, and "show me the control" is answered by \d+ cellar_entries rather than by a screenshot of a runbook. When you next map controls to systems, notice how many detective controls exist only because a preventive one was available and nobody declared it.

Module 02 From requirements to tables

Module 1 argued that a schema is a set of enforced invariants. That leaves the prior question: invariants about what? Modeling is the act of deciding which things in your domain deserve identity, how they relate, and how many of each side a relationship permits — and those three decisions determine where every foreign key goes, which constraints are even expressible, and which questions your data can answer later without a migration.

This module works one schema end to end: the wine-cellar product's six tables, presented with every decision defended out loud. It is deliberately a small domain, because the interesting failures are not scale failures. They are the design that mirrors the request instead of the domain — a table per screen, a column per form field, an array where a relationship belonged — which reads fine in review and becomes structurally expensive about the time the second feature arrives.

Entities are the nouns that have identity

Here is the entire requirement the cellar product started from, as it was actually written: "Users track the bottles they own, identified by producer and vintage year, with what they paid and where in the cellar it sits. They record tasting notes with a score, and receive recommendations of wines to buy next." Modeling begins by mining that paragraph for nouns and then rejecting most of them.

The test for entity-hood is two questions. First, does the thing have identity independent of other rows — can you point at it and say "that one" without reference to its context? Second, do invariants attach to it in its own right? A user passes both: an account exists whether or not it owns anything, and "emails are unique" is a rule about users. A bottle count does not pass either: it has no independent existence and no rules of its own; it is an attribute of an ownership fact.

Run the paragraph through the test and six nouns survive: users, wines, vintages, cellar_entries, tasting_notes, recommendations. "Producer" does not survive today — no invariants attach to it yet, so it is a text column on wines. "Score" does not survive — it is an attribute of a note. "Cellar" does not survive either, which is a genuinely close call: today a user has exactly one implicit cellar, so it would be a table with an id and nothing else. The section on attributes returns to what would change that.

The split that does the most work

"Wine" and "vintage" both survive, and separating them is the most consequential decision in this schema. Château Montrose is a label — producer, name, region, country, all stable across decades. Montrose 2016 is a specific thing you can own, taste, and pay for, with its own alcohol level and its own market price. Attributes attach at different levels, so they are different entities; collapse them and you either repeat the region on every vintage row or lose the ability to say anything about a particular year.

Cardinality decides where the key lives

Once you have entities, cardinality is a purely mechanical question with a mechanical consequence. Ask it in both directions — can one X relate to many Y, and can one Y relate to many X — and the answer places the foreign key for you.

For 1:N, the key goes on the many side. One wine has many vintages; one vintage belongs to one wine; therefore vintages.wine_id. The tell that you have it backwards is immediate and worth memorizing: if the column would need to hold multiple values, it is on the wrong table. wines.vintage_id could only ever name one of Montrose's forty vintages, and teams that go down this path end up either with a comma-separated list or with a duplicate wine row per year — which is to say, with the redundancy m3 is about.

For M:N, neither side can hold the key, so the relationship becomes a table of its own. One user owns bottles of many vintages; one vintage is owned by many users; therefore cellar_entries, holding user_id and vintage_id. For 1:1, default to one table. A separate table for a strict 1:1 buys you a join in exchange for almost nothing — the exceptions being genuinely optional sections of data, or columns with a different security or access profile that you want isolated on purpose.

Decision flowchart: asking the many-question in both directions to choose between merging a 1:1, placing a foreign key for 1:N, and creating a junction table for M:NCan one X havemany Y?yesnoCan one Y havemany X?Can one Y havemany X?yesnoyesnoM:N — junction tablecellar_entries(user_id, vintage_id)payload lives here too1:N — FK on the Y sidevintages.wine_id → wines.idthe many side carries it1:N reversed — FK on Xtasting_notes.user_id → users.idsame rule, other direction1:1 — merge into one tablesplit only for optionality ora different access profile
Figure 2.1 — The many-question, asked twice. Cardinality is decided by asking whether each side can have many of the other, and the answer determines placement mechanically: many-to-many needs a junction table (which is also where the relationship's own attributes live), one-to-many puts the key on the many side, and one-to-one collapses into a single table unless optionality or access control argues otherwise.

The full worked design

Here is the complete schema, Postgres 16, with the decisions defended. Every SQL statement in the rest of this guide runs against exactly this.

CREATE TABLE users (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email        citext NOT NULL UNIQUE,
  display_name text   NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE wines (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  producer text NOT NULL,
  name     text NOT NULL,
  region   text NOT NULL,
  country  text NOT NULL,
  UNIQUE (producer, name)
);

CREATE TABLE vintages (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  wine_id bigint   NOT NULL REFERENCES wines(id) ON DELETE RESTRICT,
  year    smallint NOT NULL CHECK (year BETWEEN 1900 AND 2100),
  abv     numeric(3,1),
  UNIQUE (wine_id, year)
);

CREATE TABLE cellar_entries (
  id                   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id              bigint  NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  vintage_id           bigint  NOT NULL REFERENCES vintages(id) ON DELETE RESTRICT,
  quantity             integer NOT NULL CHECK (quantity >= 0),
  purchase_price_cents integer NOT NULL CHECK (purchase_price_cents > 0),
  purchased_on         date    NOT NULL,
  rack                 text,
  slot                 smallint
);

CREATE TABLE tasting_notes (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    bigint   NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  vintage_id bigint   NOT NULL REFERENCES vintages(id) ON DELETE RESTRICT,
  rating     smallint NOT NULL CHECK (rating BETWEEN 50 AND 100),
  note       text,
  tasted_on  date     NOT NULL
);

CREATE TABLE recommendations (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id      bigint       NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  vintage_id   bigint       NOT NULL REFERENCES vintages(id) ON DELETE RESTRICT,
  score        numeric(4,3) NOT NULL CHECK (score BETWEEN 0 AND 1),
  reason       text         NOT NULL,
  generated_at timestamptz  NOT NULL DEFAULT now()
);

Why cellar_entries is a junction table with payload. It realizes the M:N between users and vintages, but it carries quantity, purchase_price_cents, purchased_on, and location. Those attributes belong to neither user nor vintage — they are facts about this ownership. That is the general rule: when a relationship has its own attributes, the junction table is where they live, and the schema is telling you the relationship was really an entity all along (a purchase event).

Why tasting_notes references vintage, not cellar entry. The tempting design hangs a note off the entry, since you usually taste what you own. But you can taste a wine at a friend's house, at a shop, or from a bottle you finished last year and no longer have an entry for. Referencing the vintage models the domain; referencing the entry models the happy path and quietly makes the other cases unrepresentable — the exact failure this guide keeps returning to, in the opposite direction from the usual one.

Why purchase_price_cents lives on the entry. It is the price paid, on purchased_on, by that user — a historical fact of the purchase, not a lookup of what Montrose 2016 costs today. Moving it to a shared price table would look like removing duplication and would actually destroy information, since a later price update would silently rewrite what Elena paid in March 2024. Module 3 gives this distinction its name and its test.

Entity-relationship diagram of the wine-cellar schema in crow's-foot notation, showing users, wines, vintages, cellar entries, tasting notes and recommendations with key columns and cardinality marksusersPK idUQ emaildisplay_namecreated_atwinesPK idUQ (producer, name)regioncountryvintagesPK idFK wine_idUQ (wine_id, year)abvcellar_entriesPK idFK user_idFK vintage_idquantity, purchased_onpurchase_price_centstasting_notesPK idFK user_idFK vintage_idrating, tasted_onrecommendationsPK idFK user_idFK vintage_idscore, generated_at1:N1:N1:N1:N1:N1:Njunction with payload: users ↔ vintagescrow's foot = many side · single bar = exactly one · PK primary key · FK foreign key · UQ unique constraint
Figure 2.2 — The running schema. Six tables, each relationship marked with its cardinality: a wine has many vintages, and a vintage is owned by many users through cellar_entries — the junction that also carries quantity, price paid, and purchase date. Both tasting_notes and recommendations point at the vintage rather than at a cellar entry, so a note about a wine you no longer own (or never owned) remains representable.

Attributes versus entities, and time

The hardest modeling calls are the ones between a column and a table. region is a text column on wines today, and that is correct today, because nothing in the product has an invariant about regions — no rule says Saint-Estèphe must exist before a wine can claim it, and nothing hangs off a region except the string itself. The day requirements arrive that attach facts to regions — appellation rules, a country's tax treatment, a canonical hierarchy of Bordeaux subregions — region becomes an entity, gets a table, and wines.region becomes wines.region_id. Promoting it before that day is over-modeling: you pay a join and a maintenance burden for a lookup table whose only column is the name you already had.

The rule generalizes cleanly. Promote an attribute to an entity when invariants attach to it, when it needs attributes of its own, or when the same value must be referenced identically from several places. Two of those three are satisfied by "we keep misspelling it," which is why the promotion usually happens sooner than teams expect but later than the pattern-matchers want.

Time deserves its own paragraph, because it is where modeling most often quietly loses information. purchased_on is a date because the domain fact is a calendar day — nobody cares that the purchase was recorded at 14:32, and a timestamp would invite time-zone questions the domain does not have. created_at and generated_at are timestamptz because they are moments in the life of the system, compared against other moments across users in different zones. And the deeper discipline: model change as new rows rather than overwritten columns wherever the history is a domain fact. If a bottle's rack location matters historically, moving it should insert a movement row, not update a column — an UPDATE is a small, silent act of deletion.

Two questions before every UPDATE column

Would anyone ever ask what this value used to be? Would anyone ever ask when it changed? If either answer is yes, the current design is lossy, and you will discover it during an investigation — the moment when the data you need is the data you overwrote.

Schemas that model the prompt

Now the anti-pattern this module exists to name. Ask an assistant to build the cellar product and you will frequently get a schema that is an excellent transcription of the request and a poor model of the domain. It has a table per screen — dashboard_items, my_cellar_rows — because the request described screens. It has columns named after form fields. And because the prompt said "users can favorite wines," it has users.favorites jsonb holding an array of wine ids.

That last one is the diagnostic case. The array works: you can read it, render it, and demo it. What it forfeits is everything m1 established. There is no foreign key, so a favorited wine can be deleted and the array now points at nothing, silently. There is no uniqueness, so the same wine appears twice the moment a double-click gets through. There is no join, so "which users favorited this wine" — the first question the recommendations feature will ask — requires scanning every user. And there is nowhere to put favorited_at, which somebody will want within the quarter, so it gets nested into the array elements and now you have an undocumented schema inside a column.

The corrective is the junction table you would have written from the cardinality question: user_favorites (user_id, wine_id, favorited_at, PRIMARY KEY (user_id, wine_id)). Note what the composite primary key does for free — it is the uniqueness invariant, so double-clicks are impossible rather than deduplicated.

Anti-pattern — the schema that restates the request

Symptom: table names that match screens, column names that match form fields, an array or jsonb column wherever the requirement used a plural noun, and no relationship that the prompt did not mention explicitly. Why it fails: the request describes one surface of the product at one moment; the domain outlives both. Corrective: extract entities from identity and invariants (not from nouns in the prompt), run every relationship through the cardinality question, then check each requirement noun against the resulting diagram to confirm it is either an entity, an attribute, or deliberately absent.

Which yields the review checklist for any proposed schema, generated or otherwise. Does every table represent something with independent identity? Is every relationship's cardinality declared and its key on the correct side? Does every plural stored inside a single column have a defended reason to not be a table? Is every natural key declared UNIQUE? For each of the three or four questions the product will obviously ask next, can this schema answer it without a migration — and if not, is that acceptable? Four minutes of checklist, applied to a diff you would otherwise skim, is the highest-leverage review in this guide.

Module 03 Normal forms, without the catechism

Normalization is taught backwards almost everywhere. The forms get recited as definitions, the definitions get memorized for an interview, and the practitioner emerges able to say "the key, the whole key, and nothing but the key" while still unable to say what goes wrong if you ignore it. So this module starts at the other end: with the corruption. Every normal form exists because someone's data started disagreeing with itself, and the form is the rule that made that disagreement structurally impossible.

Then the harder half. Denormalization is legitimate — sometimes necessary — and the discipline is entirely about knowing which kind of redundancy you are creating. Some duplicated values are historical facts that must never be normalized away; others are derivable copies that will silently drift the moment a write path forgets them. Telling those apart, and writing a maintenance plan for the second kind, is the whole skill.

Anomalies first

Imagine the cellar product had shipped with one table instead of six — a cellar_log, one row per bottle event, everything on the row:

user_emailproducerwine_nameregionyearqtyrating
elena@marchpane.exampleChâteau MontroseChâteau MontroseSaint-Estèphe2016694
elena@marchpane.exampleChâteau MontroseChâteau MontroseSaint-Estèphe2015292
marc@vineyard.exampleChâteau MontroseChâteau MontroseSaint-Estèphe2016196

It works. You can query it, render it, and demo it. Three specific failures are already inevitable.

The update anomaly. The region was recorded as Saint-Estèphe, which is right, but suppose an editor corrects a mis-entered region across the catalog — or that Montrose's region string needs standardizing from "Saint-Estèphe" to "Saint-Estèphe, Médoc". The fact lives in 44 rows. The update statement matches 41 of them, because three rows spell the producer with a different accent or a trailing space. Now the database holds two answers to one question, and every subsequent report picks one arbitrarily. This is the update anomaly: the same fact stored many times, updated once, disagreeing with itself thereafter. Note what makes it vicious — nothing failed. No error, no alert, just a slow divergence discovered by a user who sees one region in search and another on the detail page.

The insert anomaly. You cannot record that Montrose 2019 exists until somebody owns a bottle of it, because every row requires a user and a quantity. The catalog and the ownership are welded together, so facts about wines can only enter the system through the act of buying one. Recommendations — which by definition are about wines the user does not own — have nowhere to come from.

The delete anomaly. Elena drinks her last 2015 and the row is deleted. The system has now forgotten that Montrose 2015 exists, along with its region and her rating of it. Deleting one fact destroyed three, because they shared a row.

The load-bearing idea

Anomalies are the disease; normal forms are the diagnosis. Every form is a rule that makes one class of anomaly structurally impossible — not less likely, impossible — by ensuring each fact is stored in exactly one place. When you find yourself unable to remember what 3NF requires, ask instead what could go wrong here if this value were stored twice, and you will re-derive it.

Functional dependencies and the forms that matter

A functional dependency is the whole apparatus in one sentence: A → B means that if you know A, B is determined. In the log table, producer, wine_name → region — knowing the wine determines the region, absolutely, regardless of who owns it or when. That dependency is a fact about the domain, and the trouble comes from storing it in a table whose key is something else entirely.

First normal form requires atomic values: no repeating groups, no lists in a cell. The violation you will meet most often is grapes text holding "Cabernet Sauvignon, Merlot, Petit Verdot". What atomicity buys is concrete — you can index a value, reference it with a foreign key, and count occurrences correctly. What the string costs is equally concrete: WHERE grapes LIKE '%Merlot%' also matches "Merlot Noir" and misses "merlot", and no index helps a leading wildcard.

Second and third normal form are one idea applied twice: every non-key attribute must depend on the key, the whole key, and nothing but the key. 2NF is violated when an attribute depends on only part of a composite key — in the log, region depends on the wine alone, not on the (user, wine, year) triple that identifies a row. 3NF is violated when a non-key attribute depends on another non-key attribute — if the log also stored country, determined by region rather than by the row's key, that is the transitive dependency 3NF forbids.

Boyce-Codd normal form, honestly: it tightens 3NF for the case where a table has overlapping candidate keys and a non-trivial dependency runs from a non-superkey to a key attribute. It is real, and in ten years of ordinary product work you may meet it twice. Know that 3NF is not the last word; do not go looking for BCNF violations in schemas that do not have overlapping candidate keys.

Before and after: a redundant single-table cellar log where correcting a region misses three rows, versus the decomposed schema where the same correction touches exactly one rowBefore — one table, region stored per rowAfter — 3NF, region stored onceuserwineregionyrelenaMontroseSt-Estèphe, Médoc2016elenaMontroseSt-Estèphe, Médoc2015marcMontroseSt-Estèphe, Médoc2016priyaMontrose·Saint-Estèphe2016priyaMontrose·Saint-Estèphe2014samMontrose·Saint-Estèphe2016UPDATE cellar_log SET region = 'St-Estèphe, Médoc' WHERE wine = 'Montrose'; -- 41 rows3 rows keep the old value — the data now holds two answersFunctional dependency, misplacedwine → regionbut the row's key is (user, wine, year),so region is stored once per ownershipwines214 · Château Montroseregion: St-Estèphe, Médocstored exactly oncevintages3187 · wine 214 · 20163186 · wine 214 · 2015cellar_entries88213 · user 1042 · vintage 3187 · qty 688214 · user 1042 · vintage 3186 · qty 291007 · user 2210 · vintage 3187 · qty 1UPDATE wines SET region = 'St-Estèphe, Médoc' WHERE id = 214; -- 1 rowone fact, one place — disagreement is unrepresentableFD wine → region now lives in the table keyed by wine
Figure 3.1 — The correction that misses three rows. On the left, wine → region is stored in a table keyed by (user, wine, year), so the region repeats once per ownership and a correction that matches 41 rows leaves 3 stale — two answers to one question, with no error raised. On the right the same facts are decomposed so that each dependency lives in the table its determinant keys, and the correction is a single-row update that cannot be partial.

Deliberate denormalization

Now break the rules, on purpose. Denormalization is reintroducing redundancy to buy read performance, and it is a legitimate move when the read path is genuinely hot and the alternative is a five-way join on every page load. The failure is not denormalizing — it is denormalizing without knowing which kind of redundancy you just created.

There are two kinds, and confusing them causes damage in both directions.

A snapshot records what was true at a moment. cellar_entries.purchase_price_cents is the price Elena paid for Montrose 2016 on 2024-03-15: 21,500 cents. It looks like duplication of a wine's price, but it is not duplication at all — it is a distinct fact about a distinct event. "Normalizing" it into a shared price table keyed by vintage would replace a historical fact with a current lookup, so the day the price is updated, Elena's purchase history quietly changes to say she paid something she did not pay. Snapshots must not be normalized away, and the same reasoning covers the shipping address on an order, the tax rate applied to an invoice, and the terms in force when a contract was signed.

A cache is derivable. A bottle_count column on users is exactly SELECT sum(quantity) FROM cellar_entries WHERE user_id = ..., precomputed. It contains no information the base tables lack, which is precisely why it can be wrong: any write path that changes quantities without updating the cache creates drift, and drift is undetectable without an explicit comparison.

Worked — the test applied

Two candidate columns on vintages. avg_rating numeric(4,2) — derivable from tasting_notes, therefore a cache, therefore requires a maintenance plan before it ships. release_price_cents integer — the price at release, which no query over current data can reconstruct, therefore a snapshot, therefore a legitimate column that nobody should later "normalize" into a prices table. Same table, same shape, opposite treatment; the test is whether a query over existing rows could recompute it.

Hence the rule this module enforces: every cache gets a written maintenance plan before it ships. The plan names three things — what updates the value (a trigger, or a transactional co-update alongside every write to the base table), when it is allowed to be stale and by how much, and what detects drift (a reconciliation query that runs on a schedule and alerts rather than silently repairing). A cache with a maintenance plan is engineering. A cache without one is a second source of truth that nobody has agreed is a source of truth.

Denormalization review discipline

When someone proposes a redundant column — in a design doc, a pull request, or a schema an assistant generated — three questions settle it, and they take about ninety seconds.

Is it a snapshot or a cache? Apply the recomputation test. Snapshots are approved on the spot and, importantly, annotated so a future refactor does not "clean them up." Caches proceed to question two.

What maintains it? Name the mechanism, not the intention. A trigger on the base table is the most robust answer because it holds regardless of which code path writes. A transactional co-update in application code is acceptable if — and only if — every write path performs it, which means you must enumerate the write paths: the web handler, the importer, the admin tooling, the backfill scripts. "The service layer handles it" is not an answer; it is a hope about a code structure that will change.

What detects drift? A reconciliation query with an owner and a schedule, alerting on mismatch. If nobody can write the reconciliation query, that usually means the value's definition is not agreed on, which is a finding of its own — you cannot maintain a number nobody can define.

Anti-pattern — unmanaged denormalization

Symptom: a count, total, or status column on a parent table that nobody on the team can trace the update path for; often discovered when two screens report different numbers and each is reading from a different place. Why it fails: the value was introduced by one feature, maintained by that feature's write path only, and every path added since has been silently wrong. Corrective: either write the maintenance plan and the reconciliation query and assign an owner, or DROP COLUMN and recompute on read. Both are respectable; a column with no plan is not.

From your other domain

A cache without a reconciliation control is the data-layer version of an access grant nobody reviews. In both cases the initial action was reasonable, the risk accrues invisibly, and the failure surfaces during an audit rather than during operation. And the remedy has the same shape in both worlds: a detective control with a named owner and a cadence, plus the honesty to state the tolerance — how stale, how far off, before it counts as an incident.

Module 04 Indexes and the planner

Indexes are where most engineers stop reasoning and start pattern-matching. A query is slow, so an index gets added; the query is still slow, so another index gets added; eventually the table has nine indexes, writes have gotten measurably slower, and nobody can say which index serves which query. The whole cycle runs on a single missing idea — that the database is not choosing between your index and nothing, it is estimating the cost of several possible plans and picking the cheapest one it believes in.

This module is about making that estimate legible. You will learn what a B-tree can and cannot do (which makes composite column order predictable rather than magical), why an index that perfectly matches your WHERE clause can still be correctly ignored, and how to read a plan well enough to tell an actual problem from a healthy sequential scan. The discipline at the end is simple: every index is justified by a named query and verified by a plan, or it gets dropped.

A query's journey

When you send SQL to Postgres, four things happen in order, and knowing where each one ends explains most planner behavior that otherwise looks arbitrary.

Parse turns the text into a tree; this is where syntax errors live and nothing else. Rewrite expands views, applies rules, and inlines CTEs where it can. Plan is the interesting stage: the query planner enumerates ways to get the answer — sequential scan, index scan, bitmap heap scan, several join orders and join strategies — assigns each an estimated cost using table statistics, and picks the cheapest. Execute runs the winner.

Two consequences fall out immediately. First, the planner's decision is made from statistics, not from your data — it consults a stored sample (histograms, most-common values, distinct-value estimates, collected by ANALYZE) and reasons about how many rows each predicate will keep. When those statistics are stale, the plan is a confident answer to the wrong question. Second, indexes only matter during planning. An index the planner does not choose costs you write throughput and buys you nothing; it does not sit there being helpful in the background.

Layered diagram of a query's journey from SQL text through parse, rewrite, plan and execute, with an annotation rail showing where statistics and candidate indexes are consultedSQL textSELECT … FROM cellar_entries WHERE user_id=1042Parsetext → parse tree · syntax errors surface hereRewriteviews expanded · CTEs inlined (Postgres 12+)Plan — the decisionenumerate paths: Seq Scan · Index Scan · Bitmapcost each from statistics · take the cheapestthis is the only moment an index can help youExecutethe plan runs · EXPLAIN ANALYZE reports realitypg_statistichistograms, MCVs — refreshed by ANALYZEavailable indexescosted as paths, then kept or rejectedno second chancesexecution never revisits a wrong estimateWhat the planner consultsStatistics are a sample, not your data.A stale sample yields a confident planfor a table that no longer exists.
Figure 4.1 — Where indexes enter. Parse and rewrite are mechanical; planning is where every decision is made, by costing candidate access paths against statistics collected by ANALYZE. Execution never revisits that choice, which is why a bad row estimate produces a bad plan that runs to completion instead of correcting itself — and why the first diagnostic is always the estimate, not the index list.

B-trees and the leftmost-prefix rule

A B-tree keeps keys in sorted order and lets you descend to any key in a few page reads. That single property is the source of everything it can do: equality lookup, range scans, ordered retrieval without a sort, and — crucially — prefix behavior on multi-column keys.

A composite index on (user_id, purchased_on) sorts by user_id first, then by purchased_on within each user. Picture a phone book ordered by surname then first name. Finding everyone named Vasquez is one descent. Finding Vasquez, Elena is one descent. Finding everyone named Elena requires reading the whole book, because the Elenas are scattered across every surname — the ordering simply does not group them.

That is the entire leftmost-prefix rule. The index on cellar_entries (user_id, purchased_on) serves:

  • WHERE user_id = 1042 — the leading column alone, one descent to a contiguous range.
  • WHERE user_id = 1042 AND purchased_on >= '2024-01-01' — descend on the prefix, then range-scan within it.
  • WHERE user_id = 1042 ORDER BY purchased_on DESC LIMIT 20 — descend, then walk the already-sorted range backwards; no sort node at all, which is often the larger win.

And it does not usefully serve WHERE purchased_on >= '2024-01-01', because the qualifying entries sit under 50,000 different user_id values, scattered across the whole leaf level. Postgres may still fall back on a full index scan if the index is much narrower than the table, but it is reading everything either way — the ordering is not helping.

B-tree on (user_id, purchased_on) with two query paths traced: one filtering on both columns descends cleanly, one filtering only on purchased_on cannot descend and must scan every leafIndex on cellar_entries (user_id, purchased_on) — sorted by user, then by date within userroot: user_id < 900 | >= 900user_id 1 … 899user_id 900 … 50000user_id 1000 … 1099(88, 2024-02-11)(88, 2024-06-02)(211, 2023-11-30)(640, 2024-01-19)(640, 2024-08-04)(871, 2022-04-08)(944, 2024-03-02)(1001, 2021-05-17)(1039, 2024-07-21)(1042, 2024-03-15)(1042, 2024-09-08)(1043, 2020-02-02)(1188, 2024-04-27)(1190, 2019-12-01)(1204, 2024-05-30)ServedWHERE user_id = 1042 AND purchased_on > '2024-01-01'one descent, then a contiguous range — 4 rows readNot servedWHERE purchased_on > '2024-01-01'no descent possible — matches are scattered across every leafevery leaf must be visited
Figure 4.2 — One index, two fates. Because the tree is ordered by user_id first, a predicate on that leading column descends to a contiguous range of leaves and a date range narrows within it. A predicate on purchased_on alone has no descent available — the 2024 entries live under fifty thousand different user ids, spread across every leaf — so the ordering that makes the first query fast is exactly what makes the second unservable.

Selectivity, or why the planner ignores your index

Here is the scenario that produces the most confused bug reports. You add exactly the right index, you re-run the query, and EXPLAIN shows a sequential scan. The index is not broken and the planner is not confused. It has decided that using the index would be slower, and it is usually right.

Selectivity is the fraction of rows a predicate keeps. An index scan is not free per row: for each matching entry the executor reads the index page, then jumps to the heap page holding the actual row — and those heap jumps are effectively random access. A sequential scan reads pages in order, which storage and the operating system's readahead both reward heavily. So there is a crossover. Reading 0.1% of a table through an index is enormously cheaper than scanning it. Reading 40% through an index means doing 40% of the table's worth of random page fetches plus the index traversal, which loses to simply reading the table straight through.

In the running schema, WHERE user_id = 1042 keeps roughly 42 rows out of 2.1 million — selectivity around 0.002%, and the index wins by orders of magnitude. Now suppose someone adds an index on a hypothetical is_archived boolean where 88% of rows are false. Every query filtering is_archived = false will sequentially scan regardless, because the index would identify 1.85 million rows and then fetch each of their heap pages. Low-cardinality columns are index dead weight in isolation — they earn their place only as the trailing column of a composite index, or in a partial index (WHERE is_archived = true) that indexes just the rare side.

Cost curve chart showing index scan cost rising steeply with the fraction of the table selected and crossing the flat sequential scan cost at roughly five to ten percentfraction of the table the predicate selectsestimated cost0%20%40%60%80%100%Seq Scan — flat: read every page in orderIndex Scan — rises with random heap fetchescrossover ≈ 5–10% of rows(exact point depends on row width,correlation, and cache state)user_id = 104242 of 2.1M rows — 0.002%is_archived = false — 88% of rowsthe index is correctly ignored hereindexwinssequential scan wins — and the planner knows it
Figure 4.3 — The crossover. Sequential scan cost is roughly flat: it reads every page in order regardless of how many rows match. Index scan cost climbs with the number of matches, because each match implies a random heap fetch on top of the index traversal. Somewhere in the neighborhood of five to ten percent selectivity the curves cross, which is why a highly selective predicate like user_id = 1042 uses the index and a predicate keeping most of the table correctly does not.

Reading EXPLAIN

Stop guessing and read the plan. EXPLAIN ANALYZE runs the query and reports both what the planner expected and what actually happened. Here is Elena's cellar page — her entries with wine names, most recent first — against the schema with no supporting index:

EXPLAIN ANALYZE
SELECT ce.id, w.producer, w.name, v.year, ce.quantity, ce.purchased_on
  FROM cellar_entries ce
  JOIN vintages v ON v.id = ce.vintage_id
  JOIN wines    w ON w.id = v.wine_id
 WHERE ce.user_id = 1042
 ORDER BY ce.purchased_on DESC
 LIMIT 20;

Limit  (cost=98241.55..98241.60 rows=20 width=64)
       (actual time=1284.902..1284.911 rows=20 loops=1)
  ->  Sort  (cost=98241.55..98241.66 rows=44 width=64)
            (actual time=1284.900..1284.905 rows=20 loops=1)
        Sort Key: ce.purchased_on DESC
        Sort Method: top-N heapsort  Memory: 28kB
        ->  Nested Loop  (cost=0.86..98240.37 rows=44 width=64)
                         (actual time=42.118..1284.771 rows=42 loops=1)
              ->  Nested Loop  (cost=0.43..98002.11 rows=44 width=26)
                    ->  Seq Scan on cellar_entries ce
                          (cost=0.00..97561.00 rows=44 width=26)
                          (actual time=41.902..1281.004 rows=42 loops=1)
                          Filter: (user_id = 1042)
                          Rows Removed by Filter: 2099958
                    ->  Index Scan using vintages_pkey on vintages v ...
              ->  Index Scan using wines_pkey on wines w ...
Planning Time: 0.412 ms
Execution Time: 1284.998 ms

Read it inside out — the innermost nodes run first, and each node's cost includes its children. The story here is one line: Rows Removed by Filter: 2099958. To find 42 rows, the executor examined 2.1 million. Then it sorted them, because nothing delivered rows in purchased_on order.

Add the index the leftmost-prefix rule predicts, matching the sort direction:

CREATE INDEX cellar_entries_user_purchased_idx
  ON cellar_entries (user_id, purchased_on DESC);

Limit  (cost=1.29..92.44 rows=20 width=64)
       (actual time=0.052..0.318 rows=20 loops=1)
  ->  Nested Loop  (cost=1.29..201.88 rows=44 width=64)
                   (actual time=0.051..0.309 rows=20 loops=1)
        ->  Index Scan using cellar_entries_user_purchased_idx on cellar_entries ce
              (cost=0.43..38.20 rows=44 width=26)
              (actual time=0.031..0.061 rows=20 loops=1)
              Index Cond: (user_id = 1042)
        ->  Index Scan using vintages_pkey on vintages v ...
Planning Time: 0.388 ms
Execution Time: 0.402 ms

Two things changed, and the second is the one people miss. The sequential scan became an index scan — expected. And the Sort node disappeared entirely, because the index already delivers rows in purchased_on DESC order, so LIMIT 20 stops after twenty rows instead of after sorting forty-two. On a user with 3,000 entries, that difference is the whole page.

NodeWhat it meansWhen it is a problem
Seq ScanRead the whole tableOnly when the table is large and Rows Removed by Filter dwarfs the result
Index ScanDescend the index, fetch each matching heap rowRarely; watch for it looping many times inside a Nested Loop
Index Only ScanAnswered from the index alone, no heap fetchNever a problem — a high Heap Fetches count means the visibility map is stale, so VACUUM
Bitmap Heap ScanCollect matching pages, then read them in physical orderNormal for mid-selectivity; a lossy bitmap recheck signals work_mem pressure
Nested LoopFor each outer row, probe the inner sideWhen the outer row count was badly underestimated — the classic estimate blowup
Hash JoinBuild a hash of one side, probe with the otherWhen it spills to disk (extra batches) on a bad size estimate
SortOrdering rows in memory or on diskWhen an index could have supplied the order, or when it spills to disk
The load-bearing idea

Read estimated versus actual rows before anything else. If the planner expected 12 and got 48,000, the plan was a reasonable answer to a false premise, and the fix is ANALYZE (or extended statistics for correlated columns), not another index. Adding an index to compensate for a bad estimate produces a system with more indexes and the same problem.

Index discipline

Every index is a second data structure that must be updated on every insert, on every delete, and on every update touching its columns. Nine indexes on a hot table means an insert does one heap write and nine index writes, each with its own page splits and WAL. This is the cost side of a ledger that most teams only ever look at one side of.

The discipline follows: every index is justified by a named query and a plan that demonstrably improved. Not a hunch, not a column that "gets filtered on sometimes" — a query you can name, whose EXPLAIN you captured before and after. Indexes that cannot pass that test get dropped, and Postgres will tell you which ones are candidates via pg_stat_user_indexes (idx_scan = 0 after a full business cycle is a strong signal, though check for a rarely-used report first).

Anti-pattern — indexes by superstition

Symptom: a table with an index on nearly every column, added one at a time after slow-query alerts, and no one able to name which query each serves. Often accompanied by write latency that crept up over two quarters with no single cause. Why it fails: an index is only used if the planner chooses it, so indexes added without reading a plan are frequently never chosen — pure write cost, permanently. Corrective: capture EXPLAIN ANALYZE first, add one index aimed at the specific access path, verify the plan changed and the timing improved, and drop the ones that pg_stat_user_indexes shows unused.

Anti-pattern — UUID-everything without considering locality

Symptom: every table keyed by a random UUIDv4 "for security and scale"; insert latency degrades gradually as the table grows, index size outpaces expectation, and cache hit ratio falls. Why it fails: index locality. A bigint identity key always inserts at the right edge of the B-tree, so one hot page absorbs every insert. Random UUIDs scatter inserts uniformly across the entire index, so each insert dirties a different page — page splits everywhere, a working set the size of the whole index rather than one page, and materially more WAL. On 2.1 million cellar_entries rows the effect is visible; at ten times that it is the dominant write cost. Corrective: use bigint identity where the key never leaves your system, and where you genuinely need unguessable or client-generated identifiers, use UUIDv7 — its time-ordered prefix restores right-edge insertion while keeping the random tail.

Note the shape of the second one: the running schema's bigint keys were not a style preference. They were a locality decision made at design time, and this is where it pays. The correct framing when someone proposes UUIDs is not "UUIDs are bad" — it is "which requirement are we meeting: unguessability in URLs, client-side generation, or cross-system merging?" Each of those has an answer, and two of the three are satisfied by UUIDv7 or by a separate public identifier column alongside a bigint primary key.

Module 05 The working SQL canon

SQL has a large surface and a small working core. Four ideas cover nearly everything you will write or review: joins match rows, aggregation changes the grain, window functions compute across a partition without collapsing it, and common table expressions name the stages of a pipeline. Master those and the exotic remainder is lookup-able; miss one and you will write contortions that produce plausible, wrong numbers.

One concept ties the module together, and it is worth holding onto: every query result has a grain — an answer to "what does one row of this mean?" Almost every subtle SQL bug is a grain change nobody noticed. A join silently multiplies rows, an aggregate is computed over the multiplied set, and a revenue report inflates by exactly the number of tasting notes attached. Everything below is taught against the running schema, with the numbers worked out, because the failures are only recognizable when you have seen the arithmetic.

Joins are row matching, not lookup

The mental model that causes trouble is "a join looks up related data." The accurate model is: a join produces every pair of rows satisfying the condition. If a wine has three tasting notes, joining wines to notes yields three rows for that wine — not one row with three notes attached. Row counts change, and that is not an implementation detail; it is the operation.

INNER JOIN keeps only matched pairs. LEFT JOIN keeps every left row, filling NULLs where no match exists. FULL JOIN preserves both sides, which in practice you will use mostly for reconciliation work. And the anti-join — rows on one side with no counterpart — is a distinct operation worth naming, because the two ways of writing it are not equivalent.

The most common silent bug in this territory is the outer join annihilated by its own WHERE clause. Suppose you want every wine with a count of its high-rated notes, including wines never tasted:

-- WRONG: the WHERE turns this back into an inner join
SELECT w.name, count(tn.id) AS high_notes
  FROM wines w
  LEFT JOIN tasting_notes tn ON tn.vintage_id IN (
         SELECT v.id FROM vintages v WHERE v.wine_id = w.id)
 WHERE tn.rating >= 90
 GROUP BY w.name;

Never-tasted wines get NULL for tn.rating, and NULL >= 90 is unknown, so WHERE drops them — the three-valued logic from m1 §2, arriving as a reporting bug. The rule: a predicate on the optional side belongs in the ON clause, which filters what matches, not in WHERE, which filters what survives.

-- RIGHT: the rating filter is part of the match condition
SELECT w.name, count(tn.id) AS high_notes
  FROM wines w
  JOIN vintages v      ON v.wine_id = w.id
  LEFT JOIN tasting_notes tn
         ON tn.vintage_id = v.id AND tn.rating >= 90
 GROUP BY w.id, w.name;

For anti-joins, prefer NOT EXISTS. The tempting NOT IN (SELECT ...) form has a trap: if the subquery returns even one NULL, the whole predicate evaluates to unknown for every row and the query returns nothing. Not fewer rows — zero rows, silently, the day someone makes a column nullable.

-- users who have never recorded a tasting note
SELECT u.id, u.display_name
  FROM users u
 WHERE NOT EXISTS (
       SELECT 1 FROM tasting_notes tn WHERE tn.user_id = u.id);
Grid comparing inner join, left join, full join and anti-join results over the same two small tables of wines and tasting notesSource rowswines214 Montrose515 Löwenstein733 Mugatasting_notes (by wine)45710 → 214 · 9445711 → 214 · 9146002 → 733 · 88Read the countsMontrose has two notes → two joined rows.Löwenstein has none → present only on anouter join, and it is the row everycareless WHERE clause deletes.INNER JOINMontrose · 94Montrose · 91Muga · 883 rows · Löwenstein goneLEFT JOINMontrose · 94Montrose · 91Löwenstein · NULLMuga · 88FULL JOINMontrose · 94Montrose · 91Löwenstein · NULLMuga · 88same here — no orphan notes existNOT EXISTSLöwenstein1 row · the never-tasted wines only4 rows · unmatched left row preservedThe annihilationLEFT JOIN tasting_notes tn ON ... WHERE tn.rating >= 90Löwenstein's tn.rating is NULL, and NULL >= 90 is unknown, so WHERE discards the row —the LEFT JOIN silently becomes an INNER JOIN. Move the predicate into ON to keep it preserved.
Figure 5.1 — Four operations over the same rows. Montrose's two notes produce two joined rows, which is fan-out in miniature; Löwenstein, never tasted, appears only under an outer join, and the anti-join returns it alone. The panel at the bottom is the failure to memorize: a predicate on the optional side placed in WHERE tests NULL, evaluates to unknown, and quietly converts the outer join back into an inner one.

Aggregation is choosing a grain

Before writing an aggregate, answer one question: what does one row of the result mean? One row per user? Per user per month? Per wine? That is the grain, and GROUP BY is how you declare it. Getting explicit about the grain is what prevents the single most expensive class of SQL bug, which is not a wrong query — it is a query that returns a number, the number looks reasonable, and it is inflated by a factor nobody can see.

Here is the arithmetic. Elena's cellar entry 88213 records 6 bottles of Montrose 2016 at 21,500 cents each — a purchase of 129,000 cents. She has recorded three tasting notes on that vintage. Now someone writes a spend report that also filters by rating:

-- WRONG: joining 1:N before aggregating
SELECT u.display_name, sum(ce.purchase_price_cents * ce.quantity) AS spend_cents
  FROM users u
  JOIN cellar_entries ce ON ce.user_id = u.id
  JOIN tasting_notes  tn ON tn.vintage_id = ce.vintage_id AND tn.user_id = u.id
 WHERE tn.rating >= 90
 GROUP BY u.id, u.display_name;

The join to tasting_notes multiplies entry 88213 into three rows, each carrying its own copy of the 129,000. The sum reports 387,000 for a purchase that cost 129,000. This is fan-out, and its signature is diagnostic: the inflation is exactly proportional to the number of matched child rows, so the best-reviewed wines are overstated the most — which makes the report look directionally sensible and therefore trustworthy.

Fan-out diagram showing one cellar entry joined to three tasting notes, its price replicated across three rows and summed incorrectly, versus the aggregate-then-join pipeline producing the correct totalcellar_entries — 1 row88213 · vintage 3187price 21500 · qty 6tasting_notes — 3 rows45710 · vintage 3187 · 9445712 · vintage 3187 · 9245719 · vintage 3187 · 91JOINjoined — 3 rows, spend copied88213 · note 45710 · 12900088213 · note 45712 · 12900088213 · note 45719 · 129000SUM = 387000 ✕ (3× the truth)The grain silently changed fromone per purchase → one per note.aggregate at the right grain firstWITH rated AS ( SELECT DISTINCT vintage_id FROM tasting_notes WHERE rating >= 90)SELECT sum(price*qty) FROM entriesJOIN rated USING (vintage_id)SUM = 129000 ✓one row per purchase, preservedThe tellInflation is exactly proportional to the number of matched child rows, so the best-reviewed wines areoverstated the most. The report stays directionally plausible, which is why fan-out survives review.SELECT DISTINCT masks the symptom and breaks on legitimately duplicate rows — use EXISTS or pre-aggregate.
Figure 5.2 — One purchase, counted three times. Joining a 1:N relationship replicates the parent's columns once per child, so summing the parent's price after the join multiplies it by the child count — 129,000 becomes 387,000. The fix is to change the grain deliberately: reduce the child side to one row per key first (or test it with EXISTS), then join, so the sum still runs over one row per purchase.

Two correct fixes, and choosing between them is about intent. If the child table is only a filter — "entries whose vintage has a high-rated note" — use EXISTS, which tests without joining and therefore cannot fan out. If you need values from the child side, aggregate it to the target grain first in a CTE, then join one-to-one. What you must not do is reach for SELECT DISTINCT: it suppresses the visible duplication while leaving the grain wrong, and it silently collapses rows that are legitimately identical — two purchases of the same wine at the same price on the same day become one, and now the report undercounts instead.

Window functions: aggregates that do not collapse

A GROUP BY aggregate destroys rows: nine tasting notes become one average, and the individual notes are gone. A window function computes the same kind of value while keeping every row — each row gets its aggregate alongside its own data. That single difference resolves three query shapes that are painful without it.

The syntax has three parts inside OVER (...). PARTITION BY defines the group (like GROUP BY, but without collapsing). ORDER BY defines the sequence within the partition, which is what makes ranking and running totals possible. The frame clause defines which rows within the partition the function sees — and the default when you write ORDER BY is "everything from the partition start through the current row," which is exactly what a running total needs.

Top-N per group. The highest-rated vintage in each region. With GROUP BY region, max(rating) you get the rating but lose which vintage earned it; the classic repair — joining back on (region, max_rating) — duplicates rows on ties and is three times the code. ROW_NUMBER is the canonical form:

WITH ranked AS (
  SELECT w.region, w.producer, w.name, v.year, tn.rating,
         ROW_NUMBER() OVER (PARTITION BY w.region ORDER BY tn.rating DESC, v.year) AS rn
    FROM tasting_notes tn
    JOIN vintages v ON v.id = tn.vintage_id
    JOIN wines    w ON w.id = v.wine_id
)
SELECT region, producer, name, year, rating
  FROM ranked WHERE rn = 1
 ORDER BY region;

   region      |    producer      |      name       | year | rating
---------------+------------------+-----------------+------+--------
 Rioja          | Bodegas Muga     | Reserva         | 2018 |     93
 Mosel          | Fürst Löwenstein | Riesling Kabi.  | 2019 |     95
 Saint-Estèphe  | Château Montrose | Château Montrose| 2016 |     96
 Willamette     | Bergström        | Cumberland Res. | 2021 |     92

Note the tiebreak inside the ORDER BY. Without v.year, ties produce a nondeterministic winner that changes between runs — the kind of thing that turns into a bug report titled "the dashboard keeps changing." Use RANK() instead if you want all tied rows returned, and DENSE_RANK() if you do not want gaps after ties.

Comparing to a neighbor. How each of Elena's ratings for a wine moved relative to her previous one:

SELECT tn.tasted_on, w.name, v.year, tn.rating,
       LAG(tn.rating) OVER (PARTITION BY tn.vintage_id ORDER BY tn.tasted_on) AS prev_rating,
       tn.rating - LAG(tn.rating) OVER (PARTITION BY tn.vintage_id ORDER BY tn.tasted_on) AS delta
  FROM tasting_notes tn
  JOIN vintages v ON v.id = tn.vintage_id
  JOIN wines    w ON w.id = v.wine_id
 WHERE tn.user_id = 1042
 ORDER BY w.name, tn.tasted_on;

Running total. Elena's cumulative cellar spend by month:

SELECT month, monthly_cents,
       sum(monthly_cents) OVER (ORDER BY month) AS cumulative_cents
  FROM (SELECT date_trunc('month', purchased_on)::date AS month,
               sum(purchase_price_cents * quantity)    AS monthly_cents
          FROM cellar_entries WHERE user_id = 1042
         GROUP BY 1) m
 ORDER BY month;

   month    | monthly_cents | cumulative_cents
------------+---------------+------------------
 2024-01-01 |         48200 |            48200
 2024-02-01 |         12000 |            60200
 2024-03-01 |        129000 |           189200
 2024-04-01 |         31500 |           220700

This one shows the two tools composing: GROUP BY sets the grain to one row per month, and the window then runs across those rows without collapsing them further.

Diagram of a window function over tasting-note rows partitioned by vintage, showing the partition boundary, the frame for a running total, and the LAG value for the current rowOVER (PARTITION BY vintage_id ORDER BY tasted_on)partition: vintage 3186 — a separate universe for the function2023-09-02 · rating 902024-01-11 · rating 922024-05-30 · rating 91partition: vintage 3187 (Montrose 2016), ordered by tasted_on2024-03-20 · rating 942024-07-08 · rating 922024-11-15 · rating 91 ← current row2025-02-01 · rating 93frame for a runningtotal: partition start→ current rowLAG reads this rowwhat the current row receivesrating 91LAG(rating) 92delta -1ROW_NUMBER() 3avg OVER (part.) 92.5sum OVER (ORDER) 277The row is still here. GROUP BY wouldhave replaced all four rows with one.Partitions are independent: vintage3186's rows never enter 3187's frame,so LAG at a boundary returns NULL.Without an explicit tiebreak in ORDER BY, rows with equal keys rank nondeterministically — add a stable second key.
Figure 5.3 — The frame, seen from one row. PARTITION BY walls off each vintage so no value crosses the boundary — which is why LAG returns NULL on a partition's first row — and ORDER BY sequences the rows inside it. The default frame for an ordered window runs from the partition start to the current row, giving a running total, while LAG reaches back one row; either way the original row survives with its aggregate attached.

CTEs: naming the pipeline

A three-step question should read as three steps. WITH lets you name each stage, which turns nested subqueries into a pipeline you can review one piece at a time — and, importantly, run one piece at a time when it produces a number nobody believes.

Here is the whole module in one query: monthly spend per user for 2024, restricted to users with at least one high-rated note, ranked by total spend.

WITH engaged_users AS (
  -- stage 1: filter, using EXISTS so no fan-out is possible
  SELECT u.id, u.display_name
    FROM users u
   WHERE EXISTS (SELECT 1 FROM tasting_notes tn
                  WHERE tn.user_id = u.id AND tn.rating >= 90)
),
monthly AS (
  -- stage 2: set the grain — one row per user per month
  SELECT ce.user_id,
         date_trunc('month', ce.purchased_on)::date AS month,
         sum(ce.purchase_price_cents * ce.quantity)  AS spend_cents
    FROM cellar_entries ce
   WHERE ce.purchased_on >= DATE '2024-01-01'
     AND ce.purchased_on <  DATE '2025-01-01'
   GROUP BY ce.user_id, 2
),
totals AS (
  -- stage 3: rank users by annual spend, keeping the monthly rows
  SELECT m.*,
         sum(m.spend_cents) OVER (PARTITION BY m.user_id)               AS year_cents,
         DENSE_RANK() OVER (ORDER BY sum(m.spend_cents)
                            OVER (PARTITION BY m.user_id) DESC)         AS spend_rank
    FROM monthly m
)
SELECT eu.display_name, t.month, t.spend_cents, t.year_cents, t.spend_rank
  FROM totals t
  JOIN engaged_users eu ON eu.id = t.user_id
 ORDER BY t.spend_rank, t.month;

 display_name  |   month    | spend_cents | year_cents | spend_rank
---------------+------------+-------------+------------+------------
 Elena Vasquez | 2024-01-01 |       48200 |     220700 |          3
 Elena Vasquez | 2024-02-01 |       12000 |     220700 |          3
 Elena Vasquez | 2024-03-01 |      129000 |     220700 |          3
 Elena Vasquez | 2024-04-01 |       31500 |     220700 |          3

Each stage has a declared grain: one row per user, then one row per user-month, then the same rows enriched. When the numbers come out wrong, you can select from any stage alone and find the step where the grain changed — which is a debugging property, not an aesthetic one.

Two things worth knowing about CTEs

Before Postgres 12, a CTE was an optimization fence: it was materialized, and predicates could not be pushed into it. That is no longer the default — CTEs referenced once and free of side effects are inlined, so you can name your stages without paying for them. When you genuinely want the old behavior (an expensive stage referenced several times), say so explicitly with WITH x AS MATERIALIZED (...). Recursive CTEs (WITH RECURSIVE) exist for hierarchies and graph walks — an org chart, a threaded comment tree, a bill of materials. This domain has no such structure, so they stay out of scope here; know the shape so you recognize the problem when it appears.

The N+1 pattern

Everything so far has been about queries that are wrong. This one is about queries that are right, individually, and catastrophic collectively.

An ORM's lazy-loading default makes related data look like attribute access. You fetch Elena's cellar entries — one query — and then the template renders entry.vintage.wine.name for each. Each of those attribute accesses issues its own query. For 200 entries, that is 1 + 200 + 200 queries, each individually fast: 0.4 ms of database time, 0.6 ms of round trip, no slow-query log entry anywhere. The page takes 1.2 seconds and nothing looks broken.

The symptom in production is unmistakable once you know it: latency scales linearly with list size, so it is fine in staging with 8 rows and terrible for the collector with 3,000; and the logs show hundreds of identical queries differing only in a bind parameter.

Swimlane sequence contrasting 1 plus N round trips between application and database against a single joined queryLazy loading — 1 + 2N round tripsappdatabaseSELECT * FROM cellar_entries WHERE user_id=1042200 rows · 1.0 msSELECT * FROM vintages WHERE id=3187SELECT * FROM vintages WHERE id=3191SELECT * FROM vintages WHERE id=3204… 197 more, then 200 more for wines401 round trips × ~3 ms = 1,203 msevery individual query is fast — none appearsin the slow-query logEager loading — 1 round tripappdatabaseSELECT ce.*, v.year, w.producer, w.nameFROM cellar_entries ceJOIN vintages v ON v.id = ce.vintage_idJOIN wines w ON w.id = v.wine_id200 fully populated rows1 round trip × ~4 ms = 4 mslatency now scales with result size, not withrow count — the shape of the curve changed
Figure 5.4 — Where the p99 went. Lazy loading issues one query per related object, so 200 entries become 401 round trips whose latency is dominated by network time, not database time. A single join (or one batched WHERE id = ANY($1)) returns the same data in one trip — the point is not that it is faster but that latency stops growing with row count.
Anti-pattern — N+1 from ORM defaults

Symptom: endpoint latency proportional to list length; logs full of identical single-row queries differing only in a bind parameter; nothing in the slow-query log; the problem invisible in staging, where the seed data has ten rows. Why it fails: lazy loading makes a network round trip look like attribute access, so the cost is invisible at the call site. Corrective: eager-load the relationship (selectinload/joinedload in SQLAlchemy, includes in ActiveRecord, select_related/prefetch_related in Django), or fetch the ids and issue one WHERE id = ANY($1). Then keep it fixed: assert a query count in the endpoint's test, because the next refactor will silently reintroduce it.

Across the series

Guide Nº 07 spends its latency budget at the API layer: the p99 target for a list endpoint, what a payload costs to serialize, when to paginate. This is where that budget is actually consumed. An endpoint that is 40 ms of application work and 1,200 ms of accumulated round trips does not have an API performance problem — it has this one, and no amount of caching at the edge fixes a page whose cost grows with the user's data.

Module 06 Transactions and isolation

Module 1 drew an honest boundary: constraints protect one row at a time, so an invariant that spans several statements — read the quantity, decide, write the new quantity — is outside their reach. This module is what lives on the other side of that boundary.

The discipline here is unusual and worth adopting explicitly: never trust an anomaly you cannot write the schedule for. Concurrency discussions degenerate into vocabulary very quickly — people trade the words "dirty read" and "phantom" without agreeing on what either would look like in their system. If you can write the numbered interleaving of two transactions that produces the bad state, you understand the failure and can test for it. If you cannot, you are repeating a term. Every anomaly below comes with its schedule, against the running schema.

The transaction is the invariant's bodyguard

Consuming a bottle from Elena's cellar is three operations: read quantity for entry 88213, decide whether it is greater than zero, write quantity - 1. The invariant — never record consumption of a bottle you do not have — is a property of that whole sequence, not of any single statement in it. Between the read and the write, the world can change.

A transaction makes a sequence of statements atomic: everything commits or nothing does. BEGIN opens it, COMMIT makes every change visible together, ROLLBACK discards them as if nothing happened. That much handles failure — a crash halfway through a two-table update cannot leave one table changed. But atomicity alone does not handle concurrency, which is the harder half and the reason isolation levels exist.

Restate the thesis in its concurrent form: constraints protect single rows against bad values; transactions protect multi-step stories against interruption; isolation levels decide how much of other transactions' work your transaction can see while it runs. Every anomaly in the next section is a specific answer to that last question turning out to be the wrong one for your invariant.

What atomicity does not give you

A transaction that runs entirely inside BEGIN/COMMIT can still produce wrong results, because atomicity is about failure, not interleaving. Wrapping check-then-act in a transaction at the default isolation level does not make it safe — a fact that surprises people precisely because the transaction feels like a lock. It is not one.

The anomaly bestiary, concretely

Six anomalies matter in practice. Each gets a one-line definition and a schedule.

Dirty read — reading data another transaction has written but not committed. Postgres never permits this at any isolation level, so it is here for vocabulary completeness and for reading other engines' documentation.

Non-repeatable read — reading the same row twice in one transaction and getting different values, because someone committed in between.

Phantom — re-running the same range query and finding rows that were not there before.

Lost update — the one that costs money:

T1 (phone)                       T2 (tablet)
1. BEGIN
2. SELECT quantity               
     FROM cellar_entries
    WHERE id = 88213;   -- 2
3.                               BEGIN
4.                               SELECT quantity
                                   FROM cellar_entries
                                  WHERE id = 88213;   -- 2
5. UPDATE cellar_entries
     SET quantity = 1           -- computed in the app: 2 - 1
    WHERE id = 88213;
6. COMMIT
7.                               UPDATE cellar_entries
                                   SET quantity = 1   -- also 2 - 1
                                  WHERE id = 88213;
8.                               COMMIT

Two bottles consumed. quantity = 1. One bottle unaccounted for.

Both transactions are individually correct. Both read a true value. The CHECK (quantity >= 0) constraint does not fire, because 1 is a perfectly legal quantity. Nothing errors, and the cellar is now wrong by one bottle — discovered eventually by a user who counts.

Read skew — reading two related values from different moments and computing a total that was never true at any instant. This is the anomaly that corrupts reports rather than rows.

Write skew — two transactions each read an overlapping set, each make a decision that is correct given what they read, and each write to different rows, so nothing conflicts and the combined result violates an invariant neither transaction broke alone. Concretely: a rule that a shared cellar must retain at least one owner; two administrators concurrently demote the two remaining owners; each sees the other still in place; both commit; the cellar has zero owners. This one matters most because, as the next section shows, Postgres's REPEATABLE READ still permits it.

Two-panel interleaving timeline showing a cellar-value report reading two rack subtotals while a transfer commits between the reads, producing read skew at READ COMMITTED and a consistent total at REPEATABLE READREAD COMMITTED — a snapshot per statementREPEATABLE READ — one snapshot per transactionT1 · reportT2 · move case B→ABEGINSUM rack A → 129000BEGINUPDATE …rack='A' (case worth 21500)COMMITSUM rack B → 86000COMMITnow visible to T1's next statementReport total: 215000rack A read before the move (129000, without the case)rack B read after the move (86000, also without it)The 21500 case is in neither subtotal. This total wasnever true at any instant — read skew.T1 · reportT2 · move case B→ABEGIN ISOLATION LEVEL REPEATABLE READSUM rack A → 129000BEGINUPDATE …rack='A'COMMITSUM rack B → 107500COMMITinvisible — T1 holds its opening snapshotReport total: 236500both subtotals read from the same instantthe case appears exactly once, in rack BSame transactions, same timestamps, same data —only the guarantee changed.
Figure 6.1 — A total that was never true. At READ COMMITTED each statement gets a fresh snapshot, so the report reads rack A before a case is moved and rack B after, and the 21,500-cent case appears in neither subtotal — the sum is internally consistent-looking and corresponds to no moment in time. At REPEATABLE READ the whole transaction reads from one snapshot taken at its first statement, so the case is counted exactly once. The transactions, the timing, and the data are identical; only the isolation guarantee differs.
From your other domain

Read skew is how a compliance report ends up asserting something that was never true — not through a wrong query or bad data, but through reading pieces of a moving system at different instants. If you have ever reconciled a report against a source system and found a discrepancy nobody could reproduce, this is a leading candidate: the report was correct about two moments, and no moment.

Postgres's actual levels

The ANSI standard defines four isolation levels by the anomalies they must prevent. That is the key to reading them correctly: the levels are upper bounds on permitted anomalies, not descriptions of implementations. An engine is free to be stricter, and Postgres is — which means "we run at READ COMMITTED" describes a different set of possible failures on Postgres than on MySQL or Oracle. Portability claims about isolation must be re-verified per engine, every time.

Postgres offers three distinct levels (requesting READ UNCOMMITTED gets you READ COMMITTED):

READ COMMITTED (the default) — each statement sees a snapshot taken at the moment that statement began. You never read uncommitted data, but two statements in one transaction can see different worlds. This is where read skew, non-repeatable reads, and phantoms live.

REPEATABLE READ — one snapshot for the entire transaction, taken at its first statement. This is snapshot isolation, and here is the part that matters: it is stronger than the ANSI name implies. ANSI REPEATABLE READ permits phantoms; Postgres's implementation does not, because a snapshot simply does not contain rows that were not there when it was taken. What it does still permit is write skew, since two transactions writing to different rows never conflict. And if your transaction tries to update a row that changed after your snapshot, you get a serialization failure (error 40001) rather than a silent overwrite.

SERIALIZABLE — Serializable Snapshot Isolation. Postgres tracks read/write dependencies between concurrent transactions and aborts one if the set could not have arisen from any serial execution. This is genuine serializability, including write skew. The price is real and unavoidable: transactions can fail with 40001 at commit, and you must retry them. A codebase using SERIALIZABLE without a retry loop is not using SERIALIZABLE; it is using intermittent errors.

AnomalyREAD COMMITTEDREPEATABLE READSERIALIZABLEExample in this schema
Dirty readNeverNeverNeverPostgres never exposes uncommitted rows at any level
Non-repeatable readPossiblePreventedPreventedReading entry 88213's quantity twice and getting 2 then 1
PhantomPossiblePrevented (stricter than ANSI)PreventedRe-counting user 1042's entries and finding a new one appeared
Read skewPossiblePreventedPreventedRack A and rack B subtotals read from different instants
Lost updatePossiblePrevented (raises 40001)PreventedTwo devices consuming from entry 88213
Write skewPossibleStill possiblePreventedTwo admins demoting the last two owners of a shared cellar
The portability trap

"REPEATABLE READ permits phantoms — it is in the standard" is a true statement about ANSI and a false statement about Postgres. Teams get burned in both directions: they add defensive locking Postgres does not need, or they carry a mental model from MySQL where REPEATABLE READ is the default and behaves differently. Test the anomaly on the engine you actually run, and write down what you found.

Patterns that survive concurrency

Four ways to make consume-a-bottle safe, in the order you should reach for them.

1. The atomic statement. Do the read, the decision, and the write in one statement, so no interleaving exists:

UPDATE cellar_entries
   SET quantity = quantity - 1
 WHERE id = 88213 AND quantity > 0
RETURNING quantity;

Postgres locks the row for the duration of the update; a concurrent writer blocks, then re-evaluates quantity > 0 against the committed value. If the row is gone to zero, zero rows are returned and the application knows the operation did not happen — the return value is the answer, with no separate check. The CHECK (quantity >= 0) constraint remains as defense in depth: if anyone ever writes a computed value instead of this form, the constraint catches it. This is the default choice, and it should be uncomfortable to depart from it.

2. SELECT ... FOR UPDATE. When the decision genuinely requires application logic between read and write — checking a plan limit, computing a price from several rows — lock the rows pessimistically:

BEGIN;
SELECT quantity, purchase_price_cents
  FROM cellar_entries
 WHERE id = 88213
   FOR UPDATE;              -- other transactions block here
-- application logic runs
UPDATE cellar_entries SET quantity = 1 WHERE id = 88213;
COMMIT;

This serializes access to the hot row, which is the cost: contention scales badly if that row is popular. Use FOR UPDATE SKIP LOCKED when you are pulling work from a queue and want the next unlocked row rather than to wait.

3. SERIALIZABLE with a retry loop. When the invariant spans rows in a way that no single statement or row lock captures — the write-skew shape — let Postgres detect the conflict and retry on 40001:

for attempt in range(3):
    try:
        with conn.transaction(isolation_level="SERIALIZABLE"):
            owners = count_owners(cellar_id)
            if owners <= 1:
                raise LastOwnerError()
            demote(member_id)
        break
    except SerializationFailure:      # SQLSTATE 40001
        sleep(0.05 * 2 ** attempt + random.uniform(0, 0.05))
else:
    raise TooManyRetries()

Two requirements are not optional. The retry must be bounded, and the transaction body must be safe to run again — which is to say idempotent, or free of side effects outside the database. A retry loop that re-sends an email each attempt has traded a data bug for a customer-facing one.

4. An application-level mutex. Named here to reject it. A lock in your process coordinates the threads in that process, and your second application instance knows nothing about it. This is the check-then-act race wearing a hat. (A distributed lock in Redis is a different proposal with different failure modes — but if you find yourself designing one to protect a single row in a database that already offers row locks, stop.)

Two interleavings compared: a read-decide-write sequence losing an update, and the atomic UPDATE with RETURNING where the second writer blocks and re-evaluates the predicateRead → decide → write (two statements)Atomic UPDATE … RETURNINGphonetabletSELECT quantity → 2SELECT quantity → 2app decides: 2-1 = 1app decides: 2-1 = 1UPDATE SET quantity=1UPDATE SET quantity=1quantity = 1 · two bottles consumedThe second UPDATE overwrites with a numbercomputed from a read that is no longer true.No error: CHECK (quantity >= 0) still holds.phonetabletUPDATE … SET q = q-1 WHERE id=88213 AND q>0RETURNING q → 1same UPDATE issued… blocked on row lockCOMMITre-evaluates q>0 vs 1RETURNING q → 0quantity = 0 · two bottles consumedThe second writer blocks on the row lock, thenre-runs its predicate against committed data.A third attempt matches zero rows, and says so.
Figure 6.2 — Closing the window. On the left, the decision is made in application memory from a value that another transaction invalidates before the write lands, so the second update overwrites the first and no error is raised. On the right there is no window: the predicate and the arithmetic are inside one statement, the second writer blocks on the row lock and re-evaluates against committed data, and RETURNING tells the application what actually happened.
Keep transactions short

A transaction holds locks and pins a snapshot for its entire life. Opening one, calling a third-party pricing API, and committing after the response means every concurrent writer to those rows waits on a network call you do not control — and a vacuum cannot clean up rows newer than your snapshot, so long transactions cause bloat as well as blocking. The pattern: read what you need, close the transaction, do the slow work, then write in a fresh short transaction with a version check (WHERE ... AND version = $1) so you notice if the world moved.

Across the series

The retry loop and Guide Nº 07's idempotency keys are the same problem from two sides. Here you retry because the database told you to; there the client retries because the network gave it no answer. Both are only safe if repeating the operation is harmless, which is why the version check above is the database-side analogue of an If-Match header and its ETag.

Module 07 Postgres in practice

Everything so far has been relational theory with a Postgres accent. This module is the specifics: which types are worth choosing deliberately, when a document column is the right answer and when it is the schema you failed to write, how to model a closed set of values, and how to change a live table without taking the product down.

The lens stays the same. A type is an invariant — numeric versus float8 is a claim about whether your arithmetic is exact, timestamptz versus timestamp is a claim about whether a moment is unambiguous. And a migration is an invariant change applied to data that already exists and traffic that does not stop, which is why the reviewer's question is not just "is this schema better" but "what lock does this take, and for how long."

The type toolbox

Types are the cheapest invariants available, and the defaults people reach for are frequently wrong in ways that surface much later.

Money is never floating point. float8 cannot represent 0.1 exactly, so summing 40,000 prices for a portfolio report accumulates error, and finance opens a ticket about a discrepancy of a few cents that nobody can reproduce in a unit test. Use integer cents, as the running schema does with purchase_price_cents, or numeric(12,2) where fractional units are real. Integer cents also removes an entire category of rounding argument at the API boundary, since there is no fractional value to round.

Time is timestamptz, always. The name is misleading: timestamptz does not store a time zone. It stores an absolute moment (UTC internally) and renders it in the session's zone, which is what you want. timestamp without time zone stores a wall-clock reading with no indication of where the clock was — it stores a lie, and the lie compounds when your first non-US user files a bug about a purchase dated tomorrow. The exception is a genuine calendar date with no instant attached: purchased_on is a date, because the domain fact is "the day I bought it," not a moment.

Exactness where it matters. numeric(3,1) for abv and numeric(4,3) for a recommendation score are deliberate: both are compared and displayed, and a score of 0.847 must not become 0.8470000000000001 in a JSON response.

Text. Prefer text over varchar(n) unless the length limit is a real domain invariant — in Postgres they perform identically, and an arbitrary varchar(50) is a migration waiting to happen the first time a producer name runs long. citext for email gives case-insensitive comparison and uniqueness in one move, which is why the running schema uses it: UNIQUE on a plain text email column happily admits both Elena@marchpane.example and elena@marchpane.example, and now the account recovery flow has two candidates.

Arrays are the honest middle ground for the m3 grapes problem. A text[] of grape varieties is a small ordered set with no attributes of its own and no foreign keys needed, and Postgres can index it with GIN so containment queries (grapes @> ARRAY['Merlot']) are fast and exact — no substring false positives. The moment grapes need attributes (a color, a canonical name, a per-grape article), it becomes a table. The array is not a way to avoid the junction table; it is what you use when the junction table would have exactly two columns forever.

From your other domain

The float-money discrepancy is small until it is evidence. A report that is off by four cents is an annoyance; the same report produced under discovery, with a rounding methodology nobody documented and results that cannot be reproduced from the source data, is a much worse afternoon. Exact types are how you make the arithmetic re-derivable by someone hostile.

jsonb without schema avoidance

jsonb is an excellent tool with a narrow brief. It stores a parsed, binary JSON document; it supports containment and path queries; it can be indexed with GIN. Two situations genuinely call for it.

Shapes you do not control. The recommendation engine's raw scoring output — feature weights, model version, per-signal contributions — is stored for audit and debugging. Its structure changes when the model changes, you do not query into it in normal operation, and the reason you keep it is to answer "why did we recommend this" six months later. That is a document, and forcing it into columns would mean a migration every time the model team ships. The running schema keeps the decision in typed columns (score numeric(4,3) NOT NULL CHECK (score BETWEEN 0 AND 1), reason text NOT NULL) and could keep the provenance blob in jsonb alongside — the invariants stay enforced, the opaque payload stays opaque.

Genuinely sparse attributes with no invariants. Imported metadata from a partner catalog where every partner sends different fields and none of them drive behavior.

Everything else is a column. The test is four questions, and one yes is enough:

QuestionIf yesExample from the running schema
Would you CHECK it?Columnquantity >= 0 cannot be enforced on a jsonb path in any way you will maintain
Would you foreign-key it?Columnvintage_id inside a document references nothing
Would you join or filter on it?ColumnSorting the cellar by purchased_on means casting a text path on every row
Would you sum or aggregate it?ColumnCellar value requires (data->>'price')::int — a cast, a plan the planner cannot estimate, and a runtime error the day one row holds a non-numeric string
Anti-pattern — jsonb as schema avoidance

Symptom: a data jsonb column holding fields the product depends on — quantity, price, status — with the only description of its shape living in application code (or in the prompt that generated it). Reads are full of (data->>'x')::type casts, and nothing prevents a row from having no x at all. Why it fails: every invariant m1 catalogued is unavailable, and the schema stops documenting itself — new engineers learn the shape by reading serializers. Corrective: apply the four-question test and extract the fields that answer yes into typed columns with constraints. The extraction migration is expand → backfill → contract (next section), and it gets harder every month you wait, because the variety of shapes in the column only grows.

Decision flowchart routing a field to a typed column, lookup table, array, or jsonb based on whether it carries invariants, is queried, and whether you control its shapeA field to storewhere does it go?Would you CHECK it,FK it, filter, or sum it?one yes is enoughyesTyped column with constraintsquantity integer NOT NULL CHECK (quantity >= 0)noDoes it carry attributesof its own?now or plausibly soonyesIts own table, referenced by FKregions the day appellation rules attach (m2 §4)noDo you controlits shape?is it stable?yesArray, GIN-indexedgrapes text[] · grapes @> ARRAY['Merlot']nojsonb — and only hererecommendation engine's raw scoring output, keptfor audit; partner metadata that drives nothingEvery arrow that skipsto jsonb from the firstquestion is theanti-pattern.
Figure 7.1 — Four exits, one of them jsonb. A field earns a typed column the moment you would constrain, reference, filter, or aggregate it; it earns its own table when it carries attributes; it earns an array when it is a small stable set with neither. jsonb is what remains — shapes you do not control and do not query — and reaching it by skipping the first question is precisely the schema-avoidance anti-pattern.

Closed value sets: enum, lookup table, CHECK

A membership role, a bottle size, an order status: a small set of allowed values. Three tools, and the choice turns on two questions — who needs to extend the set, and do the values carry attributes of their own?

ENUM typeLookup table + FKCHECK constraint
Extending the setALTER TYPE ... ADD VALUE, cheap and non-blockingINSERT — a data change, so product or ops can do itALTER TABLE to replace the constraint; validates existing rows
Removing a valueAwkward: no DROP VALUE; requires a new type and a column rewriteDELETE, and the FK stops you if rows still use itStraightforward, and the new constraint's validation tells you if rows violate it
Values carry attributesNoYes — display name, sort order, permissions, deprecation flagNo
Referential integrityType-levelReal FK, joinablePredicate only
Storage and query4 bytes, sorts in declaration orderNeeds a join for the labelStores the text itself
Best forStable sets owned by engineeringSets that grow, are edited by non-engineers, or need attributesSmall, rarely-changing sets where a type feels heavy

In practice: bottle size is an ENUM ('375ml', '750ml', '1.5L', '3L') — the set is fixed by the physical world, engineering owns it, and sorting in declaration order is a small free win. Membership role from the m2 worksheet is an ENUM for the same reason. If roles later needed permission bitmaps or display names, that would be the signal to promote to a lookup table — the same promotion rule as m2 §4, applied to a value set instead of an attribute.

Anti-pattern — rejecting enums into unconstrained text

Symptom: a status or role column typed text with no constraint, justified by "enums are rigid, you cannot remove values." Six months later the column contains Active, active, ACTIVE, and one actve, and every query carries a lower() call and a mental list of variants. Why it fails: the cost of enum rigidity was weighed against zero cost, when the real alternative was a lookup table. Corrective: use an enum or a lookup table — both prevent this — and treat value removal with the documented pattern (add the new type, migrate the column, drop the old), which is a scheduled hour, not an impossibility.

Migrations discipline

A migration is a schema change applied to data that already exists while traffic continues. Two failure modes matter: the change locks a table long enough to stall the application, and the change is not safely reversible if the deploy goes wrong.

The AI-review angle returns here with teeth. Ask an assistant to add a required column and you will get a single, clean, correct-looking statement — one that takes an ACCESS EXCLUSIVE lock on a 2.1-million-row table and rewrites it while every query waits behind it. The statement is not wrong; it is unreviewed for lock behavior, and lock behavior is the reviewer's job. Know these:

OperationLock and costSafe form
ADD COLUMN nullable, no defaultBrief ACCESS EXCLUSIVE, metadata onlySafe as written
ADD COLUMN ... NOT NULL DEFAULT ...Safe in Postgres 11+ (no rewrite); still validates against existing rowsSafe, but verify your version and that the default is not volatile
ADD CONSTRAINT ... CHECK / FOREIGN KEYScans and validates the whole table under a strong lockAdd NOT VALID, then VALIDATE CONSTRAINT in a separate statement (weaker lock, no blocking)
CREATE INDEXBlocks writes for the buildCREATE INDEX CONCURRENTLY, outside a transaction, and check for an INVALID index afterward if it fails
ALTER COLUMN ... TYPEFull table rewrite under ACCESS EXCLUSIVEExpand/contract: new column, backfill, switch reads and writes, drop old
Bulk UPDATE backfillLong transaction, bloat, replication lagBatch by primary key with a commit per batch and a progress query

The pattern that makes all of this safe is expand/contract, and its property is that every step is independently deployable and independently reversible.

Expand — add the new shape alongside the old, nullable and unconstrained. Nothing reads it; nothing breaks. Dual-write — deploy code that writes both shapes, so new rows are correct in both. Backfill — copy old to new in batches, with a progress query so you know where you are. Switch reads — deploy code reading the new shape; the old is still maintained, so a rollback is a code deploy rather than a data recovery. Contract — after a soak period, add the constraints, stop dual-writing, drop the old columns.

Timeline comparing a single blocking ALTER migration against the expand, dual-write, backfill, switch and contract sequence with the lock each phase takesThe one-shot migrationALTER TABLE … ADD COLUMN … NOT NULL; CREATE INDEX …ACCESS EXCLUSIVE — every read and write queues behind it2.1M rows · ~90 s · no rollback point once the rewrite startsExpand → backfill → contract1 · Expandadd rack_id, nullablemetadata lock, ms2 · Dual-writecode writes bothno lock — a deploy3 · Backfillbatches of 5,000row locks only, brief4 · Switch readsindex CONCURRENTLYwrites continue5 · ContractNOT NULL, drop oldNOT VALID + VALIDATEdeploy Adeploy Bscriptdeploy Cdeploy Drollback: droprollback: revertrollback: stoprollback: revertrollback: none neededThe property that mattersEvery phase ships alone and reverses alone. Between phases the system is fully working on bothshapes, so a bad deploy is a code rollback rather than a data recovery — and the soak timebetween 4 and 5 is deliberate, not slack.
Figure 7.2 — Two ways to change a live table. The single ALTER takes an ACCESS EXCLUSIVE lock for the duration of a full rewrite, so every query queues behind it and there is no rollback point once it starts. Expand/contract achieves the same end state through five independently deployable steps, each taking only a brief or row-level lock, with the system fully functional on both shapes in between.

Two closing rules. Migrations are forward-only — a "down" migration that drops a column you have been writing to for a week does not restore the data, so the real rollback plan is the previous deploy plus the old shape still being present, which is exactly what expand/contract preserves. And rehearse against a copy at production scale; the timing difference between 10,000 rows and 2.1 million is the difference between a migration and an incident.

Module 08 Relational vs. single-table

Seven modules have argued for the relational model on its own terms. This one asks the question that should have been asked first: is it the right model for this system? The answer is not always yes, and the argument for the alternative is stronger than most relational advocates admit — which is why this module is written to be fair enough that a DynamoDB practitioner would recognize their own position in it.

The decision reduces to one question with several honest qualifiers. Everything else — scale, cost, team fluency — is real but secondary, and the failure mode in both directions is choosing by fashion and then discovering the cost eighteen months later, when moving is expensive. The module closes with the guide's anti-pattern catalog, arranged as symptom to diagnosis to corrective, because the last thing you need from a course like this is the ability to name what you are looking at.

Two philosophies of certainty

The relational bet is: model the domain, ask questions later. You capture entities, relationships, and invariants without reference to any particular query, and the planner figures out how to answer whatever you ask. When a new question arrives — "average rating by region over the last 90 days" — you write it, and it works, possibly slowly until you add an index. The schema is query-agnostic by construction.

The single-table bet is: enumerate the access patterns first, then shape storage to serve them. In DynamoDB's single-table design, you list every query the application will make, design partition and sort keys so each is a direct key lookup or a narrow range scan, and store heterogeneous item types together so that one request retrieves a user and their cellar entries in a single round trip at predictable cost. Queries are decided at design time and served at fixed latency regardless of table size.

Neither is the default. They are bets about how well you can predict the questions — and the same product can justify either answer at different stages of its life, which is why this is a decision with a review date rather than a permanent identity.

Across the series

Guide Nº 01 teaches single-table design properly — item collections, key overloading, sparse and inverted GSIs, and the modeling process that starts from an access-pattern list. This module does not re-teach it; it argues the choice. If the single-table side of the comparison below feels compressed, that is where the full treatment lives.

What each side genuinely buys

Each pitch below is written as its advocate would write it, because a comparison you win against a straw man tells you nothing about your own system.

The case for DynamoDB single-table. Reads are single-digit milliseconds at any table size, because a key lookup is a hash and a disk read — there is no planner, no statistics, no plan regression at 3am when a row estimate shifts. There are no connection pools to size and no vacuum to tune; scaling is a configuration value, not a project. The operational surface is genuinely someone else's pager. And the two objections relational advocates reach for first are both out of date: TransactWriteItems provides real ACID transactions across up to 100 items, and strongly consistent reads are available per-request at double the read cost. Condition expressions enforce per-item invariants at write time — a conditional write that fails is the same mechanism as a constraint violation. Cost is predictable per request rather than per provisioned instance, which for spiky workloads is materially cheaper.

What it charges. A new access pattern that your keys do not serve requires either a new global secondary index or reshaping the data — a migration of a different and often larger kind than adding an index in Postgres. Ad hoc joins and aggregates do not exist; "average rating by region for the last 90 days" is a scan or an export to an analytics system, not a query. Invariants that span items — the multi-row rules from m1 §5, and any uniqueness constraint on a non-key attribute — largely move into application code, which means re-litigating this entire guide's first module by hand, in every service. And the transaction limits are real: 100 items, no interactive transactions, no "read, think, write" across a network call.

The case for Postgres. New questions cost a query, not a migration — which for a product still discovering what it is worth is the dominant consideration. Every invariant in m1 is enforced by one component on every write path. Transactions span arbitrary rows and tables with real isolation guarantees. One system serves both product and analytics at moderate scale, so there is no pipeline between the operational store and the ability to answer a question. And the ecosystem is deep: every ORM, every BI tool, every engineer you hire.

What it charges. You own the planner's moods — plan regressions, stale statistics, the occasional query that was fast for a year and is not. You own connection management, and a serverless architecture will need a pooler in front. You own vacuum, bloat, and the operational cost of a primary you cannot horizontally scale by configuration. Past a very large single node, the scaling story is real work: read replicas, then partitioning, then sharding, in ascending order of pain.

The wine-cellar domain modeled two ways: six normalized relational tables versus a single-table item collection with partition and sort keys, annotated with what each serves and what it cannot answerRelational — six tables, query-agnosticSingle-table — one collection, pattern-shapeduserswinesvintagescellar_entriestasting_notesrecommendationsServes any question you have not thought ofSELECT w.region, avg(tn.rating) FROM tasting_notes tn JOIN … JOIN … WHERE tn.tasted_on > now() - '90 days' GROUP BY w.region;Written in two minutes. No migration, no new indexrequired for correctness — only for speed.PK SKUSER#1042 PROFILEUSER#1042 ENTRY#2024-03-15#88213USER#1042 ENTRY#2024-01-08#88110USER#1042 NOTE#2024-11-15#45710WINE#214 METAWINE#214 VINTAGE#2016GSI1: VINTAGE#3187 → ENTRY#…, NOTE#…One query on PK = USER#1042 returns the profile,every entry, every note — one trip, any table size.Serves at fixed cost· a user's whole cellar · one entry by id· everything about a vintage (via GSI1)Cannot answer without new infrastructure"average rating by region, last 90 days" — region is nota key; needs a new GSI, a scan, or an analytics export.
Figure 8.1 — The same domain, two shapes. The relational schema stores entities and relationships without reference to any query, so a question nobody anticipated is a two-minute SELECT. The single-table design stores one item collection keyed to serve enumerated patterns, returning a user's entire cellar in one round trip at fixed cost — and answering an unanticipated aggregate only through a new index, a scan, or an export. Both diagrams describe a working product; they differ in which future is cheap.

The decision framework

The load-bearing question: how well are the access patterns known in advance, and how stable are they? If you can write them all down today and believe the list in a year, single-table is a strong choice and its constraints cost you little. If the list is a guess — a product still finding its shape, an analytics surface that grows with the business — then the relational bet is the one that pays, because the cost of a wrong prediction is paid in migrations rather than in queries.

Four secondary axes, in the order that usually matters:

Measured scale, not aspired scale. 2.1 million rows is not a scaling problem. Ten million is not a scaling problem. A single well-indexed Postgres node handles workloads far larger than most teams predicting they will outgrow it. Bring a number: current row counts, current writes per second, current p99. If you cannot produce them, that is the finding.

Invariant density. How much of m1's toolkit does this domain actually need? Financial records, permissions, inventory, anything with regulatory exposure — high density, and moving those invariants into application code is a large, permanent tax. An event log or a session store has almost none, and the relational advantage largely evaporates.

Analytics needs. If the product's questions are its value — dashboards, cohort analysis, ad hoc investigation — ad hoc queryability is the feature, not a convenience.

Team fluency. Single-table design done badly is worse than either option done well, and it is genuinely harder to learn: the modeling process is unfamiliar, and mistakes surface as "we cannot answer that" months later. This is a legitimate input, not an excuse.

And the hybrid, named without hedging: Postgres as system of record with a specific proven hot path offloaded to DynamoDB (or a cache) is a common, honest end state. The rule is to draw the boundary along the access-pattern-certainty line — offload the read-heavy, pattern-stable, invariant-light workload, and keep the invariant-dense writes where the constraints and transactions are.

Decision flowchart from access-pattern stability through invariant density and measured scale to relational, single-table, or hybrid outcomes, with fashion-driven inputs shown entering a danger pathCan you enumerateevery access pattern?and believe it in a yearyesno / unsureIs invariant densitylow?yesnoSingle-tableaccepting: new questions cost a GSI or a reshapeRelational — the invariants decide itaccepting: you own planner tuning and scalingMeasured scale pastone large node?no — and bring the numberyes, measuredIs the hot pathpattern-stable?yesHybrid — boundary on the certainty linePostgres keeps the invariant-dense writes; thestable read path moves. Two systems to run."it scales" · "we might need it someday" · résuméChoice by fashionThe cost lands in 18 months, as a pipeline forevery question and a migration you cannot plan.
Figure 8.2 — Where the decision actually turns. Access-pattern certainty is the first gate and invariant density the second; measured scale enters only after both, and it requires a number rather than a projection. The hybrid exit is legitimate when the hot path is stable and read-heavy — the boundary follows access-pattern certainty, not component names. The dashed input is the path where the inputs are aspiration rather than measurement, and it leads somewhere real.

The anti-pattern catalog

The guide's field reference, arranged the way you will meet these — symptom first, because that is what you are handed.

Observable symptomDiagnosisCorrectiveModule
Duplicate rows exist despite a validation functionCheck-then-insert race; only the database sees all concurrent writersUNIQUE constraint; handle 23505 as a documented outcomem1
A joined export undercounts against the in-product numberOrphaned rows from missing referential integrityClean orphans, add the foreign key with an explicit ON DELETE policym1
A count query silently omits rowsThree-valued logic: != against NULL is unknownIS DISTINCT FROM, and decide whether NULL is a real domain statem1
Table names match screens; plurals live in array columnsSchema models the prompt or the UI, not the domainExtract entities from identity and invariants; run the cardinality question on every relationshipm2
Two screens report different values for one factUpdate anomaly from redundant storageNormalize, or keep the copy with a maintenance plan and drift detectionm3
A count or total column nobody can explain the update path forUnmanaged denormalization; a cache with no ownerTrigger-based maintenance plus a reconciliation query — or DROP COLUMNm3
Historical values change when a lookup row is editedA snapshot fact was normalized awayRestore the value to the event row; annotate it so a future refactor leaves it alonem3
An index per column; writes slower; queries no fasterIndexes by superstition, added without reading a planIndex for a named query, verify with EXPLAIN, drop what pg_stat_user_indexes shows unusedm4
Insert latency degrades with table growth; index bloatUUIDv4 keys destroying insert localitybigint identity, or UUIDv7 where unguessable ids are a real requirementm4
A plan whose estimated rows are orders of magnitude off actualStale or insufficient statistics, not a missing indexANALYZE; extended statistics for correlated columnsm4
Rows expected in a report are missing entirelyOuter join annihilated by a WHERE on the optional sideMove the predicate into ONm5
Totals inflated in proportion to child-row countsFan-out: aggregating after a 1:N joinEXISTS to filter, or pre-aggregate to the target grain then join — never DISTINCTm5
Latency grows with list length; logs full of identical queriesN+1 from ORM lazy loadingEager load or batch with = ANY($1); assert query count in the testm5
Inventory drifts under load with no errors loggedCheck-then-act race across statementsOne atomic UPDATE ... RETURNING; FOR UPDATE or SERIALIZABLE plus retry when logic must intervenem6
Unrelated writes stall when a vendor API is slowTransaction scope bloat — locks held across a network callRead, commit, do slow work, write in a fresh transaction with a version checkm6
Intermittent 40001 errors treated as a database bugSSI working as designed without the required retry loopBounded retry with backoff; ensure the transaction body is safe to re-runm6
Casts everywhere; nothing prevents impossible valuesjsonb used to avoid schema designApply the four-question test; extract to typed columns via expand/contractm7
Small unreproducible discrepancies in financial totalsFloating-point moneyInteger cents or numeric; migrate rather than round at displaym7
A status column holding three casings of one valueUnconstrained text where a closed set belongsEnum or lookup table, chosen by whether values carry attributesm7
A deploy freezes all writes for a minuteLock-blind migrationNOT VALID plus VALIDATE, CREATE INDEX CONCURRENTLY, batched backfills, expand/contractm7
Every new dashboard question requires a data pipelineModel chosen by fashion, against unstable access patternsRe-derive the decision from measured scale and pattern stability; consider a hybrid boundarym8

Twenty-one entries, and the useful property of the list is that the left column is what someone actually brings you — a ticket, a graph, a complaint — while the middle column is the thing you had to know in advance to recognize it.

The habit, restated

One question ran this entire guide: what invariant is at stake, and which layer enforces it? It is worth seeing it applied to the three artifacts you will actually be handed.

Reviewing a proposed schema — generated or human. What illegal states can this represent? For each business rule the product believes, which layer enforces it, and is "nobody" on the list? Is every natural key declared UNIQUE alongside its surrogate? Is every relationship's cardinality declared and its key on the correct side? Does any plural stored in a single column have a defended reason not to be a table? For the three questions the product will obviously ask next, can this schema answer them without a migration? Four minutes, and it catches more than a careful read of the application code will.

Reviewing a slow query with its plan — start with estimated versus actual rows, because a plan built on a bad estimate is a rational answer to a false premise and no index will fix it. Then ask what grain the result claims and whether any join could have changed it. Then check whether the proposed index is justified by this named query and whether its column order matches the predicate and the sort. Then ask what it costs on the write side and whether it makes an existing index redundant.

Reviewing a migration — what lock does each statement take and for how long on production row counts? Is each step independently deployable and reversible, and where is the point of no return? What happens to rows that violate the new constraint, and have you counted them? Is the backfill batched, and does it have a progress query? And the one people skip: what is the soak period, and what signal would tell you to stop?

None of this requires being a database expert. It requires holding one question steadily and refusing to accept "the application handles it" as an answer to it — because the application handles it on the code paths that exist today, and the schema handles it on all of them, forever. That asymmetry is the whole guide.

The load-bearing idea

You will increasingly review data-layer code you did not write, generated by systems that model the request rather than the domain and validate in the handler rather than the schema. The defense is not to write more of it yourself. It is to read every schema, query, and migration for what it actually guarantees — and to insist that every invariant the business believes has a named enforcer, in writing.

Concept index

Invariant
A statement about your data that must hold on every row, on every write, forever — the unit of schema design.
Primary key
The enforced identity of a row; the thing every reference means.
Surrogate key
A meaningless generated identifier used for referencing, kept alongside — not instead of — the declared natural key.
Natural key
The column set the domain itself considers identity (producer plus name; wine plus year) — declare it UNIQUE even when a surrogate holds the foreign-key traffic.
Foreign key
The constraint that every reference resolves — referential integrity enforced at write time, not audited after the fact.
CHECK constraint
A row-level predicate the database enforces on every write; the cheapest control you will ever deploy.
Three-valued logic
SQL's NULL semantics: comparisons with NULL are neither true nor false, and WHERE keeps only true.
Cardinality
How many of each side a relationship allows (1:1, 1:N, M:N) — it decides where the foreign key lives.
Junction table
The table that realizes an M:N relationship, and the home of the relationship's own attributes.
Entity
A domain noun with independent identity and invariants of its own — the test for deserving a table.
Functional dependency
This attribute's value is determined by that key — the working concept behind all the normal forms.
Update anomaly
The corruption redundancy invites: the same fact stored twice, updated once, now disagreeing with itself.
Third normal form
Every non-key attribute depends on the key, the whole key, and nothing but the key.
Denormalization
Deliberately reintroducing redundancy for read performance — legitimate only with a written maintenance plan.
Snapshot vs cache
The load-bearing test: a snapshot records history (the price paid) and must not be normalized away; a cache is derivable and can drift.
B-tree
The ordered index structure behind almost every index you will meet: equality, ranges, and prefix ordering.
Composite index
An index on (a, b): sorted by a, then by b within a — servable only via the leftmost prefix.
Selectivity
The fraction of rows a predicate keeps; the planner's reason to use — or rightly ignore — your index.
Query planner
The cost-based estimator that chooses among access paths using statistics; argue with it via EXPLAIN, not hints.
EXPLAIN ANALYZE
The plan plus what actually happened — estimated versus actual rows is the first diagnostic in the book.
Index locality
How clustered an index's inserts are; random UUIDv4 keys scatter them, ordered keys keep hot pages hot.
Outer join
A join that preserves unmatched rows from one side as NULLs — until a careless WHERE filters them back out.
Grain
What one row of a query result means; aggregation is the act of changing it on purpose.
Fan-out
The row multiplication a 1:N join introduces — the reason you aggregate before joining, not after.
Window function
An aggregate computed over a partition without collapsing rows — rank, LAG, running totals.
CTE
A named query stage (WITH ...) that turns a nested question into a readable pipeline.
N+1
One query for the list, one more per item — the ORM lazy-loading default that scales latency with row count.
Transaction
The multi-statement unit of atomicity — how invariants that span several steps survive failure and concurrency.
Isolation level
The contract for which concurrent interleavings a transaction can observe — named by ANSI, defined by your engine.
Read skew
One transaction reading two values from different moments and computing a total that was never true.
Lost update
Two read-decide-write transactions both acting on the same stale read; the later write erases the earlier one.
Snapshot isolation
Postgres REPEATABLE READ: one consistent snapshot per transaction — stronger than the ANSI name implies, but write skew survives.
Serialization failure
Error 40001 under SERIALIZABLE: the mechanism working as designed, and your obligation to retry.
jsonb
Postgres's indexed document type — for shapes you do not control, never for dodging the schema you do.
Expand/contract
The zero-downtime migration pattern: add the new shape, backfill and dual-write, then remove the old — each step deployable alone.
Access pattern
A query shape known at design time — the currency single-table design trades in, and the certainty it demands.
Single-table design
DynamoDB's discipline of shaping storage to enumerated access patterns for fixed-cost reads at any scale.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.