Data first: model the data, the rest follows
Model entities, owners, rules, states and write paths before the API; then make the database refuse any write that breaks a rule.
Predict first
Before you read: predict
ExamHall's attempts table holds one row per attempt: id, candidate_id, exam_id and status. A candidate may retake an exam after submitting. Last week the proctor console's Reopen button ran this on a submitted attempt while the same candidate's retake was in progress:
UPDATE attempts SET status = 'active' WHERE id = $1;
The team will add one rule to the table. Afterwards, a candidate with a submitted attempt must still be able to start a retake and later submit it, and that Reopen must fail.
Which rule?
The 30-second version
Start a feature from its data: entities, who writes each fact, rules that must always hold, states and allowed moves, and every path that writes. Then put each rule where every write passes: a database constraint, not an if. A rule over some rows is a partial unique index; a rule about a move needs the old row.
Model entities, owners, rules, states and write paths before the API; then make the database refuse any write that breaks a rule.
How it works
Step 1 of 15
In the kitchen
- vehicles, keepers and keepership lines, settled before the forms → entities and who writes each fact (steps 1–2)
Analogy: A vehicle-licensing agency's keeper register
Before any form or counter exists, the agency settles its register: vehicles, keepers, and a keepership line for each sale.
| At the agency | In the data model |
|---|---|
| vehicles, keepers and keepership lines, settled before the forms | entities and who writes each fact (steps 1–2) |
| "one current keeper per vehicle", with past keepers listed below | an invariant over some rows: a partial index (steps 3, 10) |
| registered, off the road, scrapped; a scrapped car never returns under its old registration | states and illegal moves (steps 4, 11) |
| dealers, private sellers and scrapyards all notify the one register | every write path (step 5) |
| a seller who promises to check the register first | an if in one handler (step 6) |
| the register refusing a second current keeper, whoever reports the sale | a constraint (step 7) |
| a car already sold twice stays a dispute after the form is changed | data outlives code (step 13) |
In real terms: write down what must always be true and every way rows get written, then let the table refuse the writes that break it.
Breaks down when: a clerk can phone a seller to ask what was meant. A database refuses the write with an error code, and the application must decide what that refusal means for the user (step 12).
Contrasting case: a reading-room list of who borrowed which file is a log, not a register: two entries for one file are both true, and nothing needs refusing.
Examples, run for real
4.1 ExamHall EXH-2150: the double-click on "Start exam"
EXH-2150: a candidate's double-click on "Start exam" sent two requests. The handler looks for an active attempt and inserts one if it finds none. The script runs the two requests on two real sessions and sends both checks before either insert. MR !2450 adds the rule to the table and turns its refusal into "resume":
4.1 EXH-2150 as shipped (check, then insert): click 1 -> a-101 · click 2 -> a-102 · active attempts for c-17: 2
4.1 MR !2450 (partial unique index, 23505 -> resume): click 1 -> a-101 · click 2 -> a-101 (its insert refused 23505) · active attempts for c-17: 1
Both checks saw no active attempt, so both requests inserted (step 6; why each check passed belongs to db-isolation-anomalies). With the rule in the table, the second insert fails, and the handler answers with the attempt already active (steps 10, 12).
4.2 Proof domain, checkout: one rule, three write paths
A basket line must have a quantity above zero. Lines are written by the checkout API, by a support console and by a CSV import from a marketplace.
4.2 the checkout API validates qty > 0 lines with qty 0 written by: support console, CSV import · refused: checkout API (its own if)
4.2 CHECK (qty > 0) on the table, no if lines with qty 0 written by: none · refused: checkout API (23514), support console (23514), CSV import (23514)
The API's if guarded only the API (steps 5–6). The CHECK refused all three paths with one code, though no path changed (step 7).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A shop's orders table holds each order's status. An order may go from pending to paid, from paid to shipped, and from paid or shipped to refunded; no other move is allowed. The checkout API, the warehouse app and a support console that runs SQL by hand all update status. Yesterday the console ran this on order o-81, which had shipped, and checkout then charged it a second time:
UPDATE orders SET status = 'pending' WHERE id = 'o-81';
Which change stops every move outside those four, from every path, while each of the four still works?
Question 2 of 6
A wallet app keeps saved cards in cards(card_id, customer_id, is_default), and each customer should have at most one default card. A bug, now fixed in the app, gave two customers two default cards each:
card_id | customer_id | is_default
k-1 | cu-1 | true
k-2 | cu-1 | true
k-3 | cu-2 | true
k-4 | cu-2 | true
k-5 | cu-3 | true
k-6 | cu-3 | false
The team then runs these two statements, one after the other:
CREATE UNIQUE INDEX one_default ON cards (customer_id) WHERE is_default;
INSERT INTO cards VALUES ('k-7', 'cu-3', true);
What happens?
Question 3 of 6
A ticket shop sells guided museum tours and keeps them in tours(guide_id, during), where during is a time range of 30, 60 or 90 minutes. No guide may lead two tours whose times overlap. Tours are written by the box office, by a support console that can move a tour, and by a partner's nightly import, and any two of them can write at the same moment.
Which choice has the database itself refuse every write that would leave a guide with two overlapping tours?
Question 4 of 6
A shop's gift cards can each be redeemed once. On every checkout that uses a card, the redeem handler runs:
Two shoppers check out with card GC-77 at the same moment, on two servers using PostgreSQL's default isolation level, and both checks run before either insert.
Which change leaves GC-77 redeemed once and tells the other shopper the card is already used?
const used = await db.query('SELECT 1 FROM redemptions WHERE card_code = $1', [code]);if (used.rowCount > 0) return 'card already used';await db.query('INSERT INTO redemptions (card_code, order_id) VALUES ($1, $2)', [code, orderId]);return 'redeemed';Question 5 of 6
A shop's products(sku, name, price) table holds 40 products priced 0, left by an old import; merchandising will reprice them next week. From today, no path may store a product priced 0 or below: not the admin form, not the supplier import, which runs plain INSERT statements, and not the support console, which runs UPDATE statements by hand.
Which change does that today, and leaves the 40 products in place?
Question 6 of 6
A film-streaming service's schema for subscriptions:
CREATE TABLE subscriptions (
id text PRIMARY KEY,
user_id text NOT NULL,
status text NOT NULL CHECK (status IN ('trial', 'active', 'cancelled')),
ends_on date,
CHECK (status <> 'cancelled' OR ends_on IS NOT NULL)
);
CREATE UNIQUE INDEX one_live ON subscriptions (user_id) WHERE status <> 'cancelled';
It holds three rows:
id | user_id | status | ends_on
s1 | u1 | cancelled | 2026-05-01
s2 | u2 | active |
s4 | u3 | cancelled | 2026-06-30
Four statements then run in this order, each in its own transaction, so a refused one changes nothing:
INSERT INTO subscriptions VALUES ('s3', 'u1', 'active', NULL);
UPDATE subscriptions SET status = 'trial', ends_on = NULL WHERE id = 's1';
UPDATE subscriptions SET status = 'cancelled' WHERE id = 's2';
UPDATE subscriptions SET status = 'active', ends_on = NULL WHERE id = 's4';
Which statements does the database refuse?
Go deeper
Pro tips
- When a start request hits the one-active rule, let the insert itself report the existing attempt:
INSERT … ON CONFLICT (candidate_id, exam_id) WHERE status = 'active' DO NOTHING RETURNING id, then read the active attempt when no row comes back. When: any "create if none is active" endpoint (steps 10, 12). TheWHEREafter the conflict columns is how PostgreSQL infers a partial unique index. Verify:tip without the WHERE: error 42P10 · with it: first start returns a-201, second start returns no row, then reads a-201 · active attempts 1
Source
Valid as of 2026-10-01.
- PostgreSQL 18, 5.5 Constraints, the curriculum row's second source: https://www.postgresql.org/docs/current/ddl-constraints.html (
pg-ddl-constraints.txt). - PostgreSQL 18, 11.8 Partial Indexes, the row's first source: https://www.postgresql.org/docs/current/indexes-partial.html (
pg-indexes-partial.txt). "This enforces uniqueness among the rows that satisfy the index predicate, without constraining those that do not." Supports step 10 and the opening question. - PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html (
pg-alter-table.txt). "Normally, this form will cause a scan of the table to verify that all existing rows in the table satisfy the new constraint." and, forNOT VALID, "The constraint will still be applied against subsequent inserts or updates". ForVALIDATE CONSTRAINT: "After that, a VALIDATE CONSTRAINT command can be issued to verify that existing rows satisfy the constraint." - PostgreSQL 18, CREATE TABLE: https://www.postgresql.org/docs/current/sql-createtable.html (
pg-create-table.txt, fetched in the W6-3 fixes). "If the constraint is NOT ENFORCED, the database system will not check the constraint." - PostgreSQL 18, CREATE INDEX: https://www.postgresql.org/docs/current/sql-createindex.html (
pg-create-index.txt). "Causes the system to check for duplicate values in the table when the index is created (if data already exist) and each time data is added." - PostgreSQL 18, 41.10 Trigger Functions (PL/pgSQL): https://www.postgresql.org/docs/current/plpgsql-trigger.html (
pg-plpgsql-trigger.txt).OLDholds the "old database row for UPDATE/DELETE operations in row-level triggers",NEWthe "new database row for INSERT/UPDATE operations in row-level triggers". - PostgreSQL 18, INSERT: https://www.postgresql.org/docs/current/sql-insert.html (
pg-insert.txt).index_predicate: "Used to allow inference of partial unique indexes." Supports tip 1. - Rob Pike, Notes on Programming in C (21 February 1989), Rule 5: https://www.lysator.liu.se/c/pikestyle.html (
pike-notes.txt). "Data structures, not algorithms, are central to programming." Supports step 1; teaching only, it keys no item.
Related lessons
- Read first: Problem framing
- Read first: Normalization vs deliberate denormalization
- Related: Isolation levels and anomalies in Postgres
- Related: Preventing lost updates
- Related: Idempotent consumers and the exactly-once myth
- Related: Idempotency keys for unsafe operations
- Related: Running a backend design round
- Related: Narrowing and discriminated unions
- Related: B-tree indexes, pages and what indexes cost
- Related: Zero-downtime schema migrations: expand, migrate, contract
Checked against the tools and docs as of 2026-10-01.