Writing DB Schemas
Overview
Normalization is the means; integrity is the point. A schema is correct
when the database itself refuses invalid states. A normalized schema that
trusts the application to behave is not done — every rule below moves a check
from code, where it can be forgotten, into the database, where it cannot.
The Rules
- The database generates surrogate ids and row timestamps.
DEFAULT
expressions — (lower(hex(randomblob(16)))) in SQLite,
gen_random_uuid() in Postgres, CURRENT_TIMESTAMP for both — read back
with RETURNING. The application never invents a surrogate key or a
created_at.
- Natural keys are preferred to surrogate keys. When a stable, atomic
natural key exists (
isbn, a currency code, (org_id, slug)), it is the
primary key. Add a surrogate only when the candidate key is mutable or
re-assignable (email — it moves between people and gets reissued), must
not leak, or is compound and widely referenced — and then the natural key
still carries UNIQUE, because every normal form is defined over candidate
keys and a table whose only key is the surrogate is vacuously normalized
and stores the same entity twice without complaint. A table with
genuinely no natural key (pure event rows) says so in a comment where
the UNIQUE would have been.
- Every reference is a foreign key, and every FK declares ON DELETE.
CASCADE for owned child and fact rows (line items, deletions, closures,
memberships); RESTRICT for cross-aggregate references. A bare TEXT/INTEGER
column that names another table's row is a missing constraint.
- A fact that happens later is its own table.
returned_at,
closed_at, deleted_at, revoked_at sitting NULL on every live row is a
column about a non-event. Give the fact its own table keyed by the parent
(loan_returns, account_closures) and derive state from row existence.
The PK on the parent id makes "can't happen twice" structural, not
procedural.
- No conditional columns. A column whose meaning depends on a sibling
(
fulfilled_at vs cancelled_at, reason only when suspended) is two
fact tables wearing one row.
- Closed vocabularies are seeded enum tables, FK targets — never
CHECK (col IN (...)), never app-enforced strings. Adding a value is an
INSERT, not a migration rebuilding a CHECK.
- Real types.
TIMESTAMPTZ/DATE for time (SQLite: declare them
anyway — affinity stores ISO8601 text and the schema stays honest and
portable), INTEGER minor units for money. Never float for money, never
TEXT-declared timestamps.
- NOT NULL is the default. NULL means "a value exists but we don't know
it yet" — never "hasn't happened" (rule 4) and never "not applicable"
(rule 5). Every surviving nullable column states what NULL means in a
comment beside it, or loses its nullability.
- 5NF means every form, not just the name.
- 1NF — columns are atomic. No delimited lists, no numbered column
families (
phone1, phone2), no JSON or array column hiding a
relation. Each is a child table; query-relevant data never lives
inside a blob.
- 2NF/3NF/BCNF — nothing depends on less than a whole key. A value
identical across an operation's child rows (date, memo, author,
client) belongs on the parent, once. A non-key column determined by
another non-key column (
city beside zip, a customer's tier copied
onto the order row) is a JOIN, not a column. Display names come from
JOINs, not snapshot copies.
- 4NF — independent multivalued facts get separate tables. One join
table whose FKs serve two unrelated many-to-manys stores cartesian
noise; split it into one table per relationship.
- 5NF — projections that rejoin losslessly are the real tables. Test
three-way relationship tables against the business rule: if the rule
is really two or three pairwise facts, store those.
- No derivable columns anywhere. A deliberate exception is recorded
in a comment beside the column — an unrecorded exception is a
loophole.
- No compound-key ceremony.
UNIQUE (scope_id, id) beside a
single-column PK exists only to feed composite FKs; when the PK is not
compound, use plain single-column FKs and delete the apparatus.
- Every index names the query it serves, in a comment. A rule SQL
cannot express (uniqueness spanning a fact table, a derived-balance
check) is enforced in the store's write transaction and recorded in a
comment exactly where the constraint would have been.
Core pattern
-- ❌ state as nullable columns, bare FKs, TEXT time
CREATE TABLE loans (
id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(id),
due_at TEXT NOT NULL,
returned_at TEXT -- NULL = still out
);
-- ✅ facts as rows, ON DELETE everywhere, real types, natural key as PK,
-- DB-generated surrogate only where no natural key exists
CREATE TABLE books (
isbn TEXT PRIMARY KEY -- natural key: stable, atomic, no surrogate needed
);
CREATE TABLE loans (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
-- event rows, no natural key: surrogate justified
book_isbn TEXT NOT NULL REFERENCES books(isbn) ON DELETE RESTRICT,
loaned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE loan_returns (
loan_id TEXT PRIMARY KEY REFERENCES loans(id) ON DELETE CASCADE,
-- PK: a loan cannot be returned twice
returned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- "one open loan per book" spans loan_returns, beyond a partial index:
-- store-enforced in the checkout transaction (recorded here).
Red flags — stop and restructure
- A
*_at column that is NULL until something happens
- Two nullable columns of which at most one may be set
- An FK with no ON DELETE behavior chosen
- A surrogate id or
created_at assigned in application code
- A surrogate id on a table whose stable natural key would have served as PK
CHECK (col IN (...)) for a vocabulary
UNIQUE (x, id) next to PRIMARY KEY (id)
- A copied display name a JOIN would have fetched
- A nullable column with no comment saying what NULL means
- A delimited list, JSON blob or numbered column family standing in for a child table
- A surrogate-PK table with no UNIQUE on any natural key and no comment saying why
- A join table whose FKs serve two independent relationships
Rationalizations
| Excuse |
Reality |
| "Quick schema, we'll harden later" |
Quick schemas ship, then the migration costs 100× the constraint line |
| "The app validates it" |
Every caller is a place the rule can be forgotten; the database is the one place it can't |
| "The partial index needs the nullable column" |
The fact-table shape has an equivalent: a current-state row (book_checkouts), or a store-enforced check recorded inline |
| "Nullable is simpler than another table" |
Simpler to write, and then every query carries IS NULL state logic forever |
1---2name: writing-db-schemas3description: Use when writing or reviewing SQL DDL — creating a table, adding a column, designing a schema or a migration — before the first CREATE TABLE is typed. Also when a schema shows nullable status columns (returned_at, deleted_at, closed_at), foreign keys without ON DELETE, TEXT timestamps, application-generated surrogate ids, CHECK (col IN (...)) vocabularies, delimited lists or JSON columns standing in for child tables, or tables whose only key is the surrogate id.4---56# Writing DB Schemas78## Overview910Normalization is the means; **integrity is the point**. A schema is correct11when the database itself refuses invalid states. A normalized schema that12trusts the application to behave is not done — every rule below moves a check13from code, where it can be forgotten, into the database, where it cannot.1415## The Rules16171. **The database generates surrogate ids and row timestamps.** `DEFAULT`18 expressions — `(lower(hex(randomblob(16))))` in SQLite,19 `gen_random_uuid()` in Postgres, `CURRENT_TIMESTAMP` for both — read back20 with `RETURNING`. The application never invents a surrogate key or a21 `created_at`.222. **Natural keys are preferred to surrogate keys.** When a stable, atomic23 natural key exists (`isbn`, a currency code, `(org_id, slug)`), it is the24 primary key. Add a surrogate only when the candidate key is mutable or25 re-assignable (`email` — it moves between people and gets reissued), must26 not leak, or is compound and widely referenced — and then the natural key27 still carries `UNIQUE`, because every normal form is defined over candidate28 keys and a table whose only key is the surrogate is vacuously normalized29 and stores the same entity twice without complaint. A table with30 genuinely no natural key (pure event rows) says so in a comment where31 the UNIQUE would have been.323. **Every reference is a foreign key, and every FK declares ON DELETE.**33 CASCADE for owned child and fact rows (line items, deletions, closures,34 memberships); RESTRICT for cross-aggregate references. A bare TEXT/INTEGER35 column that names another table's row is a missing constraint.364. **A fact that happens later is its own table.** `returned_at`,37 `closed_at`, `deleted_at`, `revoked_at` sitting NULL on every live row is a38 column about a non-event. Give the fact its own table keyed by the parent39 (`loan_returns`, `account_closures`) and derive state from row existence.40 The PK on the parent id makes "can't happen twice" structural, not41 procedural.425. **No conditional columns.** A column whose meaning depends on a sibling43 (`fulfilled_at` vs `cancelled_at`, `reason` only when suspended) is two44 fact tables wearing one row.456. **Closed vocabularies are seeded enum tables**, FK targets — never46 `CHECK (col IN (...))`, never app-enforced strings. Adding a value is an47 INSERT, not a migration rebuilding a CHECK.487. **Real types.** `TIMESTAMPTZ`/`DATE` for time (SQLite: declare them49 anyway — affinity stores ISO8601 text and the schema stays honest and50 portable), INTEGER minor units for money. Never float for money, never51 TEXT-declared timestamps.528. **NOT NULL is the default.** NULL means "a value exists but we don't know53 it yet" — never "hasn't happened" (rule 4) and never "not applicable"54 (rule 5). Every surviving nullable column states what NULL means in a55 comment beside it, or loses its nullability.569. **5NF means every form, not just the name.**57 - **1NF — columns are atomic.** No delimited lists, no numbered column58 families (`phone1`, `phone2`), no JSON or array column hiding a59 relation. Each is a child table; query-relevant data never lives60 inside a blob.61 - **2NF/3NF/BCNF — nothing depends on less than a whole key.** A value62 identical across an operation's child rows (date, memo, author,63 client) belongs on the parent, once. A non-key column determined by64 another non-key column (`city` beside `zip`, a customer's tier copied65 onto the order row) is a JOIN, not a column. Display names come from66 JOINs, not snapshot copies.67 - **4NF — independent multivalued facts get separate tables.** One join68 table whose FKs serve two unrelated many-to-manys stores cartesian69 noise; split it into one table per relationship.70 - **5NF — projections that rejoin losslessly are the real tables.** Test71 three-way relationship tables against the business rule: if the rule72 is really two or three pairwise facts, store those.73 - **No derivable columns anywhere.** A deliberate exception is recorded74 in a comment beside the column — an unrecorded exception is a75 loophole.7610. **No compound-key ceremony.** `UNIQUE (scope_id, id)` beside a77 single-column PK exists only to feed composite FKs; when the PK is not78 compound, use plain single-column FKs and delete the apparatus.7911. **Every index names the query it serves**, in a comment. A rule SQL80 cannot express (uniqueness spanning a fact table, a derived-balance81 check) is enforced in the store's write transaction and recorded in a82 comment exactly where the constraint would have been.8384## Core pattern8586```sql87-- ❌ state as nullable columns, bare FKs, TEXT time88CREATE TABLE loans (89 id INTEGER PRIMARY KEY,90 book_id INTEGER NOT NULL REFERENCES books(id),91 due_at TEXT NOT NULL,92 returned_at TEXT -- NULL = still out93);9495-- ✅ facts as rows, ON DELETE everywhere, real types, natural key as PK,96-- DB-generated surrogate only where no natural key exists97CREATE TABLE books (98 isbn TEXT PRIMARY KEY -- natural key: stable, atomic, no surrogate needed99);100CREATE TABLE loans (101 id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),102 -- event rows, no natural key: surrogate justified103 book_isbn TEXT NOT NULL REFERENCES books(isbn) ON DELETE RESTRICT,104 loaned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,105 due_at TIMESTAMPTZ NOT NULL106);107CREATE TABLE loan_returns (108 loan_id TEXT PRIMARY KEY REFERENCES loans(id) ON DELETE CASCADE,109 -- PK: a loan cannot be returned twice110 returned_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP111);112-- "one open loan per book" spans loan_returns, beyond a partial index:113-- store-enforced in the checkout transaction (recorded here).114```115116## Red flags — stop and restructure117118- A `*_at` column that is NULL until something happens119- Two nullable columns of which at most one may be set120- An FK with no ON DELETE behavior chosen121- A surrogate id or `created_at` assigned in application code122- A surrogate id on a table whose stable natural key would have served as PK123- `CHECK (col IN (...))` for a vocabulary124- `UNIQUE (x, id)` next to `PRIMARY KEY (id)`125- A copied display name a JOIN would have fetched126- A nullable column with no comment saying what NULL means127- A delimited list, JSON blob or numbered column family standing in for a child table128- A surrogate-PK table with no UNIQUE on any natural key and no comment saying why129- A join table whose FKs serve two independent relationships130131## Rationalizations132133| Excuse | Reality |134|---|---|135| "Quick schema, we'll harden later" | Quick schemas ship, then the migration costs 100× the constraint line |136| "The app validates it" | Every caller is a place the rule can be forgotten; the database is the one place it can't |137| "The partial index needs the nullable column" | The fact-table shape has an equivalent: a current-state row (`book_checkouts`), or a store-enforced check recorded inline |138| "Nullable is simpler than another table" | Simpler to write, and then every query carries `IS NULL` state logic forever |