What normalization buys you
Normalization is usually the first topic newcomers meet in database work, and the motivation behind it is narrow: eliminate redundancy so that mistakes and inconsistencies have nowhere to hide. Store each fact once and there is only one version of it to be wrong.
A minimal product/category schema shows the pattern in PostgreSQL:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
CREATE TABLE t_category ( id int PRIMARY KEY, cat_name text ); INSERT INTO t_category VALUES (1, 'Shoes'), (2, 'Skiing equipment'), (3, 'Plants'); CREATE TABLE t_product ( id int PRIMARY KEY, cat_id int REFERENCES t_category (id) ON UPDATE CASCADE ON DELETE CASCADE, product_name text, price numeric ); INSERT INTO t_product VALUES (1, 1, 'Blue boots', 154.98), (2, 1, 'Yellow flip flop', 32.79), (3, 2, 'Blizzard carver', 459.98), (4, 3, 'Oak', 17.95), (5, 3, 'Birch', 15.39), (6, 3, 'Elder flower', 9.75) ; |
Category names appear exactly once, in the category table. The product table holds only the foreign key. Renaming "Shoes" or "Plants" therefore costs one update instead of one per product, and a join reconstructs the full picture:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
test=# SELECT p.*, c.cat_name FROM t_category AS c, t_product AS p WHERE c.id = p.cat_id ORDER BY p.id; id | cat_id | product_name | price | cat_name ----+--------+------------------+--------+------------------ 1 | 1 | Blue boots | 154.98 | Shoes 2 | 1 | Yellow flip flop | 32.79 | Shoes 3 | 2 | Blizzard carver | 459.98 | Skiing equipment 4 | 3 | Oak | 17.95 | Plants 5 | 3 | Birch | 15.39 | Plants 6 | 3 | Elder flower | 9.75 | Plants (6 rows) |
The payoff becomes concrete when a category is edited:
|
1 2 3 4 5 6 7 8 9 |
test=# UPDATE t_category SET cat_name = 'Footware' WHERE id = 1 RETURNING *; id | cat_name ----+---------- 1 | Footware (1 row) UPDATE 1 |
One statement changed multiple rows without touching any of them. That is consistency and integrity, delivered by the schema rather than by discipline.
The cost of taking it too far
Normalization is no universal answer, and there are cases where strict adherence is effectively a bug. Consider an invoice table that lists sold products:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
test=# CREATE TABLE t_invoice ( id int PRIMARY KEY, tstamp date, units int, product_id int REFERENCES t_product (id) ); CREATE TABLE test=# \d t_invoice Table "public.t_invoice" Column | Type | Collation | Nullable | Default ------------+---------+-----------+----------+--------- id | integer | | not null | tstamp | date | | | units | integer | | | product_id | integer | | | Indexes: "t_invoice_pkey" PRIMARY KEY, btree (id) Foreign-key constraints: "t_invoice_product_id_fkey" FOREIGN KEY (product_id) REFERENCES t_product(id) |
Now suppose the same shoes were sold twice in the past, and a turnover report is requested:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
test=# SELECT * FROM t_product AS p, t_invoice AS v WHERE v.product_id = p.id; id | cat_id | product_name | price | id | tstamp | units | product_id ----+--------+------------------+-------+----+------------+-------+------------ 2 | 1 | Yellow flip flop | 32.79 | 1 | 2025-01-22 | 22 | 2 2 | 1 | Yellow flip flop | 32.79 | 2 | 2025-01-23 | 19 | 2 (2 rows) test=# SELECT sum(units * price) FROM t_product AS p, t_invoice AS v WHERE v.product_id = p.id; sum --------- 1344.39 (1 row) |
With product_id as a foreign key into the product table, the report returns 1344.39 Postgres bucks — a total that breaks as soon as prices change:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
test=# UPDATE t_product SET price = 984.34 WHERE id = 2 RETURNING *; id | cat_id | product_name | price ----+--------+------------------+-------- 2 | 1 | Yellow flip flop | 984.34 (1 row) UPDATE 1 test=# SELECT sum(units * price) FROM t_product AS p, t_invoice AS v WHERE v.product_id = p.id; sum ---------- 40357.94 (1 row) |
For a sales report, the current price of the product is the wrong number. What the report needs is the price as it stood at the moment of sale. Fully normalized, this schema destroys the historical record it was supposed to preserve.
Denormalizing deliberately
Historical data has to be captured redundantly — as the value at the time of the event. Product name and price belong in the sales (invoice) table itself:
|
1 2 3 4 5 6 7 8 9 |
test=# CREATE TABLE t_invoice ( id int4 PRIMARY KEY, tstamp date, units int, product_name text, product_price numeric ); CREATE TABLE |
Once stored that way, later changes to prices or names, or removal of a product entirely, cannot rewrite the past. These fields are a snapshot, not a reference.
The mistake is common in real applications, and worth flagging at design time — particularly when table definitions are produced by generative AI, which has no notion of which values must be frozen.



