Normalization puts each fact in one home so a change cannot leave contradictory copies. Denormalization keeps a copy only when that copy is a different, justified fact—with an owner who updates it.
Duplication is a maintenance problem
This order sheet looks convenient: every row has the customer, the city, and a product. The convenience is fake. Amina's city is stored twice. The SQL notebook price is stored twice. If Casablanca is wrong, you must edit every Amina row. If you miss one, the database now contains two cities for one person.
That is an update anomaly. Closely related failures are an insert anomaly—you cannot record a customer who has not ordered yet—and a delete anomaly—deleting someone's last order also deletes the only copy of their city.
SQL BROWSER RUNNER
Read a duplicated order sheet
Notice which facts repeat across rows that share an order or a product.
Edit the query, predict the rows it will return, then run it.
Repeating groups hide a second table
A worse spreadsheet uses product_1 and product_2. The number of products is now a column-design accident. A third item has nowhere to live. Queries such as “total quantity of SQL notebooks” have to inspect several columns. That shape is not first normal form.
First normal form asks for atomic values and a consistent grain: one row, one kind of thing. If an order can contain any number of products, those products are rows in a child table—not extra columns on the parent.
SQL BROWSER RUNNER
See a repeating-group order table
Compare two orders, one of which cannot store a third product.
Edit the query, predict the rows it will return, then run it.
First normal form makes the grain consistent
Move each product line onto its own row. (order_id, line_no) identifies one line. Every column now describes that line. The table can hold one item or twenty with the same shape.
1NF is necessary and not sufficient. Customer city still repeats on every line of the same order. You have fixed the repeating group; you have not yet given the customer a home of their own.
SQL BROWSER RUNNER
Put every order line in its own row
Replace product_1 and product_2 with one row per item.
Edit the query, predict the rows it will return, then run it.
Second normal form removes partial key dependencies
Second normal form matters when the primary key has more than one column. In this enrollment table, (student_id, course_id) identifies one enrollment. student_name does not depend on the whole key—it depends only on student_id. course_title depends only on course_id. Those are partial dependencies.
The repair is the same idea as before: give each independent thing its own table. Students own names. Courses own titles. Enrollments own only the relationship.
SQL BROWSER RUNNER
Spot a partial dependency
Read an enrollment table whose names belong to only part of the key.
Edit the query, predict the rows it will return, then run it.
SQL BROWSER RUNNER
Split students, courses, and enrollments
Give each fact a table, then join them back into a readable list.
PRAGMA foreign_keys =ON;CREATETABLE students (
student_id INTEGERPRIMARYKEY,
student_name TEXTNOTNULL);CREATETABLE courses (
course_id INTEGERPRIMARYKEY,
course_title TEXTNOTNULLUNIQUE);CREATETABLE enrollments (
student_id INTEGERNOTNULL,
course_id INTEGERNOTNULL,PRIMARYKEY(student_id, course_id),FOREIGNKEY(student_id)REFERENCES students(student_id),FOREIGNKEY(course_id)REFERENCES courses(course_id));INSERTINTO students VALUES(101,'Amina Idrissi'),(102,'Bilal Karim');INSERTINTO courses VALUES(10,'SQL foundations'),(20,'Schema design');INSERTINTO enrollments VALUES(101,10),(101,20),(102,10);SELECT student.student_name, course.course_title
FROM enrollments AS enrollment
JOIN students AS student ON student.student_id = enrollment.student_id
JOIN courses AS course ON course.course_id = enrollment.course_id
ORDERBY student.student_name, course.course_title;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Third normal form removes transitive dependencies
Here each order has one customer, so the primary key is only order_id. customer_city still does not describe the order. It describes the customer. City depends on customer_id, which depends on order_id. That chain is a transitive dependency.
Move city to customers. After one UPDATE, every order for Amina shows Marrakech because they all read the same customer row. The join is the cost of a single source of truth.
SQL BROWSER RUNNER
Spot a transitive dependency
City is stored on every order even though it belongs to the customer.
Edit the query, predict the rows it will return, then run it.
SQL BROWSER RUNNER
Give the city one home
Update the customer once and read the new city through every related order.
PRAGMA foreign_keys =ON;CREATETABLE customers (
customer_id INTEGERPRIMARYKEY,
customer_name TEXTNOTNULL,
customer_city TEXTNOTNULL);CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
customer_id INTEGERNOTNULL,
ordered_on TEXTNOTNULL,FOREIGNKEY(customer_id)REFERENCES customers(customer_id));INSERTINTO customers VALUES(101,'Amina Idrissi','Casablanca'),(102,'Bilal Karim','Rabat');INSERTINTO orders VALUES(5001,101,'2026-10-01'),(5002,101,'2026-10-08'),(5003,102,'2026-10-02');UPDATE customers SET customer_city ='Marrakech'WHERE customer_id =101;SELECT sale.order_id, customer.customer_name, customer.customer_city
FROM orders AS sale
JOIN customers AS customer ON customer.customer_id = sale.customer_id
ORDERBY sale.order_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
A snapshot is a different fact
Normalized models are not allergic to copies. They are allergic to accidental copies of the same fact. The name on a receipt is often not the current catalog name. If the product is later renamed, last month's order should still print what the buyer saw.
Store product_id so the relationship remains. Also store product_name_snapshot and unit_price_cents as facts about the sale. That is intentional denormalization—or, more precisely, recording history instead of live catalog state.
SQL BROWSER RUNNER
Keep a purchase-time product name
Rename the catalog product and compare it with the name stored on the order item.
PRAGMA foreign_keys =ON;CREATETABLE products (
product_id INTEGERPRIMARYKEY,
product_name TEXTNOTNULL,
price_cents INTEGERNOTNULL);CREATETABLE order_items (
order_item_id INTEGERPRIMARYKEY,
order_id INTEGERNOTNULL,
product_id INTEGERNOTNULL,
quantity INTEGERNOTNULLCHECK(quantity >0),
product_name_snapshot TEXTNOTNULL,
unit_price_cents INTEGERNOTNULL,FOREIGNKEY(product_id)REFERENCES products(product_id));INSERTINTO products VALUES(1,'SQL notebook',1499),(2,'Database sticker',299);INSERTINTO order_items VALUES(1,5001,1,1,'SQL notebook',1499),(2,5001,2,2,'Database sticker',299);UPDATE products SET product_name ='SQL field notebook'WHERE product_id =1;SELECT item.order_id, product.product_name AS catalog_name,
item.product_name_snapshot, item.quantity
FROM order_items AS item
JOIN products AS product ON product.product_id = item.product_id
ORDERBY item.order_item_id;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Cached totals need an owner
A dashboard may want orders.cached_total_cents so it does not sum items on every page load. The moment you store that total, you have two versions of the amount. If a later insert adds an item and nobody updates the cache, the report is wrong.
Before adding a cached column, name the writer: a transaction that updates items and the total together, a nightly rebuild, or a generated column in a database that supports one. If you cannot name the writer, do not store the copy. Compute it in a query or a view.
SQL BROWSER RUNNER
Watch a cached total drift
Add an order item without updating cached_total_cents, then compare the two amounts.
PRAGMA foreign_keys =ON;CREATETABLE orders (
order_id INTEGERPRIMARYKEY,
cached_total_cents INTEGERNOTNULL);CREATETABLE order_items (
order_item_id INTEGERPRIMARYKEY,
order_id INTEGERNOTNULL,
quantity INTEGERNOTNULL,
unit_price_cents INTEGERNOTNULL,FOREIGNKEY(order_id)REFERENCES orders(order_id));INSERTINTO orders VALUES(5001,2097);INSERTINTO order_items VALUES(1,5001,1,1499),(2,5001,2,299);INSERTINTO order_items VALUES(3,5001,1,500);SELECT sale.order_id, sale.cached_total_cents,SUM(item.quantity * item.unit_price_cents)AS true_total_cents
FROM orders AS sale
JOIN order_items AS item ON item.order_id = sale.order_id
GROUPBY sale.order_id, sale.cached_total_cents;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Choose a shape on purpose
Write the one-row meaning for each table.
Look for repeating groups and split them into child rows.
Look for facts that depend on only part of a composite key.
Look for facts that depend on another non-key column.
Keep a copy only when it is a snapshot, a cache with an owner, or a measured performance need.
Prove the copy by changing the source and checking whether the copy should follow or stay still.
Independent lab: repair a workshop registration dump
A spreadsheet of registrations repeated member names, emails, workshop titles, and cities on every row. The provided solution gives each person, each workshop, and each registration its own table. One row in registrations is one member in one workshop.
Read the grain of each table, then try the writes that the spreadsheet made painful: rename a workshop city once, add a workshop with no registrations, and prevent the same member from registering twice.
SQL BROWSER RUNNER
Normalize members, workshops, and registrations
Split a duplicated dump into three tables with keys, uniqueness, and relationships.
PRAGMA foreign_keys =ON;-- One row in members is one person.CREATETABLE members (
member_id INTEGERPRIMARYKEY,
full_name TEXTNOTNULL,
email TEXTNOTNULLUNIQUE);-- One row in workshops is one scheduled workshop.CREATETABLE workshops (
workshop_id INTEGERPRIMARYKEY,
title TEXTNOTNULLUNIQUE,
city TEXTNOTNULL);-- One row in registrations is one member in one workshop.CREATETABLE registrations (
member_id INTEGERNOTNULL,
workshop_id INTEGERNOTNULL,
seats INTEGERNOTNULLCHECK(seats >0),PRIMARYKEY(member_id, workshop_id),FOREIGNKEY(member_id)REFERENCES members(member_id),FOREIGNKEY(workshop_id)REFERENCES workshops(workshop_id));INSERTINTO members VALUES(1,'Amina Idrissi','amina@example.com'),(2,'Bilal Karim','bilal@example.com');INSERTINTO workshops VALUES(10,'SQL foundations','Casablanca'),(20,'Schema design','Rabat');INSERTINTO registrations VALUES(1,10,1),(1,20,1),(2,10,2);SELECT member.full_name, workshop.title, workshop.city, registration.seats
FROM registrations AS registration
JOIN members AS member ON member.member_id = registration.member_id
JOIN workshops AS workshop ON workshop.workshop_id = registration.workshop_id
ORDERBY member.full_name, workshop.title;
QUERY OUTPUT
Edit the query, predict the rows it will return, then run it.
Common mistakes to avoid
Stopping at 1NF and leaving customer or product facts copied onto every child row.
Treating “I do not like joins” as a schema requirement.
Using product_1, product_2 columns instead of a child table.
Copying a live catalog field onto orders without deciding whether it is a snapshot.
Caching a total with no transaction, job, or constraint that keeps it aligned.
Normalizing past the point of meaning—splitting a table whose columns all describe the same row.
Lesson review
I can recognize update, insert, and delete anomalies caused by duplicated facts.
I can repair repeating groups so each row has a consistent grain.
I can move partial-key facts into their own tables for 2NF.
I can move transitive facts, such as a city that belongs to a customer, for 3NF.
I can keep a snapshot or cache only when I can say who owns the copy.
I can join the normalized tables back into a readable result.
KNOWLEDGE CHECK
Check your normalization reasoning
Answer all ten questions, then revisit the example whose grain or anomaly still feels unclear.