SQL: normalization removes repeated facts

Split a table that repeats a customer's city on every row into two tables and see why one update is enough.

SQL
CREATE TABLE orders_flat (order_id INTEGER, customer TEXT, city TEXT, item TEXT);
INSERT INTO orders_flat VALUES (1, 'Ada', 'London', 'pen'), (1, 'Ada', 'London', 'book'), (2, 'Bo', 'Paris', 'pen');

CREATE TABLE customer (name TEXT PRIMARY KEY, city TEXT);
INSERT INTO customer SELECT DISTINCT customer, city FROM orders_flat;
CREATE TABLE order_item (order_id INTEGER, customer TEXT REFERENCES customer(name), item TEXT);
INSERT INTO order_item SELECT order_id, customer, item FROM orders_flat;

UPDATE customer SET city = 'Oxford' WHERE name = 'Ada';

SELECT i.order_id, c.name, c.city, i.item
FROM order_item i JOIN customer c ON c.name = i.customer
ORDER BY i.order_id, i.item;

SELECT (SELECT COUNT(*) FROM orders_flat WHERE customer = 'Ada') AS flat_copies,
       (SELECT COUNT(*) FROM customer WHERE name = 'Ada') AS normalized_copies;
Output
1|Ada|Oxford|book
1|Ada|Oxford|pen
2|Bo|Paris|pen
2|1

Explanation

A flat table that mixes facts about several things repeats information. Here every row of an order repeats the customer's city. If the city changes, every row must be updated, and missing one leaves the data inconsistent. This is an update anomaly. Insert and delete anomalies are the same problem in other forms.

Normalization splits the table so that each fact is stored once. The customer's city depends on the customer, not on the order, so it moves into a customer table keyed by name. The order items keep only a reference to the customer. A join rebuilds the original view when needed. This is the idea behind second and third normal form: every non-key column depends on the key, the whole key, and nothing but the key.

Explore the dry run
Guided walkthroughReady
1CREATE TABLE orders_flat (order_id INTEGER, customer TEXT, city TEXT, item TEXT);
2INSERT INTO orders_flat VALUES (1, 'Ada', 'London', 'pen'), (1, 'Ada', 'London', 'book'), (2, 'Bo', 'Paris', 'pen');
3
4CREATE TABLE customer (name TEXT PRIMARY KEY, city TEXT);
5INSERT INTO customer SELECT DISTINCT customer, city FROM orders_flat;
6CREATE TABLE order_item (order_id INTEGER, customer TEXT REFERENCES customer(name), item TEXT);
7INSERT INTO order_item SELECT order_id, customer, item FROM orders_flat;
8
9UPDATE customer SET city = 'Oxford' WHERE name = 'Ada';
10
11SELECT i.order_id, c.name, c.city, i.item
12FROM order_item i JOIN customer c ON c.name = i.customer
13ORDER BY i.order_id, i.item;
14
15SELECT (SELECT COUNT(*) FROM orders_flat WHERE customer = 'Ada') AS flat_copies,
16 (SELECT COUNT(*) FROM customer WHERE name = 'Ada') AS normalized_copies;

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
Ada's city appears on two rows.London is stored twice
—
Each customer and city is stored once.customer = Ada/London, Bo/Paris
—
One row changes, and every order sees the new city.customer = Ada/London, Bo/Paris
—
The join rebuilds the flat view, now with Oxford on both of Ada's rows.customer = Ada/London, Bo/Paris
1|Ada|Oxford|book
1|Ada|Oxford|pen
2|Bo|Paris|pen
The flat table stores Ada's city twice, the normalized one once.customer = Ada/London, Bo/Paris
1|Ada|Oxford|book
1|Ada|Oxford|pen
2|Bo|Paris|pen
2|1

Cost and common mistakes

Joins cost O(n + m) with hashing or O(n log m) with an index on the key.

  • Normalization trades simpler updates for more joins. Heavy read workloads sometimes denormalize on purpose, accepting the duplication.
  • Using a name as the key is fragile because names change and repeat. Real schemas use surrogate ids.
  • Changing the flat table's city for only one of Ada's rows would leave contradictory data, which is the anomaly normalization prevents.

Try it yourself

Which fact would you move out of order_item if each item also stored its price from a product list?

Reveal answer and reasoning

The price belongs with the product, so it should live in a product table keyed by item and be joined in when needed.

Further reading