DevelopmentAll levels

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 questionThe answer that holds upWhat the wrong answer costs later
1What 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
2What identifies a row?A surrogate primary key, plus a unique constraint on the business identifierThe business identifier as the key — until it changes, and every reference changes with it
3How large can the key grow?bigint or a UUID; never a 32-bit integer for anything that counts eventsA 32-bit key stops at 2,147,483,647 — and so do inserts
4What does this row point at, and may that be missing?A foreign key on the “many” side; NOT NULL unless the business allows an orphanOrphans nobody can explain, and a nullable reference nobody decided on
5Is a many-to-many link a thing of its own?A table per link — it usually gains a quantity, a role or a dateA list of ids inside a column, which no foreign key or index can check
6How is money stored?An exact decimal or an integer of minor units, with the currency beside itFloating point: 0.1 + 0.2 is not 0.3, and rounding differs between languages
7Is 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 appointmentA timestamp with no zone: ambiguous twice a year and wrong across regions
8Is the set of values fixed?A CHECK, an enum or a lookup tableFree text: “paid”, “PAID” and “Payed” within a year
9Why may this column be NULL?One of two reasons: optional in the business, or not yet known when the row is createdNULL meaning five different things — or a stand-in such as 'N/A' or 1900-01-01 meaning one of them
10Why is this JSON rather than columns?JSON only for what genuinely varies per row and is never filtered or constrainedFilters and rules on fields the database cannot see or check
11Is every fact stored exactly once?Each non-key column states a fact about the key, the whole key and nothing but the keyThe same fact in two places, one of which an update will miss
12Who keeps each copy true?Every deliberate copy has a mechanism — a trigger, a rebuild — or is meant to be frozenA copy that drifts silently and is trusted anyway
13Is this value derived, or was it agreed?Store what was agreed — the price at purchase, the total the customer accepted; compute what is derivedA recomputed total that changes when a price does
14Will anyone ask what this looked like in March?History for the entities that need it, decided now: a history table or validity periodsOnly the current state — so the question has no answer, ever
15When 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 itMonthly totals that change after the month is closed, with no way to say why
16What does deleting it mean?A decision per entity: hard delete, soft delete enforced in one place, or anonymise — with a retention periodRemoved 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.

LevelWhat it saysWho reads itThe usual mistake
ConceptualThe things the business talks about and how they relate: a customer places ordersThe business and the team, togetherSkipped, so the first argument about meaning happens in a code review
LogicalEvery attribute, key and relationship, normalised; no engine yetDevelopers and analystsMixed with the physical, so an index decision bends the model
PhysicalTables, types, indexes and partitions for one engineThe engine, and whoever tunes itTreated 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.

Crow’s-foot notation, introduced by Gordon Everest in 1976, marks each end of a relationship with two symbols. The one nearest the box is the maximum: a bar for one, the three-pronged foot for many. The one further out is the minimum: a circle for zero, a bar for one. Read each line as a sentence, from one box to the marks at the far end: a customer places zero or more orders, and an order belongs to exactly one customer. The order line in the middle resolves the many-to-many between orders and products, and it is an entity in its own right, with a quantity and a price.

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.

KeySizeGood forWatch out for
32-bit integer4 bytesSmall reference tables that will never reach two billion rowsStops at 2,147,483,647 — Basecamp’s events table reached it in 2018 (row 3)
64-bit integer (bigint)8 bytesThe default for an internal key: compact, ordered, enough for any realistic countIn a URL it reveals how many rows exist and how fast they grow
UUIDv416 bytesIds generated anywhere — on a phone, in another service — that nobody can guessRandom order scatters inserts across the index (see the SQL page)
UUIDv716 bytesIds generated anywhere that still sort by creation time (RFC 9562, 2024)Reveals when the row was created
Natural keyVariesReference data whose codes genuinely never change, such as ISO currency codesBusiness identifiers change, and every reference changes with them (row 2)
Composite keyIts columns combinedLink tables, and children numbered within a parentEvery table that references it carries all of its columns
RelationshipHow it is representedThe detail people miss
One to manyA foreign key on the “many” side, pointing at the oneWhether the key may be NULL decides whether an orphan is legal — a business question (row 4)
Many to manyA third table holding one row per pair, with a foreign key to each sideThat table is usually an entity in its own right and gains attributes (row 5)
One to oneA shared key, or a unique constraint on the foreign keyGenuinely rare: usually one entity split for storage reasons, or two entities confused
HierarchyA foreign key from a row to another row in the same tableA 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:

ShapeWhat it looks likeWhat breaksInstead
A list in a columntags = 'urgent,billing'No index, no foreign key; “billing” also matches “rebilling”A row per value in a link table (row 5)
Numbered columnsphone_1, phone_2, phone_3The fourth phone; a search across all threeA child table
Entity–attribute–valueOne table of (entity, attribute, value) rows that can hold “anything”No types, no constraints, and one join per attribute to read one recordReal columns for known attributes; JSON for the genuinely variable rest (row 10)
Polymorphic associationcommentable_type plus commentable_id, pointing into one of several tablesNo 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.

