SQL / DATA MODELLING AND NORMALISATION
When to denormalise on purpose
Decide when a redundant column is worth its write cost, keep it true with triggers, and prove it with a reconciliation query that must return no rows.
What you will learn
- Tell a derived value apart from a point-in-time fact before duplicating either
- Maintain a stored count with INSERT and DELETE triggers so it cannot drift
- Write a reconciliation query that returns zero while stored equals recomputed
- Refuse to denormalise when an index on the join column fixes the read instead
Understanding When to denormalise on purpose
Normalising to third normal form gives every fact exactly one home, which is why a single-row UPDATE can never leave two copies disagreeing. Denormalising on purpose gives that up in exchange for something you can name: a read that no longer joins, an aggregate that no longer scans thousands of child rows, or a value that must stay put while its source changes. The trade is not really speed against disk space; it is read cost against a synchronisation duty that you now own and the database no longer performs for you.
Two very different things get called denormalisation. Derived data - a reply count, a running balance, a pre-joined reporting row - is fully reconstructible from base tables, so the worst outcome is staleness and the repair is a recompute. A copied source value such as the unit price on an invoice line is not duplication at all: the price a customer was charged is a different fact from today's catalogue price, and joining to product to fetch it would rewrite history every time someone edits a number. The dangerous middle case is copying a mutable attribute that still lives authoritatively somewhere else with no rule about which copy wins.
Before adding the column, prove the join is the problem; a missing index on the foreign key produces exactly the same symptoms and costs nothing to fix. Then decide where maintenance lives: triggers and generated columns sit in the database where every writer is subject to them, while application code only holds if there is genuinely one write path, which stops being true the first time somebody runs a backfill script. Whatever you choose, keep a reconciliation query that recomputes the value from base tables and compares, because that is the only thing turning "should stay in sync" into something you can check. Then price the write side: every child insert now also updates a parent row, so a popular parent becomes a contention point.
In short, a redundant column is a debt with an interest rate paid on every write, and the enforcement mechanism is what stops that debt from turning into wrong answers.
CREATE TABLE thread (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
reply_count INTEGER NOT NULL DEFAULT 0 -- derived from post, stored on purpose
);
CREATE TABLE post (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL REFERENCES thread(id),
body TEXT NOT NULL
);
CREATE TRIGGER post_ai AFTER INSERT ON post BEGIN
UPDATE thread SET reply_count = reply_count + 1 WHERE id = NEW.thread_id;
END;
CREATE TRIGGER post_ad AFTER DELETE ON post BEGIN
UPDATE thread SET reply_count = reply_count - 1 WHERE id = OLD.thread_id;
END;
INSERT INTO thread (id, title) VALUES (1, 'Index bloat'), (2, 'Vacuum settings');
INSERT INTO post (id, thread_id, body) VALUES
(1, 1, 'Try REINDEX'),
(2, 1, 'Check fillfactor'),
(3, 2, 'Autovacuum handles it');
DELETE FROM post WHERE id = 2;
-- the read the column exists for: no join, no aggregate
SELECT id AS thread_id, title, reply_count FROM thread ORDER BY id;
-- the invariant that keeps it honest
SELECT COUNT(*) AS drifting_threads
FROM thread
WHERE reply_count <> (SELECT COUNT(*) FROM post WHERE post.thread_id = thread.id);Denormalisation trades a synchronisation duty for a cheaper read, so it is only justified when you can name the read it fixes and enforce the invariant that keeps the copy true.
Worked examples
A price copy that is a separate fact
Shows a duplicated column that must exist for correctness, not for speed, because it records a value as of a moment in time.
CREATE TABLE product (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL
);
CREATE TABLE order_item (
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL REFERENCES product(id),
qty INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL, -- what was charged, frozen at sale time
PRIMARY KEY (order_id, product_id)
);
INSERT INTO product VALUES (1, 'Keyboard', 4000);
INSERT INTO order_item VALUES (100, 1, 2, 4000);
UPDATE product SET price_cents = 5500 WHERE id = 1;
SELECT oi.order_id AS order_id,
oi.qty * oi.unit_price_cents AS invoiced_cents,
oi.qty * p.price_cents AS if_we_joined_instead
FROM order_item oi
JOIN product p ON p.id = oi.product_id;Example explained
Line 1unit_price_cents is written once when the order is placed and never refreshed, so it answers "what did this customer pay".
Line 2The UPDATE changes the catalogue price only; no trigger or job is expected to touch the order line.
Line 3invoiced_cents stays at 8000 while if_we_joined_instead reports 11000, a total the customer never agreed to.
Line 4There is no reconciliation query for this column because there is nothing to keep in sync, which is exactly what makes the copy safe.
An unenforced copy drifting
Shows what happens when a mutable attribute is duplicated with no mechanism deciding which copy is authoritative.
CREATE TABLE customer (
id INTEGER PRIMARY KEY,
region TEXT NOT NULL
);
CREATE TABLE sale (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customer(id),
region TEXT NOT NULL, -- copy of customer.region, maintained by nobody
amount INTEGER NOT NULL
);
INSERT INTO customer VALUES (1, 'EU'), (2, 'US');
INSERT INTO sale VALUES (1, 1, 'EU', 100), (2, 2, 'US', 250);
UPDATE customer SET region = 'US' WHERE id = 1;
SELECT 'from sale.region' AS source, region, SUM(amount) AS total
FROM sale
GROUP BY region
UNION ALL
SELECT 'from customer.region', c.region, SUM(s.amount)
FROM sale s JOIN customer c ON c.id = s.customer_id
GROUP BY c.region
ORDER BY source, region;Example explained
Line 1sale.region duplicates a column that is still authoritative in customer, so two places now claim to hold the same fact.
Line 2The UPDATE moves customer 1 to US and leaves both sale rows untouched, because nothing in the schema connects them.
Line 3Grouping on the copy reports 100 under EU, while grouping through the join puts all 350 under US.
Line 4Neither total looks broken on its own, which is why this drift is normally found by a reconciliation query rather than by a user.
Important notes
A trigger-maintained counter turns independent child inserts into repeated updates of one parent row, so concurrent writers queue on it and can deadlock when transactions touch parents in different orders.
In SQLite, INSERT OR REPLACE removes the conflicting row without firing the DELETE trigger unless PRAGMA recursive_triggers is ON, so that single statement inflates a trigger-maintained count.
Common mistakes
Adding the redundant column before reading the query plan: the real cost was a sequential scan on post.thread_id, so now there is a duplicated value, extra work on every insert, and the same slow read.
Incrementing the counter on INSERT but forgetting DELETE and forgetting UPDATE of thread_id, so moving or removing a post leaves counts permanently too high with no error anywhere.
Reading the copy in some queries and joining to the source in others, so two reports quietly disagree and nobody can say which number is the real one.
Try it yourself
Change, predict, then run
In a browser SQLite editor, recreate the thread and post schema and add an AFTER UPDATE OF thread_id trigger that decrements OLD.thread_id and increments NEW.thread_id. Move post 3 from thread 2 to thread 1, then rerun the drifting_threads query and confirm it still reports 0.
Open the SQL workspaceCheck your understanding
A report is slow because it joins orders to customers only to group by customer country. Which situation makes copying country onto orders a sound decision rather than a bug waiting to happen?
- The join is slow, so removing a join will always make the report faster
- The application always updates both tables inside the same function, so drift cannot happen
- The copy records the billing country as of the sale and is never meant to follow the customer's current country
- orders is far larger than customers, so one extra text column costs very little storage
Show answer
In option 3 the copied value is a different fact from the customer's current country, so there is no synchronisation duty at all and nothing can drift. Option 2 is tempting but a single write path is a convention rather than a constraint: a backfill script, an admin console, or a second service will eventually update customers alone, and no part of the database will notice.