11 KiB
Data Modeling — Keys, Relationships, and Normal Forms
Design order that survives contact with production: identify entities and their identity → fix cardinality → choose keys → add constraints → then, and only then, denormalize against a measured read. Reversing that order produces schemas whose bugs are unfixable without a migration.
Contents: Entity Test · Cardinality · Key Choice · Natural Keys · Normal Forms · Denormalization · Nullability · Enumerations · Junctions · Inheritance · Units and Precision · Naming · Review Checklist
Is It An Entity Or A Column?
- It is an entity when it has an independent lifecycle, its own attributes, or something else must reference it.
- It is a column when it only ever describes exactly one parent row and is never referenced.
- The tell for a missed entity: repeated column groups (
address_line1,address_line2,address_cityappearing on three tables) or numbered columns (phone1,phone2,phone3). Numbered columns are a one-to-many relationship written by hand. - The tell for an over-modeled entity: a table whose only columns are an id and a name, joined every single time, never referenced from more than one place, never edited. That is an enum (→ Enumerations).
Cardinality: Get It Right Before Anything Else
| Relationship | Implementation | Common mistake |
|---|---|---|
| 1:1 | FK with a UNIQUE constraint, or merge into one table | Splitting for aesthetics; split only for access frequency, permissions, or genuinely optional bulk columns |
| 1:N | FK on the many side | Putting an array of child ids on the parent, which no FK can validate |
| M:N | Junction table with a composite PK | A comma-separated string column; every query becomes LIKE and nothing is enforceable |
| Optional 1:N | Nullable FK on the many side | Sentinel 0/-1 instead of NULL, which breaks the FK |
| Self-referencing | Nullable FK to the same table | No cycle guard; recursive reads then loop forever |
Ask "can this ever be more than one?" for every 1:1 and "will it always be exactly one?" for every required FK. Both answers change over the product's life, and the M:N junction is cheap to build up front and expensive to retrofit.
Primary Key Choice
| Option | Pick it when | Cost |
|---|---|---|
BIGINT GENERATED ALWAYS AS IDENTITY |
Default for almost everything | Values are guessable and leak volume (id 4,120 tells a competitor your order count) |
| UUIDv7 / ULID | Ids generated client-side, offline, or across shards; ids exposed publicly | 16 bytes vs 8 in every index; still time-ordered so inserts stay local |
| UUIDv4 | Only when unpredictability matters more than write locality | Random insert position fragments the B-tree and inflates every index |
| Composite natural key | Pure junction tables | Every child table must carry all the columns |
INT |
Never for a growing table (SKILL.md rule 2) | Overflow is an outage-grade migration |
Public exposure is a separate decision from the primary key: keep BIGINT internally and add a unique, indexed external id (UUID or short random slug) for URLs and APIs. That gives compact joins internally and no enumeration externally.
Natural Keys: The Honest Frontier
A natural key is defensible when the value is defined by an outside authority, immutable by that authority, and never re-issued: ISO 3166 country codes, ISO 4217 currency codes.
Values that look natural and are not: email (users change them, and case/alias normalization is a policy), phone number (reassigned), national identifiers (formats change, and they are regulated personal data), SKU (merchandising renames them), "username" (rename features exist), any code with a check digit whose standard has been revised.
When in doubt: surrogate PK plus a UNIQUE constraint on the natural key. That gives you the integrity guarantee without cascading a rename through every child row.
Normal Forms, In The Order They Bite
- 1NF — one value per column, no repeating groups. Violation: a comma-separated
tagscolumn. Cost: no index, no FK, no counting. - 2NF — no non-key column depends on part of a composite key. Violation:
order_items(order_id, product_id, product_name)—product_namedepends onproduct_idalone. Cost: renaming a product updates thousands of rows and misses some. - 3NF — no non-key column depends on another non-key column. Violation: storing both
zipandcitywhenzipdeterminescity. Cost: they drift. - BCNF — every determinant is a candidate key. Matters mainly for tables with overlapping candidate keys; reaching 3NF handles the vast majority of real designs.
- 4NF/5NF — independent multi-valued facts crammed into one table (a person's languages and their skills in one row) produce a cross-product of rows. Rare, but unmistakable once seen: the row count is the product of two unrelated lists.
Design at 3NF by default. Every deliberate exception gets a comment saying which read it serves and what keeps the copies in sync.
Denormalization: The Four Legitimate Kinds
Denormalize only after the normalized read is measured and too slow, and only with a named synchronization mechanism.
- Cached aggregate (
posts.comment_count) — synced by trigger or by the same transaction as the insert. Becomes a lock hotspot on hot rows; shard into N counter rows and SUM if it does. - Copied immutable value (
order_items.unit_price_at_purchase) — not denormalization at all: the price at purchase time is a different fact from the current price. Always copy this one. - Materialized rollup (daily revenue table) — refreshed on a schedule, explicitly stale, queried by dashboards.
- Redundant FK for locality (
comments.post_author_idto avoid a join in a hot path) — measurable win only on very hot paths, and it must be maintained on parent change.
Anything outside these four is usually a missing index.
Nullability
NOT NULLis the default posture; nullable is the exception you justify. Every nullable column multiplies the query branches downstream (SKILL.md rule 6).- NULL means "unknown or not applicable". It does not mean zero, empty string, or false — encoding those as NULL destroys the distinction between "no answer" and "answered zero".
- Never use a sentinel (
-1,'N/A',1970-01-01) to avoid NULL: it defeats aggregates, sorts wrong, and eventually collides with a real value. - A column nullable "for now, during the migration" needs a scheduled follow-up to set
NOT NULL, or it is permanent. - Uniqueness over nullable columns treats NULLs as distinct in most engines — two rows with NULL both pass a UNIQUE constraint. PostgreSQL >=15 offers
UNIQUE NULLS NOT DISTINCTwhen you need the opposite.
Enumerations: Three Options
| Option | When | Watch out |
|---|---|---|
TEXT + CHECK (col IN (...)) |
Default — values evolve with normal migrations | Constraint must be dropped and re-added to change the set |
Native ENUM type |
Fixed forever, and the storage saving matters | PostgreSQL cannot remove or reorder values; MySQL renumbers on ALTER |
| Lookup table + FK | The set has attributes (label, sort order, active flag) or is user-editable | An extra join on every read |
Store the machine value, not the display label — labels are localized and get edited. Transition rules between states belong in code or a trigger, never in a CHECK, which only sees the new row.
Junction Tables
CREATE TABLE post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_post_tags_tag ON post_tags(tag_id); -- the reverse lookup (SKILL.md rule 4)
- The composite PK gives you one direction's index for free; the other direction needs its own index, and its absence is the most common junction-table performance bug.
- Add a surrogate id only when the relationship itself gains attributes (
added_at,added_by,role) or something must reference the relationship. - Column order in the PK is a real decision: put the side you filter on most often first (SKILL.md rule 3).
Inheritance / Subtypes
Three shapes for "a Vehicle is a Car or a Truck":
- Single table — all columns in one table with a
typediscriminator. Simplest queries; subtype-specific columns must be nullable, so the database cannot enforce "trucks havepayload_kg". Best when subtypes differ by a couple of columns. - Table per subtype with a shared parent table holding the common columns and a FK. Full integrity, one join per read. Best when subtypes have substantial distinct attributes and are queried separately.
- Table per concrete type, no shared parent. Fastest per-type reads, but no way to reference "any vehicle" with a FK, and every cross-type query is a
UNION ALL.
Default to single table below roughly five subtype-specific columns, table-per-subtype above it. The forcing question: does anything need a foreign key to "any subtype"? If yes, you need a shared parent table.
Units, Precision, and Encoding of Values
- Store money as integer minor units or
NUMERIC(p, s), and store the currency next to it. An amount without a currency column is a bug waiting for the second market. - Put the unit in the column name (
weight_kg,duration_ms,distance_m) — a column namedweightis read as pounds by someone eventually. Convert at the presentation layer only. - Percentages: pick ratio (0-1) or percent (0-100), state it in the name (
discount_rate,discount_pct), and never mix them in one schema. - Precision for
NUMERIC(p, s):smust exceed the smallest unit you will ever aggregate. Interest and tax intermediate values typically need 4-6 decimal places even when the display shows 2. - Booleans that will grow a third state (
is_approved→ approved/rejected/pending) start as a status enum. The retrofit costs a migration and a search for every truthiness check.
Naming That Prevents Bugs
- One concept, one name, across the whole schema:
user_ideverywhere, neveruidin one table andownerin another. - FK column = referenced table singular +
_id(user_id→users.id). Deviation forces every reader to check. - Booleans read as assertions (
is_active,has_verified_email); timestamps end in_at(deleted_at); dates end in_on; durations carry the unit. - Name constraints and indexes explicitly (
uq_users_email_live,fk_orders_user), because the engine's autogenerated name is what appears in production error messages. - Avoid reserved words (
order,user,group,check,end) — quoting them forever is worse than choosingorders,accounts,groups. Casing and quoting rules differ by engine.
Review Checklist
- Every table has a primary key, and its type follows SKILL.md rule 2.
- Every FK column is indexed (SKILL.md rule 4), and its
ON DELETEbehavior was chosen, not defaulted. - Every UNIQUE constraint is scoped correctly: per tenant, and excluding soft-deleted rows if they exist.
- Every nullable column has a reason; every NULL means "unknown", not zero.
- No repeating groups, no comma-separated lists, no numbered columns.
- Every timestamp is zone-aware and stored as UTC; every money column has a currency.
- Nothing stores a derived value without a stated sync mechanism.
- The model answers the three or four queries the feature actually needs — write them out and check.