SQL: Database Design & Normalization
Lesson 3 of 5
3SQL: Database Design & Normalization
The big pictureHow to turn a description of a real domain into sound tables: identify entities and relationships, spot redundancy and anomalies, remove them by normalizing up to the third normal form, and protect the result with constraints.
What the picture shows: In the first table Sara's name and city are written twice. Normalising moves the customer into a table of its own and leaves only a number in the order, so an edit happens once.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
Designing a database is like organising a warehouse. Write the supplier's name and phone on every box, and when the number changes you edit a hundred boxes and miss some. Normalisation means writing each fact in one place only and pointing to it from everywhere else, so a change happens once.
You start with entities, attributes and relations (ER), then choose keys. Duplication causes insert, update and delete anomalies. Functional dependencies show what determines what, and normalisation splits tables: 1NF atomic values, 2NF no partial dependency on the key, 3NF no transitive dependency. Constraints then protect the result.
Required Fundamentals
Three things should be solid before you design:
Keys and relationships (the SQL joins lesson)
The primary key identifies the row, the foreign key links to the parent, and a relationship is one-to-one, one-to-many or many-to-many (with a junction table). Design is mostly one decision: where does each key go?
Constraints (the SQL CRUD lesson)
NOT NULL, UNIQUE, CHECK and DEFAULT are how you “write” the domain’s rules inside the database itself rather than relying on the discipline of whoever writes the application.
JOIN (the SQL joins lesson)
Normalization breaks data into smaller tables and joins put it back together. If you are not comfortable joining three or more tables, keep the previous part open while you read this one.
Core Concepts
Good design makes mistakes hard to make: each fact is stored in exactly one place and the rules are written as constraints, so nobody can enter contradictory data even by trying. Normalization is the systematic way to get there.
1Entity, attribute, relationship
Entity
A “thing” with independent existence that we want to track: customer, drink, order. It usually becomes one table.
Attribute
A fact describing the entity: customer name, drink price. It becomes a column. If an attribute has attributes of its own (a city has a region) it may be an entity in its own right.
Relationship
How two entities connect, with the count (cardinality): one customer → many orders. Implemented with a foreign key or a junction table.
2Keys
| Term | Meaning | Example |
|---|---|---|
| Primary key | The chosen key that identifies a row; unique and never empty. | drinks.id |
| Candidate key | Any column or set of columns that could serve as a key (unique and minimal). You pick one as primary and guard the others with UNIQUE. | customers.phone UNIQUE |
| Surrogate key | A system-generated number meaningless outside it (id). Stable even when a phone number or name changes. | customers.id |
| Natural key | A real-world value from the domain (phone, email, national ID). Understandable, but it can change, repeat, or be mistyped. | phone |
| Composite key | A key made of several columns together, common in junction tables. | (order_id, drink_id) |
3Why normalize? Anomalies
One giant table that stores everything (each line carrying the customer, the drink and the barista) repeats facts, and repetition breeds three problems:
Update anomaly
One fact (Noura’s city) is written in three rows; if you edit one and forget the others you hold two contradictory copies.
Insertion anomaly
You cannot record a new drink that has not been sold yet, because a row needs an order; you are forced to invent a dummy one.
Deletion anomaly
Deleting Sultan’s only order also wipes everything we know about him and about “Spanish Latte”, because their existence hung on that row.
drinks = 'Flat White, Cortado') or creating repeating columns (drink1, drink2, drink3). The first blocks precise searching and checking, the second puts an artificial ceiling on the count. The right fix is always a junction table with a row per value.
4Functional dependency
We say A → B (A determines B) when the value of A fixes the value of B without ambiguity: drink_id → drink_name, price. This idea is the key to all of normalization: ask of every column: what determines it? If something that is not the table’s whole key determines it, the column is in the wrong place.
5The normal forms
| Form | The rule | Violation signal | The fix |
|---|---|---|---|
| 1NF | Every cell holds one atomic value (no lists, no repeating columns like drink1, drink2), and each row is identified by a key. | Text like 'Flat White, Cortado' in one cell | A separate row for each value |
| 2NF | 1NF and no non-key column depends on just part of a composite key. | drink_name is determined by drink_id alone inside the key (order_id, drink_id) | Move what depends on part of the key to its own table |
| 3NF | 2NF and no non-key column depends on another non-key column (no transitive dependency). | customer_city is determined by customer_phone, which is not the table’s key | Move the dependent group to a table keyed by the determining column |
| BCNF | Slightly stricter than 3NF: every determinant (left side of a functional dependency) must be a candidate key. It rarely differs from 3NF in simple designs. | Shows up with overlapping candidate keys | Decompose until every determinant is a key |
6Small design decisions that matter
Money as integers
Store price in the smallest unit (halalas) as an INTEGER; floating-point numbers accumulate rounding errors in sums and comparisons.
Dates as ISO text
'2026-10-03' is text that sorts chronologically and that SQLite’s date() understands; SQLite has no separate date type.
Don’t store derived values
An invoice total is the sum of qty × price; compute it in a query. Storing it creates a second fact that can disagree with the first.
Consistent names
Pick one style (snake_case, singular or plural table names) and apply it everywhere; the reader should never have to guess.
unit_price_halalas to order_items: that is not a redundant copy but a different fact (the price at the moment of sale). The reverse is true too: some people denormalize on purpose to speed up huge reports, but it is a conscious trade-off paid for by keeping the copies identical by hand.
How Do We Design, Step by Step?
- Describe the domain in sentences: “a customer places orders, an order contains drinks in quantities, a barista prepares the order”.
- Extract entities and attributes: nouns become tables, descriptors become columns.
- Pick a key for each entity (often a surrogate
id) and identify the candidate keys you protect withUNIQUE. - Define relationships and counts, put the foreign key on the “many” side, and a junction table for many-to-many.
- Normalize: write the functional dependencies and check 1NF, then 2NF, then 3NF.
- Add constraints:
NOT NULL,CHECK,DEFAULT, and decide anON DELETEaction for each foreign key. - Test with data: load a small sample and ask real questions; if a natural query turns out awkward, revisit the design.
Diagram 26, the normalization path: each step removes one specific kind of redundancy.
Diagram 27, our café’s final schema after normalization: five tables, one home for every fact.
Diagram 28, choosing an ON DELETE action is a question about the relationship’s meaning, not about syntax.
Worked Example: From One Sales Log to a Sound Schema
Imagine someone logged the café’s sales in a single table where every line carries everything:
Step 1, the starting (unnormalized) table
CREATE TABLE sales_log (
order_id INTEGER,
drink_id INTEGER,
drink_name TEXT,
drink_category TEXT,
drink_price_halalas INTEGER,
customer_phone TEXT,
customer_name TEXT,
customer_city TEXT,
barista_id INTEGER,
barista_name TEXT,
qty INTEGER,
order_date TEXT,
PRIMARY KEY (order_id, drink_id)
);
| order_id | drink_id | drink_name | customer_name | customer_city | barista_name | qty |
|---|---|---|---|---|---|---|
| 101 | 1 | Flat White | Noura | Riyadh | Majed | 2 |
| 101 | 4 | Cortado | Noura | Riyadh | Majed | 1 |
| 102 | 2 | Cold Brew | Noura | Riyadh | Reem | 1 |
| 103 | 3 | Spanish Latte | Sultan | Jeddah | Reem | 2 |
(Only seven of the twelve columns are shown for brevity; the table also holds the customer’s phone, the barista id and the drink’s price.)
Is it in 1NF? Yes: every cell is one value and the key (order_id, drink_id) identifies each row. An example of what is not in 1NF:
| order_id | customer | drinks |
|---|---|---|
| 101 | Noura | Flat White, Cortado |
| 102 | Noura | Cold Brew |
Step 2, find the functional dependencies
| Dependency | Verdict |
|---|---|
(order_id, drink_id) → qty | Depends on the whole key, correctly placed. |
drink_id → drink_name, drink_category, drink_price_halalas | Dependence on part of the key ⇒ a 2NF violation. |
order_id → customer_phone, barista_id, order_date | Dependence on part of the key ⇒ a 2NF violation. |
customer_phone → customer_name, customer_city | A non-key column determines non-key columns ⇒ a 3NF violation (transitive). |
barista_id → barista_name | Another transitive dependence ⇒ a 3NF violation. |
Step 3, watch the anomalies happen
Update: Noura moved to Jeddah and we edited only one row, forgetting the rest:
UPDATE sales_log SET customer_city = 'Jeddah' WHERE order_id = 101 AND drink_id = 1; -- 1 row changed SELECT DISTINCT customer_phone, customer_name, customer_city FROM sales_log ORDER BY 1, 3;
| customer_phone | customer_name | customer_city |
|---|---|---|
| 0501110001 | Noura | Jeddah |
| 0501110001 | Noura | Riyadh |
| 0502220002 | Sultan | Jeddah |
Noura is now in two cities! A correct edit needed WHERE customer_phone = … to hit three rows. Deletion: deleting order 103 made “Spanish Latte” and Sultan vanish from the database entirely (counting rows that mention them gave 0). Insertion: there is no place for a new drink that has not been sold yet.
Step 4, decompose to 3NF
Each group that depends on its determinant moves to a table keyed by that determinant. 2NF: move the drink_id columns to drinks and the order_id columns to orders. 3NF: move the customer_phone columns to customers and the barista_id columns to baristas. The result:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
phone TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
city TEXT NOT NULL
);
CREATE TABLE baristas (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE drinks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL CHECK (category IN ('hot', 'cold')),
price_halalas INTEGER NOT NULL CHECK (price_halalas > 0)
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
barista_id INTEGER NOT NULL REFERENCES baristas(id),
order_date TEXT NOT NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
drink_id INTEGER NOT NULL REFERENCES drinks(id) ON DELETE RESTRICT,
qty INTEGER NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, drink_id)
);
Note the decisions: phone is a candidate key guarded by UNIQUE while id is the primary; CHECK guards category, price and quantity; and the three ON DELETE actions differ on purpose (Diagram 28).
Step 5, migrate the old data
INSERT INTO customers (phone, name, city) SELECT DISTINCT customer_phone, customer_name, customer_city FROM sales_log; INSERT INTO baristas (id, name) SELECT DISTINCT barista_id, barista_name FROM sales_log; INSERT INTO drinks (id, name, category, price_halalas) SELECT DISTINCT drink_id, drink_name, drink_category, drink_price_halalas FROM sales_log; INSERT INTO orders (id, customer_id, barista_id, order_date) SELECT DISTINCT s.order_id, c.id, s.barista_id, s.order_date FROM sales_log s JOIN customers c ON c.phone = s.customer_phone; INSERT INTO order_items (order_id, drink_id, qty) SELECT order_id, drink_id, qty FROM sales_log;
INSERT … SELECT DISTINCT lands each fact once. Then we fill orders by joining sales_log to the new customers table to take the customer’s id from the phone number. The new tables:
| id | phone | name | city |
|---|---|---|---|
| 1 | 0501110001 | Noura | Riyadh |
| 2 | 0502220002 | Sultan | Jeddah |
| id | name |
|---|---|
| 7 | Majed |
| 9 | Reem |
| id | customer_id | barista_id | order_date |
|---|---|---|---|
| 101 | 1 | 7 | 2026-10-01 |
| 102 | 1 | 9 | 2026-10-03 |
| 103 | 2 | 9 | 2026-10-03 |
Step 6, check we lost nothing
We rebuild the flat shape by joining the five tables:
SELECT oi.order_id, d.name AS drink, c.name AS customer, c.city, b.name AS barista, oi.qty FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN drinks d ON d.id = oi.drink_id JOIN customers c ON c.id = o.customer_id JOIN baristas b ON b.id = o.barista_id ORDER BY oi.order_id, d.id;
| order_id | drink | customer | city | barista | qty |
|---|---|---|---|---|---|
| 101 | Flat White | Noura | Riyadh | Majed | 2 |
| 101 | Cortado | Noura | Riyadh | Majed | 1 |
| 102 | Cold Brew | Noura | Riyadh | Reem | 1 |
| 103 | Spanish Latte | Sultan | Jeddah | Reem | 2 |
And in our full test (all twelve columns) the join’s result matched the original sales_log table row for row. The decomposition is lossless: what we split can be reassembled exactly.
Step 7, the anomalies are gone
Changing Noura’s city is now UPDATE customers SET city = 'Jeddah' WHERE phone = '0501110001', which changes exactly one row. Deleting an order no longer erases the drink or the customer. And recording a new drink is just INSERT INTO drinks … with no order at all.
Step 8, test the ON DELETE actions
With PRAGMA foreign_keys = ON: deleting customer 1 (SET NULL) left his orders with no customer:
DELETE FROM customers WHERE id = 1; SELECT id, customer_id FROM orders ORDER BY id;
| id | customer_id |
|---|---|
| 101 | NULL |
| 102 | NULL |
| 103 | 2 |
And deleting order 101 (CASCADE) removed its items automatically:
DELETE FROM orders WHERE id = 101; SELECT order_id, drink_id FROM order_items ORDER BY 1, 2;
| order_id | drink_id |
|---|---|
| 102 | 2 |
| 103 | 3 |
And a drink used in an order (RESTRICT) is refused by the engine:
| Attempt | Result |
|---|---|
DELETE FROM drinks WHERE id = 2 (drink used in an order) | FOREIGN KEY constraint failed |
Myth vs. Reality
| The myth | The reality |
|---|---|
| The more tables, the better the design | The goal is removing redundancy and anomalies, not raising the count. Splitting for no reason just adds joins with no benefit. |
| A phone or email is the best primary key because it is real | Real values change and get mistyped. Better a stable surrogate key with UNIQUE on the real value. |
| A table in 3NF is perfect | 3NF removes specific kinds of redundancy only. Domain rules (constraints) and decisions like the price at the moment of sale remain. |
| Denormalizing is always wrong | It can be a deliberate choice to speed up huge reads, but its price is keeping the copies identical. Always start from a normalized design. |
| It is enough for the application to validate the data | Applications multiply and change, and bugs happen. Constraints inside the database are the last line of defense that cannot be bypassed. |
Where Does Good Design Actually Matter?
Financial systems
Balances and transactions cannot tolerate two contradictory copies of a fact; normalization and constraints are requirements, not options.
Health records
Patient data is shared by many departments, and one home for a fact means one update everyone sees.
E-commerce
Products, customers, orders and items are exactly our café’s model, with the price at the moment of sale saved in the order item.
Migration and cleanup
Turning a flat Excel or CSV file into a sound database is exactly what we did with INSERT … SELECT DISTINCT.
Try it yourself
An orders table repeats the customer name and city on every row. What is the main problem?
Repeated facts drift apart over time. Normalising puts the fact in one table and points to it by number.
Quick Review Flashcards
UNIQUE.CASCADE and when RESTRICT?CASCADE when the children mean nothing without the parent (order items); RESTRICT when the parent must not be deleted while in use (a drink in orders).Check Your Understanding
1. (Recall) What does 1NF require?
One cell = one atomic value (no lists or repeating columns), and every row identified by a key.
2. (Why) Why does having customer_city in the sales table create a risk while having it in the customers table does not?
In the sales table it repeats on every line of the same customer, so copies diverge if only some are edited. In the customers table there is a single row per customer, so it cannot contradict itself.
3. (Spot the error) A table order_items(order_id, drink_id, qty, drink_name) has key (order_id, drink_id). Which normal form does it violate, and what is the fix?
2NF: drink_name depends on drink_id alone (part of the key). Fix: remove it from the table and leave it in drinks.
4. (Spot the error) A table orders(id, customer_id, customer_city). Which normal form does it violate?
3NF: customer_city depends on customer_id (a non-key column), not directly on id, a transitive dependency. It belongs in the customers table.
5. (Decide) When a barista is deleted, which ON DELETE action do you pick for orders.barista_id, declared NOT NULL? Why can SET NULL not work?
SET NULL clashes with NOT NULL, so the delete would fail. The sensible choice is RESTRICT (or NO ACTION): block deleting a barista with past orders, or change the design so the column accepts NULL if we want to keep the orders.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs1
Articles2
Done with this lesson?
Sign in to save your progress.