Normalization
In short: Database normalization splits data into tables so that every fact is stored in one place, which removes update, insert and delete anomalies.
Problem
Excel came to the database
Remember the orders table from the first lesson of the course? Someone has moved it into the database as it was: one row for each product in an order, and next to it the date, the customer, their email and their city.
flat_orders
order_idinteger *ordered_attext *customertext *emailtext *citytextproducttext *pricereal *quantityinteger *
11 rows, and only three customers. Maria's name and email are written 6 times. Look at her city in order 6, too.
The idea
Anomalies
When one fact is written in many places, every change is a risk. These problems are called anomalies. There are three kinds.
On update. Maria changes her email. A colleague fixes it only in her latest order:
Now the database says two things at once. Which email is right?
See it
Insert and delete
On insert. The shop starts selling oat flakes. Nobody has ordered them yet. Where do you write down their price?
A row without an order cannot go in. The product exists only once somebody buys it.
On delete. Elena cancels order 4, and it gets deleted. It was the only order with the frying pan:
Together with the order, the fact that the shop sells a frying pan for 24.90 € is gone.
The idea
Normal forms
Arranging the tables so that every fact is in one place is called normalisation. The rules come in steps, the normal forms. Each one includes the one before.
First (1NF): one value per cell. No lists like “vegan, organic”, and no columns tag1, tag2, tag3.
Only the white bread. The wholegrain bread is vegan, but its cell says “vegan, organic”. With LIKE '%vegan%' you will find it, but you cannot count products by tag, fix “organic” everywhere or keep out a spelling mistake. Tags go in a link table, as in the first lesson of this module.
Second (2NF): every column depends on the whole key. This matters when the key has several columns:
order_lines
- PK
order_idinteger - PK
product_idinteger product_nametext *quantityinteger *
The product name depends only on product_id, not on the order. It will repeat in every order with that product. It belongs in products.
Third (3NF): columns depend only on the key, not on another column:
products
- PK
idinteger nametext *category_idinteger *category_nametext *pricereal *
category_name is decided by category_id, not by the product. If the category is renamed, you have to fix every product in it. The name belongs in categories.
More precisely, if you are curious
The short formula for the three forms: every column depends on “the key, the whole key, and nothing but the key”.
There are higher forms too (Boyce-Codd, fourth, fifth), but in practice a table in third normal form usually no longer has these anomalies.
flat_orders breaks second normal form. The key of a row is the order and the product, while the date and the customer depend only on the order. It breaks the third as well: the city and the email depend on the customer, not on the order.
Try it
Which form?
The idea
Splitting
You split flat_orders into the tables of the shop: customers, products, orders and order_items. You start with the ones that point to nothing.
INSERT can take its rows from a SELECT instead of VALUES. With DISTINCT, each product goes in once:
products
- PK
idinteger nametext *pricereal *
From 11 rows you get 6 products, each with an id. The price stays in the order rows as well, because that is the price it was bought at. Why that is not a repeat, you will see in the next lesson.
See it
Excel is dirty
The same for the customers:
customers
- PK
idinteger nametext *emailtext *citytext
An error. In order 6, Maria's city is “Sofiya”. DISTINCT compares the whole row, so it sees two customers with one email, and UNIQUE does not let them in. That is good: the rule caught the mistake instead of carrying it into the new database.
First you fix the data, then you split:
Almost every move from Excel starts with a clean-up like this.
Try it
The orders
orders
- PK
idinteger - FK
customer_idinteger *→ customers ordered_attext *
Products and customers are already split. Fill orders (id, customer_id, ordered_at) from flat_orders. The order number is order_id, and the customer is found by email.
Solution
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
ordered_at TEXT NOT NULL
);
INSERT INTO orders (id, customer_id, ordered_at)
SELECT DISTINCT f.order_id, c.id, f.ordered_at
FROM flat_orders f
JOIN customers c ON c.email = f.email;INSERT INTO orders (id, customer_id, ordered_at) SELECT DISTINCT f.order_id, c.id, f.ordered_at FROM flat_orders f JOIN customers c ON c.email = f.email. You join on the email because it is unique, while a name can repeat. DISTINCT is needed because an order with two products is on two rows. 7 orders.
Exercise
Two tasks
1. What is left is the link table between orders and products.
order_items
- PK
order_idinteger→ orders - PK
product_idinteger→ products quantityinteger *unit_pricereal *
Fill order_items from flat_orders: the order number, the product id, the quantity and the price.
Solution
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
PRIMARY KEY (order_id, product_id)
);
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT f.order_id, p.id, f.quantity, f.price
FROM flat_orders f
JOIN products p ON p.name = f.product;Every row of flat_orders becomes a row of order_items, so there is no DISTINCT here. The product is found by name, which is now UNIQUE in products. 11 rows, as many as at the start.
2. Now the report from the first lesson of the course takes one query. How much has each customer spent?
Show the name of each customer and the total of their purchases (quantity times price):
Solution
SELECT c.name, SUM(oi.quantity * oi.unit_price) AS spent
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name;Three tables and GROUP BY. Maria 28.20 €, Georgi 25.20 €, Elena 31.20 €. If Maria changes her email tomorrow, that is one row in customers.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Constraints: rules for the data · Foreign keys: links between tables · Relationship types: 1:1, 1:N, M:N · ER diagram: from a description to a schema
- Continues in: Natural keys and denormalization · Document databases · The bakery, stage 1: the database and the tables
- Tasks on this topic: Designing a database