Natural keys and denormalization
In short: A natural key comes from the data itself, like an email, a surrogate key is a number with no meaning, and denormalization is a copy of data kept on purpose.
Problem
Maria changed her email
If you design the customers yourself, the email looks like a good primary key. It is unique, every customer has one, it is what they log in with. Here is such a design: customers are keyed by email, and orders point to the email.
customers
- PK
emailtext nametext *citytext
orders
- PK
idinteger - FK
customer_emailtext *→ customers ordered_attext *
Maria changes her email:
The database refuses the change, because three orders point to the old email. If you change those too, there are still the invoices already sent, the reports and every other system that has remembered the old key.
The idea
Natural and surrogate keys
A key taken from the world (an email, a phone number, a book's number) is a natural key. A number the database assigns itself, with no meaning outside it, is a surrogate key. The id in the shop is one.
A surrogate key never changes, because nothing in the world changes it. So the email changes on one row, and the orders do not notice:
The email keeps its rule. It is no longer the key, but it is UNIQUE, so two customers cannot have the same email:
What about a national ID number (in Bulgaria, the EGN)? No. It is personal data, and the key gets copied into every table that points to the customer. A customer from abroad usually does not have one. And the shop has no need to know it.
More precisely, if you are curious
ON UPDATE CASCADE in REFERENCES makes the database also change the rows that point to the old key. That helps inside the database, but not with the copies of the key outside it: in emails, in logs, in another system.
A natural key fits when the value really never changes and is known from outside. Examples are a currency code (EUR) or a country code (BG).
A UUID is another kind of surrogate key: a 128-bit number written as 550e8400-e29b-41d4-a716-446655440000. It can be made anywhere, without asking the database (in the app, on a phone with no internet), two databases can be merged without clashes, and the address /orders/1043 no longer tells how many orders the shop has. The cost is that it is longer (16 bytes against 8 for a big integer), and random UUIDs land in random places in the index and slow it down. That is why there are also time-ordered UUIDs (version 7). PostgreSQL has a uuid type and a gen_random_uuid() function. SQLite has no separate type and keeps it as text or as a BLOB.
Try it
A new email
Customer 12, Boris, writes that his new email is boris.m@example.com. Fix it.
Solution
UPDATE customers SET email = 'boris.m@example.com' WHERE id = 12;UPDATE customers SET email = 'boris.m@example.com' WHERE id = 12. One row. His three orders point to id 12 and stay his.
The idea
The price in the order row
order_items has unit_price, and products has price. That looks like a repeat that normalisation removes. Raise the price of the coffee:
The old orders remember 14.90 €, while the product costs 15.90 €. These are not two copies of one fact, but two different facts: “what it costs today” and “what the customer paid”. If the order took its price from products, every price rise would change past bills too.
The idea
Denormalisation
The order total is different. It really is computed from the items. But the list of orders on the website shows it for every order, and instead of adding up the items every time, you can keep it in orders:
A deliberate copy of data that could be computed is called denormalisation. You do it when reads are much more frequent than changes and the computation is expensive.
See it
The total that lies
A colleague adds coffee to order 60 but forgets about total:
The website shows 14 €, while the items add up to 28.90 €. Nothing in the database stopped this. The cost of the copy is that every change to the items must also change the total: in the program, in every place that writes to order_items, or with a trigger (the transactions module).
Exercise
Two tasks
1. Accounting suspects some totals are wrong. Find the orders where total is not equal to the sum of the items. Show the number, the stored total and the real one.
The orders with a wrong total:
Solution
SELECT o.id, o.total, SUM(oi.quantity * oi.unit_price) AS items
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.total
HAVING ROUND(o.total, 2) <> ROUND(SUM(oi.quantity * oi.unit_price), 2);You group the items by order and compare in HAVING. Two orders: 59 (13.90 € stored, while the items give 8.90 €) and 60. ROUND(…, 2) keeps decimal numbers from differing in the last digit. Once you keep copies, a check query like this is run regularly.
2. Fix the totals of the two orders by computing them again from the items.
Update total for orders 59 and 60:
Solution
UPDATE orders SET total = (
SELECT SUM(quantity * unit_price) FROM order_items
WHERE order_items.order_id = orders.id
)
WHERE id IN (59, 60);The same correlated subquery that filled the column, but only for id IN (59, 60). If you update all orders, the result is the same, because the others are already right.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Foreign keys: links between tables · ALTER TABLE: changing a table · Relationship types: 1:1, 1:N, M:N · Normalization
- Continues in: OLTP and OLAP · Document databases · Columnar and time series databases · The bakery, stage 1: the database and the tables
- Tasks on this topic: Designing a database