Relational Data Modeling
The decisions a relational schema forces, and how to settle them so the database enforces what it can and the application is left with only what it must. Every rule names the wrong default it corrects; there is no rule for what a capable model already gets right.
Examples are PostgreSQL 18, and every DDL statement in this skill was
executed against 18.4 — including the failure cases, to confirm the constraints
reject what they claim to reject. Roughly a third of the rules depend on
mechanisms MySQL and SQLite do not have (EXCLUDE, partial unique indexes,
range types, WITHOUT OVERLAPS, deferrable constraints, NOT VALID); those
rules say so. The judgment rules transfer to any relational engine.
When to Apply
Use this skill when:
- Writing or reviewing
CREATE TABLE/ALTER TABLE, a migration, or an ORM schema definition (Prisma, Drizzle, Django models, ActiveRecord, Ecto, SQLAlchemy) - Designing an entity-relationship model, or naming what a row is — the point where key choices become expensive to reverse
- The user says "should this be one table or two", "how do I model many-to-many", "the schema allows bad data", "we need history", "we're adding multi-tenancy", or "can the database enforce this"
- A bug turns out to be a schema that permitted the bad state — duplicates that a unique constraint should have caught, orphans a foreign key should have blocked, overlapping bookings, two rows flagged as default
- Adding constraints to a table that already has rows and traffic
- Reviewing a schema generated by an ORM or a scaffolding tool, which is where polymorphic associations, reflexive surrogate keys, and blanket soft-delete flags arrive from
This skill is NOT for:
- Query tuning, execution plans, or choosing indexes for a read path — this covers only the indexes that constraints and foreign keys require
- Connection pooling, replication, or operational tuning
- Non-relational stores, where the trade-offs it argues from do not hold
Rule Categories
| # | Category | Prefix | Covers |
|---|---|---|---|
| 1 | Identity and Keys | key- |
What a row is; the choice every foreign key depends on |
| 2 | Relationships and Cardinality | rel- |
Keeping references declarable to the database, not just intended |
| 3 | Constraints as the Model | cons- |
Which mechanism can actually hold which invariant |
| 4 | Types and Domains | type- |
The cheapest constraint available, chosen for meaning not habit |
| 5 | Derived and Encoded Data | norm- |
What every deliberate copy costs, and what the database can't see inside |
| 6 | Time, History and Lifecycle | time- |
What happens to a row when the world changes |
Quick Reference
1. Identity and Keys
key-primary-key-is-real-identity— a surrogate hides the natural key; you still owe the table aUNIQUEkey-identity-not-serial—serialis legacy;GENERATED ALWAYS AS IDENTITYblocks the desync that breaks insertskey-uuidv7-for-client-generated— v4 randomises every B-tree insert; useuuidv7(), and only when a UUID is earnedkey-propagate-scoping-key— carry the tenant into child keys so a cross-tenant reference cannot be writtenkey-partition-axis-binds-the-key— for tables that grow with traffic, the partition axis is a key decision, not later tuning
2. Relationships and Cardinality
rel-no-polymorphic-fk—type+idmakes referential integrity undeclarable; use an exclusive arc or a supertyperel-relationship-as-entity— name it if it has facts and a lifecycle; keep it a keyed link table if it doesn'trel-choose-referential-action—CASCADEmeans composition; on a ledger it is data lossrel-index-referencing-side— PostgreSQL does not index the child column; MySQL does, and the habit transfersrel-one-to-one-is-one-table— a split buys a join and enforces nothing unless storage or access control differsrel-subtypes-through-the-key—UNIQUE (id, kind)plus a pinned discriminator proves the variants are disjoint
3. Constraints as the Model
cons-not-null-by-default— aCHECKthat evaluates to NULL passes, andUNIQUEstops constrainingcons-exclude-for-non-overlap— the app-level overlap check is a read-then-write race;EXCLUDEis notcons-partial-unique-index— "at most one active X" is aUNIQUE ... WHERE, not a triggercons-check-is-single-row— wrapping a query in a function makes aCHECKthat silently stops holdingcons-deferrable-for-cycles— defer toCOMMITinstead of making a column nullable forevercons-not-valid-then-validate— what makes constraints addable to a live table at all
4. Types and Domains
type-timestamptz-for-instants—timestampstores a wall clock with no zone; a birthday is not an instanttype-text-over-varchar—varchar(255)is a MySQL artifact; a length limit is aCHECKor a domaintype-numeric-with-currency— floats drift,moneyis locale-dependent, and currency is never implicittype-range-not-two-columns— two loose columns admit inverted rows and block every temporal constrainttype-enum-lookup-or-check—CHECKby default, lookup table when values carry attributes,enumis a one-way door
5. Derived and Encoded Data
norm-derived-needs-a-mechanism— no constraint can hold two copies equal; name the mechanism or don't copynorm-encoded-composite-values— generate the reference code from its parts; a parsed column can't be constrainednorm-jsonb-for-open-shapes— if you can enumerate the keys and filter on one, they are columns
6. Time, History and Lifecycle
time-events-not-in-place-updates— an in-place balance destroys the audit trail and serialises the accounttime-soft-delete-breaks-constraints—deleted_atdisables unique constraints and foreign keys silentlytime-validity-as-a-range—WITHOUT OVERLAPSmakes two simultaneous versions unrepresentabletime-valid-vs-transaction-time— two axes only when you must reproduce a past belief; usually you must not
How to Use
Read a reference file when its decision comes up — the quick reference above is enough to route. Each rule states the wrong default it corrects and why, then gives a canonical example.
Two rules of thumb tie the categories together and are worth applying even outside a specific rule:
- Before adding a column, name what makes two rows the same row. That sentence is a constraint you owe the table.
- Before writing a validation in application code, ask which constraint could
hold it instead. If the answer is "none", that is worth knowing explicitly
— see
cons-check-is-single-rowfor the map of invariant shapes to mechanisms.
- Section definitions — category structure and ordering
- Rule template — for adding new rules
- AGENTS.md — auto-built table of contents across all rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and source references |