Each normal form removes one specific way data goes wrong when the same fact is stored in more than one place. First normal form: one value per cell and no repeating groups, so a column holding a comma-separated list — or numbered columns standing in for a list — is split into rows. Second: in a table keyed by more than one column, no field may depend on only part of that key, so a product name does not sit on every order line. Third: no field may depend on another non-key field, so a customer city does not sit on the order and drift when they move. The whole of third normal form fits in one sentence — every non-key field depends on the key, the whole key, and nothing but the key.

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.

FormWhat it removesWhen you meet it
Boyce–Codd (1974)A dependency on part of an overlapping candidate keyRarely: 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 bothA 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 onesAlmost never in a product schema
Sixth (Date, Darwen, Lorentzos, 2002)Every non-key column in its own table, each with its own validity periodTemporal 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 copyLegitimate whenWhat keeps it true
Product name and price on an order lineAlways — it records what was soldNothing needs to: it is meant to be frozen
The order totalAlways — it is the amount the customer agreed toFrozen at checkout, with a check that the lines added up at that moment
A comment count on a postWhen the count is read on every page viewAn atomic increment in the same transaction as the comment, or a trigger
The customer’s name and address on an invoiceAlways — an invoice is a document as it was issuedFrozen; a correction is a new document
A search or reporting copyWhen reads need a different shape from writesRebuilt 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 storingThe choice that hurtsWhat to do instead
MoneyA floating-point number, which cannot hold 0.10 exactlyAn 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 timeA timestamp with no zoneA 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 yearConverted to UTC when it was bookedThe 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 fieldAn interval type and the engine’s or library’s date arithmetic: 29 February plus one year is not a date
A fixed set of statesFree textA 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 stateA boolean is_cancelledA timestamp (cancelled_at) or a status: the boolean loses “when”, and a third state always arrives
Something that looks like a numberA numeric column for postcodes, phone numbers, account numbersText with a format check: leading zeros and plus signs are part of the value
A person’s nameA required first name and a required last nameOne full-name field, plus what the person wants to be called
An email addressUnique as typed, so Ann@x.com and ann@x.com are two accountsUnique on the lower-cased value
A structured blobA JSON column for fields every request filters onPromote 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.

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:

ApproachWhat it keepsThe question it answersWhat it costs
An audit logWho 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)
BitemporalBoth: 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 sourcingThe changes themselves are the record; state is derivedAny of the above, by replayingEvery 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.

Valid time is when something was true in the world; transaction time is when the system recorded it. A customer paid 102 on Friday 3 October. The payment was keyed in on Monday as 120 and corrected on Wednesday to 102. Each block sits in the Friday column of valid time and spans the days the system believed it. A report for that Friday is a horizontal line at the moment it ran: run on Sunday it finds nothing, on Tuesday 120, on Thursday 102. All three were right about what the system knew. A table that overwrites can only give the last answer; storing both times lets every earlier report be reproduced and explained.

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 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.

A star schema, from Ralph Kimball’s dimensional modelling, puts one fact table at the centre and the dimensions around it. The fact table’s grain is declared first — here, one product on one receipt — and every row holds the keys of its dimensions and the measures that can be added up: quantity and amount. Dimensions carry what you filter and group by: the calendar, the product’s brand and category, the store’s region, the customer’s segment. Queries become simple joins from the centre outward. The rule that keeps it honest is the grain: a number true at a coarser grain, such as a yearly total, must not be spread across rows that describe something finer.

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.

ApproachShapeOptimises forThe cost
Inmon (1992)A normalised enterprise warehouse, with departmental marts built from itOne integrated, consistent version of the truthSlow to build, and people query the marts anyway
Kimball (1996)A star schema per business process, sharing “conformed” dimensionsFast queries that analysts can readThe shared dimensions need governance across teams
Data Vault (Linstedt, around 2000)Hubs for keys, links for relationships, satellites for historyLoading many changing sources with full historyMany joins; a star is usually built on top for reading
One wide tableEverything pre-joined, in a columnar engineThe simplest queries; columnar compression absorbs the repetitionEvery 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 , 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.

CaseWhat happenedWhat it shows
Basecamp, 2018The events table’s 32-bit key reached 2,147,483,647, and Basecamp 3 was read-only for almost five hoursA key’s type is a capacity limit (row 3)
Gene names, 2016–2021Spreadsheets turned gene names into dates in a fifth of the genomics papers checked, later almost a third; in 2020 the genes were renamedA value that looks like a date is still text; natural identifiers change (rows 2 and 6–10)
The NULL plate, 2016–2019A driver registered the plate NULL and received $12,049 of fines written without a plate numberAbsence 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 existDates and durations belong to types, not to field arithmetic (row 7)
Robodebt, 2016–2023Annual income averaged over 26 fortnights was treated as fortnightly income; about 470,000 debts were raised wronglyA 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 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 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

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.