Data Modeling
Data modeling is deciding what a system stores — the entities, what identifies each, how they relate and what must always be true — before the first table exists. The model outlives the application on top of it: frameworks are replaced, the schema is migrated, and a careless decision from week two is still costing somebody in year five. The page opens with sixteen questions to ask of every table, with the answer that holds up and what the wrong one costs later, then covers entities, keys and relationships, normal forms, types and nulls, time and history, models built for analysis, five documented cases, and where it degrades.
- A model states
- What must always be true
- Normal forms
- One named anomaly each
- The one you cannot fix later
- History that was never recorded
Sixteen questions for every table — the answer that holds up, and what the wrong one costs
Nobody opens a page about data modelling for the theory. They open it because a schema is about to be written, or because one already hurts: a report that cannot be built, a migration that takes a weekend, a number nobody can explain. Almost all of that pain goes back to a question that was never asked of some table. Here are sixteen of them, with the answer that holds up and what the wrong answer costs a year later. Later sections cite them by number.
| # | The question | The answer that holds up | What the wrong answer costs later |
|---|---|---|---|
| 1 | What is one row of this table? | One sentence about one thing: “one row is one line of one order” | Two entities in one table, told apart by a type column and columns that are empty for half the rows |
| 2 | What identifies a row? | A surrogate primary key, plus a unique constraint on the business identifier | The business identifier as the key — until it changes, and every reference changes with it |
| 3 | How large can the key grow? | bigint or a UUID; never a 32-bit integer for anything that counts events | A 32-bit key stops at 2,147,483,647 — and so do inserts |
| 4 | What does this row point at, and may that be missing? | A foreign key on the “many” side; NOT NULL unless the business allows an orphan | Orphans nobody can explain, and a nullable reference nobody decided on |
| 5 | Is a many-to-many link a thing of its own? | A table per link — it usually gains a quantity, a role or a date | A list of ids inside a column, which no foreign key or index can check |
| 6 | How is money stored? | An exact decimal or an integer of minor units, with the currency beside it | Floating point: 0.1 + 0.2 is not 0.3, and rounding differs between languages |
| 7 | Is this a moment, a calendar date, or a future local time? | A timestamp with a time zone for moments; a date for a day; local time plus a zone name for a future appointment | A timestamp with no zone: ambiguous twice a year and wrong across regions |
| 8 | Is the set of values fixed? | A CHECK, an enum or a lookup table | Free text: “paid”, “PAID” and “Payed” within a year |
| 9 | Why may this column be NULL? | One of two reasons: optional in the business, or not yet known when the row is created | NULL meaning five different things — or a stand-in such as 'N/A' or 1900-01-01 meaning one of them |
| 10 | Why is this JSON rather than columns? | JSON only for what genuinely varies per row and is never filtered or constrained | Filters and rules on fields the database cannot see or check |
| 11 | Is every fact stored exactly once? | Each non-key column states a fact about the key, the whole key and nothing but the key | The same fact in two places, one of which an update will miss |
| 12 | Who keeps each copy true? | Every deliberate copy has a mechanism — a trigger, a rebuild — or is meant to be frozen | A copy that drifts silently and is trusted anyway |
| 13 | Is this value derived, or was it agreed? | Store what was agreed — the price at purchase, the total the customer accepted; compute what is derived | A recomputed total that changes when a price does |
| 14 | Will anyone ask what this looked like in March? | History for the entities that need it, decided now: a history table or validity periods | Only the current state — so the question has no answer, ever |
| 15 | When did it happen, and when did we learn it? | Two dates where the gap matters: when it was true in the world, and when the system recorded it | Monthly totals that change after the month is closed, with no way to say why |
| 16 | What does deleting it mean? | A decision per entity: hard delete, soft delete enforced in one place, or anonymise — with a retention period | Removed data shown by one query that forgot the filter — and a “deletion” that does not satisfy an erasure request |
From sentences to tables — entities, keys and relationships
A model is usually drawn three times, at three levels of detail, and many schema arguments are two people talking about different levels. The split goes back to the ANSI/SPARC committee’s three-schema architecture of 1975. The diagram most people draw at the first level comes from Peter Chen’s entity-relationship model of 1976.
| Level | What it says | Who reads it | The usual mistake |
|---|---|---|---|
| Conceptual | The things the business talks about and how they relate: a customer places orders | The business and the team, together | Skipped, so the first argument about meaning happens in a code review |
| Logical | Every attribute, key and relationship, normalised; no engine yet | Developers and analysts | Mixed with the physical, so an index decision bends the model |
| Physical | Tables, types, indexes and partitions for one engine | The engine, and whoever tunes it | Treated as the model, so it is redesigned every time the engine changes |
Modelling starts with one question asked over and over: what are the things this product talks about, and what is true of each of them exactly once? A customer has one email address and many orders. An order has one total and many lines. Getting this list right is most of the work, and the reliable way to find it is to write down the sentences the business actually says out loud. "An invoice can cover several deliveries" is a many-to-many relationship stated in plain language, and it will still be true after three rewrites of the application. Row 1 is the same test applied to one table: if you cannot say what one row is in a sentence, the table holds two things.
The diagram uses crow’s-foot notation, which Gordon Everest introduced in 1976 and most tools draw today. Each end of a line says how many of that entity can stand there. A bar means one, a circle means zero, and the three-pronged foot means many, so a circle with a foot reads "zero or more" and two bars read "exactly one". Read it as a sentence, from one box along the line to the marks at the far end: a customer places zero or more orders; an order belongs to exactly one customer. The order line in the middle is row 5 made visible. An order holds many products and a product appears on many orders, and the table that links them turned out to be an entity of its own, with a quantity and a price.
A key is the promise that a row can be identified, and there are two kinds. A natural key is a value the business already uses — an email address, a tax number, an ISBN. A surrogate key is a number or identifier the database made up, meaningless outside the system. Natural keys read well and save a join, and they share one failure that recurs everywhere: business identifiers change. People change email addresses. Companies re-register. Books went from ten-digit to thirteen-digit ISBNs on 1 January 2007, and every system that used the ISBN as its key had to change every reference to every book. The workable default is row 2: a surrogate primary key, plus a unique constraint on the natural one, so the identity is stable and the business rule is still enforced.
| Key | Size | Good for | Watch out for |
|---|---|---|---|
| 32-bit integer | 4 bytes | Small reference tables that will never reach two billion rows | Stops at 2,147,483,647 — Basecamp’s events table reached it in 2018 (row 3) |
64-bit integer (bigint) | 8 bytes | The default for an internal key: compact, ordered, enough for any realistic count | In a URL it reveals how many rows exist and how fast they grow |
| UUIDv4 | 16 bytes | Ids generated anywhere — on a phone, in another service — that nobody can guess | Random order scatters inserts across the index (see the SQL page) |
| UUIDv7 | 16 bytes | Ids generated anywhere that still sort by creation time (RFC 9562, 2024) | Reveals when the row was created |
| Natural key | Varies | Reference data whose codes genuinely never change, such as ISO currency codes | Business identifiers change, and every reference changes with them (row 2) |
| Composite key | Its columns combined | Link tables, and children numbered within a parent | Every table that references it carries all of its columns |
| Relationship | How it is represented | The detail people miss |
|---|---|---|
| One to many | A foreign key on the “many” side, pointing at the one | Whether the key may be NULL decides whether an orphan is legal — a business question (row 4) |
| Many to many | A third table holding one row per pair, with a foreign key to each side | That table is usually an entity in its own right and gains attributes (row 5) |
| One to one | A shared key, or a unique constraint on the foreign key | Genuinely rare: usually one entity split for storage reasons, or two entities confused |
| Hierarchy | A foreign key from a row to another row in the same table | A whole branch needs a recursive query; when branches are read far more than changed, store the path as well |
Four shapes look flexible and are not. Bill Karwin named them in "SQL Antipatterns" (2010), and every long-lived schema seems to grow at least one:
| Shape | What it looks like | What breaks | Instead |
|---|---|---|---|
| A list in a column | tags = 'urgent,billing' | No index, no foreign key; “billing” also matches “rebilling” | A row per value in a link table (row 5) |
| Numbered columns | phone_1, phone_2, phone_3 | The fourth phone; a search across all three | A child table |
| Entity–attribute–value | One table of (entity, attribute, value) rows that can hold “anything” | No types, no constraints, and one join per attribute to read one record | Real columns for known attributes; JSON for the genuinely variable rest (row 10) |
| Polymorphic association | commentable_type plus commentable_id, pointing into one of several tables | No foreign key can say “a row in one of these tables” | A link table per parent type, or a shared parent table they all reference |
Each normal form removes one specific anomaly
Normalisation has a reputation as academic ceremony, and the reputation comes entirely from being taught as a ladder to climb rather than as named problems to avoid. Edgar Codd defined the first normal form in 1970, in the paper that founded the relational model, and the second and third in 1971. Each one removes a specific way data goes wrong when the same fact is stored in more than one place. Learn the anomaly and the rule follows. Learn the rule alone and you will apply it where it does not help.
The anomalies come in three kinds, and one wide order table shows all of them. An update anomaly: a fact stored in many rows is changed in some of them — a product is renamed on three hundred order lines and missed on the three hundred and first. An insertion anomaly: a fact cannot be recorded until an unrelated one exists — a new product cannot be added until somebody orders it, because products only live on order lines. A deletion anomaly: removing one fact removes another — delete the last order of a product and its name and price go with it.
First normal form says each cell holds one value and there are no repeating groups. A tags column holding "urgent, billing, vip" looks harmless and cannot be filtered, indexed, counted or renamed: every operation on it becomes string manipulation. The same rule rules out the other shape of repeating group, phone_1, phone_2, phone_3, which is a list pretending to be columns and breaks the day somebody has four. The fix in both cases is a row per value.
Second normal form matters only when a table is keyed by more than one column, and it says no field may depend on just part of that key. A line-item table keyed by order and product should not carry the product name. The name depends on the product alone, so it is repeated on every order that ever included it, and renaming the product means updating thousands of rows, one of which will be missed. Third normal form extends the same logic to fields that depend on another non-key field. A customer’s city stored on the order depends on the customer, not on the order, so when the customer moves, the old orders quietly claim they were placed from the new city.
| Form | What it removes | When you meet it |
|---|---|---|
| Boyce–Codd (1974) | A dependency on part of an overlapping candidate key | Rarely: a table with two candidate keys sharing a column — a timetable keyed by room and hour, and by teacher and hour |
| Fourth (Fagin, 1977) | Two independent lists stored as every combination of both | A teacher’s subjects and spoken languages in one table: three subjects × two languages = six rows |
| Fifth (Fagin, 1979) | A table that is correct only as the join of three smaller ones | Almost never in a product schema |
| Sixth (Date, Darwen, Lorentzos, 2002) | Every non-key column in its own table, each with its own validity period | Temporal data where each attribute changes on its own schedule |
Denormalisation is then a deliberate, reversible optimisation rather than a failure to normalise, and the difference is whether you can say what it bought and what keeps it true (row 12). Some copies are not even redundancy, because they record something different from the source. A price on an order line is the price the customer paid, not the product’s price, and it must not change when the catalogue does (row 13). The illegitimate copy looks identical and has no mechanism keeping it honest:
| The copy | Legitimate when | What keeps it true |
|---|---|---|
| Product name and price on an order line | Always — it records what was sold | Nothing needs to: it is meant to be frozen |
| The order total | Always — it is the amount the customer agreed to | Frozen at checkout, with a check that the lines added up at that moment |
| A comment count on a post | When the count is read on every page view | An atomic increment in the same transaction as the comment, or a trigger |
| The customer’s name and address on an invoice | Always — an invoice is a document as it was issued | Frozen; a correction is a new document |
| A search or reporting copy | When reads need a different shape from writes | Rebuilt from the source — by change data capture or a scheduled job — and never edited by hand |
Types, nulls and absence — the cheapest constraints there are
A type is the cheapest constraint there is, and a well-chosen one makes whole categories of bug impossible to store. The reliable instinct is to pick the narrowest type that can hold every legal value and no illegal one, and to resist storing everything as text because text always fits. Text does always fit. It also accepts "N/A", "unknown", " 12,50 " and an empty string, and somebody will eventually put each of them there.
| What you are storing | The choice that hurts | What to do instead |
|---|---|---|
| Money | A floating-point number, which cannot hold 0.10 exactly | An exact decimal, or an integer of minor units with the currency beside it — minor units differ: the yen has none, the Kuwaiti dinar three (row 6) |
| A moment in time | A timestamp with no zone | A zone-aware timestamp, stored in UTC and shown in the reader’s zone (row 7) |
| A future local event — a meeting at 09:00 in Almaty next year | Converted to UTC when it was booked | The local time and the zone’s name: zone rules change — Kazakhstan moved to one time zone on 1 March 2024, and Almaty’s clocks went back an hour |
| A duration, or “one year later” | Adding one to the year field | An interval type and the engine’s or library’s date arithmetic: 29 February plus one year is not a date |
| A fixed set of states | Free text | A constraint or a lookup table; the second also lets a label be renamed without touching data (row 8) |
| A yes or no that is really a state | A boolean is_cancelled | A timestamp (cancelled_at) or a status: the boolean loses “when”, and a third state always arrives |
| Something that looks like a number | A numeric column for postcodes, phone numbers, account numbers | Text with a format check: leading zeros and plus signs are part of the value |
| A person’s name | A required first name and a required last name | One full-name field, plus what the person wants to be called |
| An email address | Unique as typed, so Ann@x.com and ann@x.com are two accounts | Unique on the lower-cased value |
| A structured blob | A JSON column for fields every request filters on | Promote the queried fields to columns; keep JSON for what genuinely varies (row 10) |
The name row deserves a sentence of its own. In 2010 Patrick McKenzie published "Falsehoods Programmers Believe About Names", a list of forty assumptions that real names break: that everyone has exactly one first and one last name, that names do not change, that they fit in a fixed number of characters, that they are written in one script. Each of those assumptions is a column definition somewhere. A model that stores the name as the person writes it, and separately how they want to be addressed, survives all forty.
NULLSQL’s marker for “unknown”The SQL marker for a value that is missing or unknown — not zero and not an empty string. A comparison with NULL is neither true nor false but unknown, so a row holding NULL matches neither “= x” nor “<> x”, and only IS NULL finds it.Full article →All terms needs its own paragraph because it is not a value. It is the absence of one, and it spreads. A comparison with NULL is neither true nor false but unknown, so a row with NULL in a column matches neither = x nor <> x; a sum skips it; and a unique constraint in most engines accepts any number of NULLs. The SQL page has the full set of surprises. For the model, the point is row 9: make each nullable column a decision with a reason. There are only two good ones — the value is genuinely optional in the business, or it is not known yet when the row is created. A group of columns that are NULL together — cancelled_at, cancelled_by, cancellation_reason — is a state that belongs in its own table or status. And a column that is NULL because it applies to only some kinds of row means two entities share a table, which is row 1 again.
Time and history — what it was, and when you knew
The request that catches most schemas out is "what did this look like in March". A table that stores only the current state answers questions about now and nothing else, and the history cannot be recovered afterwards, because it was never written down. That is why rows 14–16 are the only ones in the table that no later migration can fix. Decide early which entities need history. There are five ways to keep it, and they answer different questions:
| Approach | What it keeps | The question it answers | What it costs |
|---|---|---|---|
| An audit log | Who changed what and when, append-only | “Who changed this, and when?” | A past state has to be rebuilt by replaying the changes |
| A history table (Kimball’s type 2) | Each version, with valid_from and valid_to | “What was it in March?” | Every query must pick a version; overlaps must be prevented — PostgreSQL 18’s WITHOUT OVERLAPS does it in the engine |
| System-versioned tables (SQL:2011) | Every old row version, stamped by the engine | “What did the database say on 1 March?” | Records when the database changed, not when the world did; engine-specific (SQL Server since 2016, MariaDB since 10.3, Db2) |
| Bitemporal | Both: when it was true, and when it was recorded | “What did we believe on Tuesday about Friday?” | Two ranges on every row, two moments in every query |
| Event sourcing | The changes themselves are the record; state is derived | Any of the above, by replaying | Every event’s shape lives forever; projections must be rebuilt and kept in step |
Two kinds of date are routinely confused, and row 15 is about keeping them apart: when something happened in the world, and when your system learned about it. Richard Snodgrass’s 1999 book on time-oriented applications in SQL calls them valid time and transaction time. A payment made on Friday and imported on Monday has both. Every reconciliation, report and audit question depends on knowing which of them is being asked about. Storing only one is the reason a monthly total changes after the month has closed and nobody can explain why.
The diagram follows one payment through a week. A customer paid 102 on Friday 3 October. The payment reached the system on Monday the 6th, keyed in as 120, and on Wednesday the 8th somebody corrected the typo. Three reports for that Friday give three answers. Run on Sunday, it has no payment, because the system did not know yet. Run on Tuesday, it shows 120. Run on Thursday, it shows 102. All three were right about what the system knew when they ran. A model that stores both times can reproduce each of them on demand, which is what an auditor asks for. A model that overwrites can only ever give the last one, and has no way to explain why last week’s report said something else.
Deletion is the third time question (row 16), and it is usually decided by reflex. A deleted_at column keeps history and makes an accidental deletion recoverable, and it charges a tax on every query in the system forever: one missed filter shows removed data to a user. If you use it, enforce it in one place — a view, a repository, a row-level policy — rather than in every query. A soft delete is also not a deletion in the legal sense. The GDPRGeneral Data Protection Regulation — EU personal-data lawThe European regulation governing personal data: what you may collect, on what legal basis, how long you may keep it, and what a person can demand you do with theirs. It applies by where the people are, not by where the company is, which is why it reaches software built anywhere.All terms gives people the right to have their personal data erased (Article 17) and limits how long it may be kept (Article 5). So decide per entity: a hard delete for what nobody needs; a soft delete for user mistakes; anonymisation — keep the order, remove the person — where the business needs the record but not the identity; and a retention period after which even soft-deleted rows go. Backups count as well. An erasure that leaves the data in last month’s backup needs a documented answer for the day that backup is restored.
Models built to be read — star schemas, grain, and the schema as code
Everything above is for a system of record, where what matters most is that every write is correct. A model built to answer questions across millions of rows makes the opposite trade: it repeats data freely, so reads are simple and fast. The most widely used way to build one is Ralph Kimball’s dimensional modelling, from "The Data Warehouse Toolkit" (1996). It has four steps. Choose the business process — sales, shipments, payments. Declare the grain. Identify the dimensions, the things you filter and group by. Identify the facts, the numbers you add up. The second step is the one that matters most and is skipped most often.
The grain is the sentence that says what one row of the fact table is: one product on one receipt, one account’s balance at the end of one day. It is row 1 of the sixteen, applied to analytics. Every number in the fact table must be true at exactly that grain. Mixing grains double-counts — a daily total stored beside line items gets summed together with them. Spreading a coarse number over a finer grain is worse, because it invents data. An annual figure divided by twenty-six looks exactly like fortnightly income, and it is not. The Robodebt case below is that sentence written into a government programme.
| Approach | Shape | Optimises for | The cost |
|---|---|---|---|
| Inmon (1992) | A normalised enterprise warehouse, with departmental marts built from it | One integrated, consistent version of the truth | Slow to build, and people query the marts anyway |
| Kimball (1996) | A star schema per business process, sharing “conformed” dimensions | Fast queries that analysts can read | The shared dimensions need governance across teams |
| Data Vault (Linstedt, around 2000) | Hubs for keys, links for relationships, satellites for history | Loading many changing sources with full history | Many joins; a star is usually built on top for reading |
| One wide table | Everything pre-joined, in a columnar engine | The simplest queries; columnar compression absorbs the repetition | Every change to a dimension means a rebuild |
The schema as code that other people read
Whichever shape you choose, treat the schema as source code that other people read. Names are the interface. A status column on three tables meaning three different sets of values costs something every day, and a column called flag is a mystery within eighteen months. Pick one convention for singular or plural table names, one for timestamps, one for foreign keys, and write it down, so the tenth table matches the first. Keep migrations in the repository, reviewed like any other change, and make each one reversible or explicitly marked as not. The SQL page covers how to change a live schema without locking it; the rule here is about meaning.
Never give an old column a new meaning. A column whose meaning changed in place is read by code, reports and downstream consumers that still believe the old meaning, and none of them fails loudly. The Risk Management page tells the most expensive version of this: Knight Capital reused a flag for a new feature in 2012, one server still ran the code that read the old meaning, and the firm lost about $460 million in 45 minutes. When a meaning changes, add a new column with a new name, move the readers over, and drop the old one — and remember that the readers now include whoever consumes your tables through change data capturestreaming changes from a database logStreaming every insert, update and delete from a database’s own change log to other systems, as tools such as Debezium do. It keeps a second copy in sync without the application writing twice, at the price of coupling every consumer to the source’s table layout.All terms, who depends on their shape as much as your own code does.
Five cases, and what each one was missing
None of these needed a clever attacker or an exotic bug. Each is one modelling decision, made by default, meeting the world.
| Case | What happened | What it shows |
|---|---|---|
| Basecamp, 2018 | The events table’s 32-bit key reached 2,147,483,647, and Basecamp 3 was read-only for almost five hours | A key’s type is a capacity limit (row 3) |
| Gene names, 2016–2021 | Spreadsheets turned gene names into dates in a fifth of the genomics papers checked, later almost a third; in 2020 the genes were renamed | A value that looks like a date is still text; natural identifiers change (rows 2 and 6–10) |
| The NULL plate, 2016–2019 | A driver registered the plate NULL and received $12,049 of fines written without a plate number | Absence stored as a value (row 9) |
| Windows Azure, 2012 | “One year from today” was computed by adding one to the year; on 29 February that gave a date that does not exist | Dates and durations belong to types, not to field arithmetic (row 7) |
| Robodebt, 2016–2023 | Annual income averaged over 26 fortnights was treated as fortnightly income; about 470,000 debts were raised wrongly | A number spread over a finer grain invents data |
Basecamp is the case for row 3. At 7:21 a.m. Central time on Thursday 8 November 2018, Basecamp 3 stopped accepting changes. Its events table records almost every action in the product, from posting a message to applauding a comment, and its key was a 32-bit integer that had just reached 2,147,483,647. Every action that tried to record an event failed, so the whole product went read-only. Changing the column to a 64-bit integer on a table that large was estimated at an hour and forty minutes, and in all the product was read-only for almost five hours, until 12:11 p.m. The detail David Heinemeier Hansson put in the post-mortema written review after a failureA written review held after something went wrong, covering what happened, why, and what changes so it does not happen the same way. The word "blameless" in front of it is not politeness: an engineer who expects to be blamed reports less, and a review that produces a lesson rather than a change to a system has produced nothing.All terms himself is the sharpest one. Ruby on Rails, which Basecamp created, had made 64-bit keys the default in version 5.1 in 2017, and the team had been working on this very table in the days before — they had misjudged how fast the headroom was running out. A key’s type is a capacity limit, and capacity limits need an alert long before they need a migration.
The gene-name case shows a type chosen by a tool rather than by anyone. In 2016 Mark Ziemann, Yotam Eren and Assam El-Osta scanned the supplementary spreadsheets of published genomics papers and found gene names silently converted in 987 files from 704 articles — about a fifth of the papers that shipped gene lists in Excel. SEPT2 had become 2 September, MARCH1 had become 1 March, and some identifiers had become floating-point numbers. The spreadsheet guessed a type from what the value looked like, and guessed wrong. A follow-up in 2021 found the share had risen to 30.9%. The fix that finally worked came from the other side. In 2020 the HUGO Gene Nomenclature Committee renamed 27 genes — SEPT1 became SEPTIN1, MARCH1 became MARCHF1 — partly so that spreadsheets would leave them alone. A whole scientific field changed its natural identifiers because tools kept mistyping them. That is rows 2 and 6–10 at once: an identifier that looks like a number or a date is text, and natural identifiers do change.
Where it degrades
Data models rarely fail on the day they are designed. They fail the first time the world does something the model did not allow for. Almost every such failure is one of these:
- A schema shaped around the current screen, which has to be migrated the first time the screen changes.
- A table that holds two entities, told apart by a type column and a set of columns that are NULL (row 1).
- A natural key used as the primary key, until the business changes the value it is made of (row 2).
- A 32-bit key on a table that counts events, with no alert on how much headroom is left (row 3).
- Money in a floating-point column, and timestamps without a zone (rows 6–7).
- Nullable columns by default, and stand-in values that mean “unknown” to some readers and nothing to others (row 9).
- A denormalised copy with nothing keeping it honest, which drifts silently and is trusted anyway (row 12).
- Only the current state stored, so the first question about last quarter has no answer (row 14).
- A fact table with no declared grain, so totals double-count or spread one number over rows it never described.
- An old column given a new meaning, read by code and reports that still believe the old one.
The honest limit is that a model can only enforce what someone understood. Normal forms remove redundancy, constraints refuse impossible rows and types refuse impossible values, but none of them can tell that a correct-looking number was invented, or that "one year" was computed by hand, or that a value two systems both call NULL means two different things. Those are decisions about meaning, made by people who know the business, and the schema is only where they are written down. A model that nobody can explain in sentences is not a model yet, however normalised it is.
The practical check takes an afternoon. For the ten largest tables, write one sentence saying what a row is; any table that needs two sentences is a finding. List every 32-bit key and its current maximum against 2,147,483,647. Search the schema for float columns that hold money and timestamps without a zone. Count the nullable columns and ask, for each, which of the two good reasons applies. And ask the finance team what question about the past they could not answer last quarter.
When to use it
Use it when
- Before the first table exists, since every later change is a migration and a deployment rather than an edit.
- Whenever a new entity enters the product, so it arrives with its key, constraints, types and history decided.
- When the same fact starts appearing in two places, which is the signal that a normal form was skipped.
- Before adopting a document or wide-column store, where the model has to be right the first time.
Avoid it when
- Normalising a reporting or analytics table, where a wide, repeated shape at a declared grain is the point.
- Chasing normal forms above the third on an ordinary product schema, where the cases they cover do not arise.
- Modelling a genuinely variable payload — a webhookan HTTP call saying something happenedAn HTTP request one system sends to a URL you registered, to tell you that something happened — a payment succeeded, an order shipped — so you do not have to keep asking. Delivery is usually at least once and sometimes out of order, so the receiver must cope with the same event arriving twice.Full article →All terms body, a third-party response — as fifty nullable columns.
- A design review so long that nothing ships, when a small schema and a migration path would answer it.
Sources and method
What this page stands on
- 19 sources
- 5 documented failures
- Checked October 13, 2026
The failures this page learns from
the section “Five cases, and what each one was missing”- PostmortemUpdate on Basecamp 3 being stuck in read-only as of Nov 8, 9:22am CST (opens in a new tab)Signal v. Noise · Nov 8, 2018
- PostmortemDHH — Postmortem on the read-only outage of Basecamp on November 9th, 2018 (opens in a new tab)Signal v. Noise · Nov 12, 2018
- ResearchMark Ziemann, Yotam Eren, Assam El-Osta — Gene name errors are widespread in the scientific literature (opens in a new tab)Genome Biology · 2016
- ResearchMandhri Abeysooriya et al. — Gene name errors: Lessons not learned (opens in a new tab)PLOS Computational Biology · Jul 2021
- StandardElspeth A. Bruford et al. — Guidelines for human gene nomenclature (opens in a new tab)Nature Genetics · Aug 2020
- ArticleHow a ‘NULL’ License Plate Landed One Hacker in Ticket Hell (opens in a new tab)WIRED · Aug 2019
- PostmortemBill Laing — Summary of Windows Azure Service Disruption on Feb 29th, 2012 (opens in a new tab)Microsoft Azure Blog · Mar 2012
- Regulator reportReport — Royal Commission into the Robodebt Scheme (opens in a new tab)Royal Commission into the Robodebt Scheme · Jul 7, 2023
Research
the normal forms and the entity–relationship model- ResearchE. F. Codd — A Relational Model of Data for Large Shared Data Banks (opens in a new tab)Communications of the ACM · Jun 1970
- ResearchWilliam Kent — A Simple Guide to Five Normal Forms in Relational Database Theory (opens in a new tab)Communications of the ACM · Feb 1983
- ResearchPeter Pin-Shan Chen — The Entity-Relationship Model: Toward a Unified View of Data (opens in a new tab)ACM Transactions on Database Systems · Mar 1976
Practices and where the terms come from
antipatterns, names and money, time, keys and warehouses- BookBill Karwin — SQL Antipatterns (opens in a new tab)Pragmatic Bookshelf · 2010
- ArticlePatrick McKenzie — Falsehoods Programmers Believe About Names (opens in a new tab)Kalzumeus · Jun 17, 2010
- BookRichard T. Snodgrass — Developing Time-Oriented Database Applications in SQLMorgan Kaufmann · 1999
- DocumentationPostgreSQL 18 — Release Notes (opens in a new tab)postgresql.org
- StandardRFC 9562 — Universally Unique IDentifiers (UUIDs) (opens in a new tab)IETF · May 2024
- BookRalph Kimball — The Data Warehouse Toolkit (opens in a new tab)Wiley · 1996
- StandardISO 4217 — Currency codes (opens in a new tab)ISO
- StandardRegulation (EU) 2016/679 — General Data Protection Regulation (opens in a new tab)European Parliament and Council · Apr 27, 2016
Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.
Found this useful?
Share it with someone who is working on the same problem.