Database Design
When to Denormalise
A design that was never normalised is not
Jr Codex DBMS Notes
Level: Intermediate Prerequisites: Chapter 4: Normalisation Time to complete: ~20 minutes
Table of Contents
- Denormalisation Is a Trade, Not a Shortcut
- The Five Patterns
- Keeping Redundant Data Honest
- Historical Snapshots
- The Decision Procedure
- Summary & Next Steps
1. Denormalisation Is a Trade, Not a Shortcut
The Exchange
─────────────────────────────────────────
YOU GAIN fewer joins, faster reads
YOU PAY redundant data
slower, more complex writes
the anomalies of Chapter 3, back
a permanent obligation to keep
copies in sync
─────────────────────────────────────────
The Precondition
─────────────────────────────────────────
Denormalise only AFTER normalising.
A design that was never normalised is not
denormalised — it is unconsidered. You cannot
make an informed trade without knowing what the
correct design was.
And denormalise only after MEASURING that the
join is genuinely the bottleneck. It usually is
not; a missing index usually is (Module 5).
─────────────────────────────────────────
2. The Five Patterns
1. STORED AGGREGATES
─────────────────────────────────────────
Keep a running count or sum rather than
recomputing it.
ALTER TABLE posts ADD COLUMN comment_count INT
NOT NULL DEFAULT 0;
WHEN: the aggregate is read far more often than
the underlying rows change — a comment count
shown on every page view.
2. DUPLICATED COLUMNS
─────────────────────────────────────────
Copy a column from a parent to avoid a join.
ALTER TABLE order_items ADD COLUMN product_name TEXT;
WHEN: the join is on a very hot path and the
source value rarely changes.
RISK: highest of all five. Two copies, one truth.
3. PRE-JOINED TABLES
─────────────────────────────────────────
A materialised view, or a reporting table
refreshed on a schedule (Module 3, Chapter 6).
WHEN: expensive analytical queries that tolerate
being minutes stale.
BEST option of the five — the redundancy is
explicit, isolated, and rebuildable.
4. REPEATING GROUPS
─────────────────────────────────────────
phone1, phone2, phone3
WHEN: the count is genuinely fixed and small and
will not grow. Rarely true.
Almost always a mistake — this is the 1NF
violation from Chapter 4 with extra steps.
5. STAR SCHEMAS
─────────────────────────────────────────
A central fact table with wide, denormalised
dimension tables.
WHEN: analytics and data warehousing. This is the
STANDARD design there, not a compromise —
workloads are read-mostly and bulk-loaded, so
update anomalies barely apply.
─────────────────────────────────────────
3. Keeping Redundant Data Honest
Once a value is stored twice, something must keep the copies in agreement. There are four mechanisms, in decreasing order of reliability.
-- 1. TRIGGERS — the database maintains it. Cannot be bypassed.
CREATE TRIGGER bump_comment_count AFTER INSERT ON comments
BEGIN
UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
END;
CREATE TRIGGER drop_comment_count AFTER DELETE ON comments
BEGIN
UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
END;The Four Mechanisms
─────────────────────────────────────────
TRIGGERS the database enforces it. Every
writer is covered, including
manual fixes. Cost: invisible
logic, harder debugging.
APPLICATION update both in one transaction.
CODE Visible and testable. Fails the
moment anything else writes to
the table.
SCHEDULED recompute periodically.
RECONCILIATION Tolerates drift; good for
pre-joined tables.
MATERIALIZED the database owns the copy and
VIEWS the refresh. Cleanest when your
database supports it.
─────────────────────────────────────────
-- Whichever you choose, WRITE A DRIFT CHECK and run it on a schedule.
SELECT p.id, p.comment_count AS stored, COUNT(c.id) AS actual
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id, p.comment_count
HAVING p.comment_count <> COUNT(c.id); -- should return ZERO rowsThe Rule
─────────────────────────────────────────
Every denormalised value needs a query that can
detect drift, and that query should run
automatically.
Redundancy without a drift check is not a
performance optimisation. It is a data-corruption
bug with a delay on it.
─────────────────────────────────────────
4. Historical Snapshots
The one case where duplication is not denormalisation at all — and the one people most often get wrong in the opposite direction.
The Problem
─────────────────────────────────────────
order_items references products(id), and reads
the price by joining.
The product's price changes next month.
Every historical invoice now shows the NEW price.
Your accounts no longer match what customers
actually paid.
─────────────────────────────────────────
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price NUMERIC NOT NULL, -- the price AT THE TIME OF SALE
product_name TEXT NOT NULL, -- the name AT THE TIME OF SALE
PRIMARY KEY (order_id, product_id)
);Why This Is NOT Denormalisation
─────────────────────────────────────────
`products.price` is the CURRENT price.
`order_items.unit_price` is the price PAID.
These are two different facts that happen to
have been equal once. Neither is a copy of the
other.
There is no redundancy, no drift, and nothing to
reconcile — so no anomaly is possible.
─────────────────────────────────────────
The General Principle
─────────────────────────────────────────
Any value that was true AT A MOMENT and must
remain true afterwards should be stored, not
derived.
Prices, tax rates, addresses on shipped orders,
terms accepted at signup, exchange rates.
Asking "is this the current value, or the value
that applied?" resolves most arguments about
whether a column is duplication.
─────────────────────────────────────────
5. The Decision Procedure
Before Denormalising, In Order
─────────────────────────────────────────
1. MEASURE. Which query is slow, how slow, and
how often does it run?
2. READ THE PLAN (Module 6, Chapter 4). Is the
join actually the cost, or is it a sequential
scan?
3. ADD AN INDEX (Module 5). This fixes most
apparent join problems and costs no
correctness.
4. REWRITE THE QUERY. Aggregate earlier, select
fewer columns, avoid the fan-out.
5. CACHE OUTSIDE THE DATABASE. Often simpler than
changing the schema, and trivially discardable.
6. MATERIALISE. A view or a rebuilt reporting
table — redundancy that is explicit and
isolated.
7. ONLY THEN denormalise columns, with a
synchronisation mechanism and a drift check.
─────────────────────────────────────────
The Documentation Requirement
─────────────────────────────────────────
Every denormalised column should carry a comment
saying WHY, WHAT keeps it in sync, and HOW to
detect drift.
COMMENT ON COLUMN posts.comment_count IS
'Denormalised for the feed query. Maintained
by triggers bump/drop_comment_count.
Drift check: reports/comment_count_drift.sql';
Without that, the next engineer sees a redundant
column, assumes it is a mistake, and either
removes it or — worse — starts writing to it
directly.
─────────────────────────────────────────
6. Summary & Next Steps
Key Takeaways
- Denormalisation trades write complexity and redundancy for read speed, and is only meaningful after a normalised design exists to trade against.
- Pre-joined and materialised tables are the safest pattern because the redundancy is explicit, isolated and rebuildable; duplicated columns are the riskiest.
- Every denormalised value needs a synchronisation mechanism and an automated drift check — redundancy without one is a corruption bug on a delay.
- Storing a historical value such as price-at-sale is not denormalisation: the current price and the price paid are two different facts, so no drift is possible.
Module 4 Complete — What's Next
You can now design a schema that stores each fact once and knows when to break that rule deliberately. Module 5 goes underneath the schema: how rows are actually laid out on disk, and why an index turns a full scan into a handful of page reads.
Concept Check
- Why is "we never normalised, so we are denormalised" a category error?
- Give the argument that
order_items.unit_priceis not redundant withproducts.price. - Which three steps should be exhausted before denormalising a column, and why is indexing among them?
Next Module
→ Module 5: Storage and Indexing
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index