How it works
The explanation, with examples you can run.
What goes wrong with copies
Suppose orders carry a copy of the customer's email and country:
-- denormalised: the customer's details are repeated on every orderCREATE TABLE orders_wide ( id bigint PRIMARY KEY, customer_id bigint NOT NULL, customer_email text NOT NULL, customer_country text NOT NULL, total_cents integer NOT NULL);Three kinds of anomaly follow:
- Update. The customer changes their email; every one of their orders must be updated, and any that are missed now disagree.
- Insert. You cannot record a customer who has not ordered yet, because customer data only exists on orders.
- Delete. Deleting a customer's last order also deletes the only record of the customer.
The normalised version keeps customers in their own table, with orders.customer_id REFERENCES customers (id), and a join puts them back together when a query needs both. With an index on the join column, that join is cheap; it is rarely the bottleneck people fear.
Copies that are really snapshots
Not every repeated column is duplication. A line on an order should store the price that was charged, even though the products table has a price:
CREATE TABLE order_lines ( order_id bigint NOT NULL REFERENCES orders (id), product_id bigint NOT NULL REFERENCES products (id), quantity integer NOT NULL CHECK (quantity > 0), unit_price_cents integer NOT NULL, -- the price at checkout, not a copy of products.price PRIMARY KEY (order_id, product_id));The catalogue price will change; the price paid must not. The same goes for a shipping address on an order. These are separate facts that happen to look alike.
Denormalising on purpose
Sometimes a read is too slow or too frequent to compute each time, and a copy is worth it:
- Counters and totals (
review_count,balance_cents), updated in the same transaction as the rows they summarise. - Generated columns, which the database computes from other columns in the same row, so they cannot drift:
total_cents integer GENERATED ALWAYS AS (quantity * unit_price_cents) STORED. - Materialised views for reports, refreshed on a schedule with
REFRESH MATERIALIZED VIEW(addCONCURRENTLY, which needs a unique index on the view, to avoid blocking readers). - Read models in another store (a search index, a cache), fed from the database's changes.
The rule for all of them: one place is the source of truth, and every copy says how it is kept in step and how stale it may be. Measure the slow read first; a missing index is a far more common cause than too many joins.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.