The previous lesson on schema design covered how to define tables well — types, keys, indexes. This one is about the harder question that comes first: what tables should exist at all?
Data modelling is the step where you read a requirements document and decide which nouns become entities, how they relate, and what the system will therefore find easy or impossible to answer later.
Get this wrong and no amount of indexing rescues you. The model decides which questions are cheap.
Finding the entities
Start with the requirements and underline the nouns. For a course platform:
"Students enrol in courses. Each course has several lessons and is taught by an instructor. Students leave reviews and earn a certificate on completion."
Six candidate entities. Not every noun survives — some are attributes, not entities. The test is whether the thing has its own identity and lifecycle.
- A student has identity: it exists independently, changes over time, is referenced by other things. Entity.
- A student's email address has no independent existence. Attribute.
- A certificate is issued at a moment, has a number, may be revoked. Entity.
The three relationships
Every pair of entities relates in one of three ways, and each has a standard representation.
One-to-one
One user has one profile. Rare in practice, because you can usually merge the two tables.
It earns its place when you are separating sensitive or rarely-read columns — keeping identity documents in a separate table with tighter access, or moving a large biography field out of a row you read constantly.
One-to-many
The most common by far. One instructor teaches many courses; one course has many lessons.
Represent it with a foreign key on the "many" side:
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
instructor_id BIGINT NOT NULL REFERENCES instructors(id),
title TEXT NOT NULL
);The foreign key always lives on the child. A beginner instinct is to store a list of course ids on the instructor — that is the wrong side, and it fights the database rather than using it.
Many-to-many
Students enrol in many courses; courses have many students. Neither side can hold the key, so you need a join table:
CREATE TABLE enrolments (
student_id BIGINT NOT NULL REFERENCES students(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (student_id, course_id)
);erDiagram
STUDENT ||--o{ ENROLMENT : has
COURSE ||--o{ ENROLMENT : has
COURSE ||--o{ LESSON : contains
INSTRUCTOR ||--o{ COURSE : teachesHere is the insight beginners miss: the join table is usually a real entity in disguise. It started as plumbing to connect two things, but almost immediately it wants attributes of its own — when they enrolled, their progress, whether they completed, what they paid.
The moment a join table has attributes, stop thinking of it as a link and give it a proper name. enrolments is a concept the business talks about; student_courses is not.
Model events, not just current state
This is the most valuable idea in the lesson, and the one that most often separates a model that ages well from one that has to be rebuilt.
The naive model stores the present:
accounts (id, balance)It answers "what is the balance?" instantly, and nothing else. It cannot answer:
- How did this balance come about?
- What was it last Tuesday?
- Who changed it, and why?
- A customer disputes a charge — what happened?
Store the events that produced the state instead:
transactions (id, account_id, amount_cents, kind, created_at, reference)Now the balance is derivable, and history is intact. Every question above becomes answerable. If computing the balance from history gets slow, you cache it — but the cache is derived from the truth rather than replacing it.
The rule generalises: when something changes for a reason people will later ask about, model the change, not just the result. Balances, inventory levels, subscription status and order state all belong to this family.
Modelling time properly
Two mistakes are so common they are worth naming.
Overwriting values that need history. If a product's price changes, updating products.price destroys the past — and now every historical order shows the current price rather than what the customer actually paid. Store the price on the order line at the moment of purchase.
Booleans where timestamps belong. is_published BOOLEAN records state; published_at TIMESTAMPTZ records state and when, in the same storage. Null means not published. You gain the ability to answer "what was published last month?" for free.
Denormalisation is a decision, not a shortcut
The schema lesson covered the trade-off. In modelling, the key discipline is to be explicit about who owns the copy.
If you store author_name on posts for faster listing, you have created a second copy of a fact. Someone must update it when the author renames themselves. If nothing does, your data quietly disagrees with itself and no error is ever raised.
Duplicate deliberately, in one place, with a stated owner. Duplicating by accident is how systems end up with three different values for the same customer's name.
A worked example
Requirement: "Customers place orders containing several products. Each order has a delivery address and a status. We need to report monthly revenue by product."
The entities and relationships:
customers (id, name, email, created_at)
addresses (id, customer_id, line1, city, postcode)
products (id, name, price_cents, active)
orders (id, customer_id, address_id, status, placed_at)
order_items (id, order_id, product_id, quantity, unit_price_cents)Two decisions carry most of the weight:
order_items stores unit_price_cents. It duplicates the product price — deliberately. Prices change, and an order must record what was actually charged. Reading the price from products at report time would silently rewrite history.
orders references an address rather than storing customer address inline. A customer has several addresses and may change them; the order must remember where it actually went. For maximum safety many systems snapshot the address onto the order, for the same reason as the price.
Notice both decisions come from the same principle: an order is a historical fact, so it must not change when the present changes.
Check your understanding
Common mistakes
Modelling the screen instead of the domain. Tables shaped like the current UI break the moment the UI changes. Model what things are, not how they are currently displayed.
One giant table with many nullable columns. When half the columns only apply to some rows, you probably have two entities crammed together.
No join table for many-to-many. Comma-separated ids in a column cannot be indexed, joined or constrained, and will be regretted.
Ignoring cardinality edge cases. Can an order have zero items? Can a course have no instructor? Decide deliberately and enforce with NOT NULL and constraints, rather than discovering the answer from corrupt data.
Premature denormalisation. Copying fields for speed before measuring gives you the synchronisation burden without the benefit.
Practice
Model a subscription that can be created, upgraded, downgraded, paused and cancelled, where billing must be able to explain any charge. State your tables and why.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Billing must explain a charge months later. That requirement decides the model.
Show a strong answer
The requirement 'explain any charge' rules out storing only the current plan. If the row says plan = pro, nobody can tell whether the customer was on pro for the whole month or upgraded on the 14th — and the charge depends entirely on that.
Two tables:
subscriptions— the current state: customer, current plan, current status, and the derived fields billing reads on the fast path. This exists for convenience and speed, and it is a projection, not the source of truth.subscription_events— append-only, one row per change: subscription ID, event type (created,upgraded,downgraded,paused,resumed,cancelled), the plan before and after, the effective timestamp, and who or what caused it. Never updated, never deleted.
Why this shape:
- Any charge is explainable by replaying the events across the billing period. "£40 = 13 days on standard plus 17 days on pro" is derivable, and the derivation is auditable.
- Bugs are recoverable. If proration was computed wrongly for three months, the events still hold the truth and the charges can be recomputed. With a mutable current-state row, that history is gone.
- Effective time and record time are separate. A downgrade requested today but effective at the next renewal has two different dates, and billing needs both. A single mutable row cannot express this at all.
One trap to avoid: do not let the projection and the events drift. Rebuild subscriptions from the events on a schedule and alert on any difference — otherwise a missed update makes the fast path quietly wrong while the audit trail is right.
A colleague proposes storing a user's addresses as a JSON array on the users row 'because we always fetch them together'. When is this right, and when does it fail?
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Ask what queries exist today, and which ones you would be unable to write.
Show a strong answer
When it is right: the addresses are genuinely a value belonging to that user, always read as a whole, never queried independently, and never referenced by anything else. A user's notification preferences fit this well — nobody ever asks "which users have preference X" — and the JSON avoids a join and a second table for no loss.
When it fails, and addresses usually do fail:
- Something else needs to reference one. An order ships to a specific address. With JSON there is nothing to point at — no ID, no foreign key — so the order must copy the address, and now you have the same data in two shapes with no relationship between them.
- You need to query across them. "How many customers are in Scotland?" or "find users with an invalid postcode" require reading and parsing every user row. What would be an index lookup becomes a full scan.
- Concurrent updates collide. Two requests each read the array, modify one entry, and write the whole array back — the second overwrites the first. With rows, they touch different rows and both survive.
- Constraints cannot be enforced. "Exactly one default address" is a database constraint against rows and a hope against JSON.
The decisive question is not 'do we fetch them together?' — it is 'does anything need to identify one of them?' The moment an address needs an identity, it needs a row. Fetching together is solved by a join; identity is not solvable inside a JSON blob.
Key takeaways
- The model decides which questions are cheap later; indexes cannot rescue a wrong one.
- Something is an entity if it has its own identity and lifecycle; otherwise it is an attribute.
- Foreign keys live on the "many" side; many-to-many needs a join table — which is usually a real entity in disguise once it gains attributes.
- Model events, not just current state. Storing transactions rather than a balance keeps history and answers questions the state alone cannot.
- Prefer timestamps over booleans — same storage, strictly more information.
- Historical records must not change when the present changes: snapshot prices and addresses onto orders deliberately.
- Denormalise explicitly, with a named owner for keeping the copy in sync.
Further reading
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — entities versus value objects, which is the distinction in the second question.
- Martin Fowler, "Event Sourcing" (2005) — storing changes rather than current state.
- Edgar F. Codd, "Further Normalization of the Data Base Relational Model" (1971).
- Richard Snodgrass, Developing Time-Oriented Database Applications in SQL (Morgan Kaufmann, 1999) — effective time versus record time.
- Pat Helland, "Immutability Changes Everything" (CIDR 2015) — why append-only models simplify so much.