Normalisation, and when to stop

Put each fact in exactly one place — and know which copies are worth keeping anyway.

17of 22
≈ 12Min read
5Parts

The spreadsheet that became a table

Every project has one: a wide table where each row repeats the customer, their city, the product and its price. It is easy to write and easy to read, and it goes wrong in three specific ways that have names.

one row per booking — and the customer written eight times
bookings_raw
  customer            customer_city   door       door_price  qty  day
  Arclith Interiors   Baku            Vesper     890         2    2024-03-01
  Arclith Interiors   Baku            Calastra   1650        1    2024-03-01
  Orsenne Ltd         London          Orsenne    1200.5      3    2024-03-12
  Orsenne Ltd         Manchester      Caivor     1980        1    2024-04-02   ← which city is right?

The three anomalies

Normalisation is the process of splitting a table until each fact lives in one row of one table. The anomalies then become impossible rather than unlikely.

AnomalyWhat happensIn this table
Updatea fact is stored many times, and one copy is missedOrsenne moved, and only the newer row says so
Insertyou cannot record one fact without anothera new door cannot exist until somebody books it
Deleteremoving one fact destroys anotherdelete the Berlin booking and Silica Design is gone

The forms, in the only version anyone needs

First normal form: one value per cell. No "oak, walnut" in a text column, no phone1, phone2, phone3. A repeating group is a table.

Second: every column depends on the whole key. In a table keyed by (order, door), the door's catalogue price does not depend on the order — so it belongs in doors.

Third: no column depends on another non-key column. customer_city depends on customer, not on the booking — so it belongs in customers.

That is the whole of it for practical purposes: every non-key column describes the key, the whole key, and nothing but the key. The higher forms exist and almost never change a design.

The copies you keep on purpose

order_items.unit_price is a duplicate of doors.price — and it is correct. The catalogue price is what a door costs today; the line price is what this customer agreed then. They are two different facts that happen to be equal at the moment of sale, and an invoice that changes when the price list changes is a bug in every jurisdiction.

Denormalising is a decision, not a mistake

A cached orders.total, a stored full_name, a counter of comments: all fine when the read pattern demands it and something keeps them true — a trigger, a job, or a single code path. The failure is not the copy; it is the copy nobody owns.

Splitting the table, in SQL

Then a booking keeps person_id instead of the name and the city, and the old columns are dropped. Real migrations run this in a transaction, with the old and new columns side by side for a release, so a rollback is possible.

build the new tables from the old one, in place
create table people (
  id   integer primary key,
  name text not null unique,
  city text
);

insert into people (name, city)
select   customer,
         (select customer_city from bookings_raw b2
          where  b2.customer = b.customer
          order by day desc limit 1)          -- the most recent spelling wins
from     bookings_raw b
group by customer;

Practice