Denormalisation on purpose

Database design · Practical · 10 min

Real systems denormalise; doing it on purpose with a sync strategy is what separates a design from a mess.

Denormalisation stores redundant or derived data to make reads cheaper: a comment_count on posts, a customer's name copied onto invoices, a precomputed daily totals table. It trades write complexity and consistency risk for fewer joins and less computation at read time.

Denormalise when you have a measured read problem that indexes and query tuning didn't solve, or when the copy is semantically a snapshot rather than a duplicate. That second case is important: an invoice should keep the billing address as it was when issued, so copying it isn't redundancy at all — it is a different fact.

Common techniques:

  • Counters and aggregates kept on the parent row (posts.comment_count).
  • Summary or rollup tables refreshed periodically, or materialized views.
  • Embedding a few columns from a related table to avoid a hot join.
  • Star schemas in analytics, where dimension data is deliberately flattened.

Every copy needs a plan to stay correct: update it in the same transaction as the source, use a trigger, or accept staleness and recompute on a schedule. Also have a way to rebuild it from the source of truth, because drift will happen. Counters updated by many concurrent writers can become lock hot spots; batching or periodic recomputation can be better than incrementing on every write.

Example

CREATE TABLE post    (id int PRIMARY KEY, title text,
                      comment_count int NOT NULL DEFAULT 0);   -- denormalised
CREATE TABLE comment (id int PRIMARY KEY, post_id int REFERENCES post(id), body text);
INSERT INTO post (id, title) VALUES (1, 'Hello');

-- keep the copy in sync in the same transaction as the source write
BEGIN;
INSERT INTO comment VALUES (100, 1, 'First!');
UPDATE post SET comment_count = comment_count + 1 WHERE id = 1;
COMMIT;

-- and keep a way to rebuild it from the source of truth
UPDATE post p SET comment_count = (SELECT count(*) FROM comment c WHERE c.post_id = p.id);
SELECT id, comment_count FROM post;   -- 1 | 1

Remember

  • Denormalise for a measured read problem, not by default
  • Snapshots (invoice addresses) are facts, not redundancy
  • Every copy needs a sync strategy and a rebuild path

Watch out for

  • Adding a cached column with no code path that keeps it updated on every write.

Check yourself

  1. Which is NOT really denormalisation but a separate fact?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/rules-materializedviews.html