learn-data-modeling-with-phoebe / Builder session 2 of 10
Learn Data Modeling with Phoebe · Builder track · Session 2 of 10

The anomaly lab

Normalization is usually taught as three definitions to memorise. This session teaches it as three failures to eliminate. You get one wide table and four levers; each lever changes the schema, and the lab re-runs real UPDATE, INSERT and DELETE statements to show you which anomalies are still there. Turn all four on and the count reaches zero - and you will have derived third normal form instead of reciting it.

🟢 Builder track Analysts · analytics engineers · DE · DS Signature lab · real SQLite 45 min
0-3 · Welcome 3-14 · The three anomalies 14-40 · The lab: normalize until the anomalies die 40-45 · Q&A
Part 0

Why normalization is worth 45 minutes

Session b1 ended with a flat export and a redundancy count. That redundancy is not an aesthetic problem - it is three specific, nameable failures waiting to happen, and every one of them will happen to a table that stores the same fact twice. Normalization is the process of removing them. The definitions (1NF, 2NF, 3NF) are just labels for how far along you are.

Live - presented in session Self-study - read after class ▶ Live lab - real SQL, real anomalies Sources covered
★ What you walk out with today The ability to look at any table and say which of the three anomalies it is exposed to, a working definition of 1NF through 3NF you derived rather than memorised, and the one case where storing a fact twice is correct.
Part 1 · covers Codd / relational theory, the anomaly classes

The three anomalies 7 min live

All three come from the same root cause: a fact stored in more than one place, or a fact that has nowhere of its own to live. Learn the shapes and you can spot them in a schema in seconds.

1 · Update anomaly The price changes. Studio Headphones is stored on 5 rows. You update 1. Now the database holds two prices for one product, and both look real. Cost: nobody knows which is true 2 · Insert anomaly A new product arrives. The only table is orders_wide, so a product cannot be recorded until somebody buys it. You invent a fake order, or you wait. Cost: the catalogue is a guess 3 · Delete anomaly An order is cancelled. You delete its rows. That was the only place Yoga Mat was stored, so the product is gone from the database entirely. Cost: silent data loss One root cause, three symptoms: a fact stored in more than one place, or a fact with no place of its own. Normalization gives every fact exactly one home - which is why all three anomalies disappear together rather than one at a time.
🔍 Click to zoom - the three failures normalization exists to remove
Live1NF, 2NF, 3NF - what each one actually forbids4 min

Each normal form removes one class of dependency. The plain-English version is short enough to hold in your head:

  • 1NF - one value per cell. No comma-separated lists, no repeating groups (product_1, product_2, product_3), no arrays pretending to be strings. If you have to split() a column to query it, you are not in 1NF.
  • 2NF - no partial dependency on a composite key. If the key is (order_id, product_id), then anything that depends on only part of that key belongs elsewhere. Order date depends on order_id alone, so it belongs on the order, not the line.
  • 3NF - no transitive dependency. A non-key column must not depend on another non-key column. Product category depends on product name, not on the order line - so it belongs in a products table.

The shorthand every practitioner ends up with: every fact depends on the key, the whole key, and nothing but the key. The lab below is the same idea with the definitions removed and the failures left in.

one value per cell no partial dependency no transitive link NORMALIZED SCHEMA (3NF) no update, insert, delete anomaly FLAT TABLE same fact repeated across rows = no anomalies left to find Each layer closes one gap: repeating values, partial dependency, then transitive dependency.
🔍 Click to zoom - three normal forms, three anomalies removed, one at a time
Beyond 3NF BCNF, 4NF and 5NF exist and are real, and you will almost never design for them on purpose. The overwhelming majority of production OLTP schemas are 3NF plus judgement. If you are in a domain where BCNF matters, you already know it.
Self-studyWhen storing a fact twice is correct3 min read

Bazaar's order_items table stores unit_price even though products already has list_price, and it stores merchant_id even though products already knows which merchant owns each product. Both look like textbook 3NF violations. Neither is a mistake.

✗ Looks like redundancy unit_price mirrors list_price merchant_id mirrors products' owner so you delete the duplicate column ✓ Point-in-time capture unit_price = price charged that day merchant_id = seller at sale time both can differ later - keep both The test: can the two copies ever legitimately differ? If yes, they are different facts, not redundancy.
🔍 Click to zoom - not every repeated word is a duplicate fact
  • Point-in-time capture, not redundancy. unit_price is the price actually charged on that line, on that day. The product's list price will change; what you charged in April must not. These are two different facts that share a word - exactly the case session b1 flagged.
  • The same logic covers merchant_id: a product can be transferred between merchants, and the sale still belongs to whoever sold it at the time.
  • How to tell the difference: ask whether the two copies are ever allowed to differ. If they must always agree, it is redundancy - normalize it. If they can legitimately differ over time, they are different facts - store both, and name them so the difference is visible (list_price vs unit_price, not price and price).

This is why the lab below does not flag those two columns. Normalization is a tool for removing accidental duplication, not a rule that forbids deliberate history.

Signature lab · 18 min · everyone builds

Normalize until the anomalies die ★ real SQL, measured

One wide table of 12 order lines - the flat export from session b1, shrunk so you can read it. Four levers. Each lever rebuilds the schema in real SQLite, and the lab then runs the actual failing statements against it: an UPDATE that changes one product's price, an INSERT of a product nobody has ordered, a DELETE of an order, and an INSERT with a bogus product id. The scorecard is measured, not scripted.

Start with everything off. Read the scorecard: five red cards. Note the redundancy count - that is how many repeated strings are stored right now.

Turn on one lever at a time. Watch which specific card goes green. Extract products and the update anomaly dies; extract customers and the redundancy count drops.

Open "Show the schema" after each change. That SQL is what your lever choices just built - the DDL is the lesson, not a by-product.

Finish with keys. The last lever is the one that makes the database refuse bad data instead of storing it. Watch the integrity card flip to "rejected".

Real world

Why the last lever matters most. Teams routinely split their tables and then never declare the foreign keys, because "the application handles it". The application handles it until a backfill script, a manual fix, or a second application writes to the same database. A constraint you declared but never enabled is a comment; SQLite in particular only enforces foreign keys after PRAGMA foreign_keys = ON, which is why the lab sets it explicitly. Session b4 makes this its main event.

Demo 2 of 2

Your turn: run the anomaly by hand ★ 8 min · build your own

The lab measures for you. Now cause the failure yourself, on Bazaar's real source tables, so the mechanism is unmistakable.

LiveQ1 · Cause an update anomaly, then detect it4 min

Bazaar's real schema is already normalized, so we recreate the flat shape first, then break it. Each run starts from a fresh database, so this is safe.

CREATE TABLE wide AS
SELECT oi.order_id, oi.line_no, p.product_name, p.category, oi.unit_price
FROM order_items oi JOIN products p ON p.product_id = oi.product_id;

-- a well-meaning price correction that only finds one row
UPDATE wide SET unit_price = 149.00
WHERE product_name = 'Studio Headphones' AND order_id = (
  SELECT MIN(order_id) FROM wide WHERE product_name = 'Studio Headphones');

SELECT product_name,
       COUNT(DISTINCT unit_price) AS distinct_prices_stored,
       COUNT(*)                   AS rows_storing_a_price
FROM wide
WHERE product_name = 'Studio Headphones'
GROUP BY product_name;

Two distinct prices for one product, and nothing in the database says which is correct. In the normalized schema this update touches exactly one row in products and the question cannot arise.

LiveQ2 · Prove the normalized schema cannot have the problem4 min
UPDATE products SET list_price = 149.00 WHERE product_name = 'Studio Headphones';

SELECT product_name, COUNT(*) AS rows_storing_a_price,
       COUNT(DISTINCT list_price) AS distinct_prices_stored
FROM products
WHERE product_name = 'Studio Headphones'
GROUP BY product_name;

One row, one price. That is the entire benefit of normalization stated as a query result.

Self-studyQ3 · Find a 1NF violation in the wild3 min

No SQL. Bazaar's source schema has no comma-separated columns, because it was designed rather than accreted. Almost every real schema has at least one: a tags column holding "sale,clearance,final", a sizes column holding "S/M/L", an address column holding a whole postal address.

Find one in a schema you work with. Then write the query someone would need to answer "how many products are tagged clearance" against it, and notice that it involves LIKE '%clearance%' - which also matches "not-clearance". That is what a 1NF violation costs: every query about that column becomes a string-matching guess.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Sources covered

Full source map in materials/official-course-map.md. This page covers:

Codd / relational theory - functional dependency, 1NF, 2NF, 3NF, the three anomaly classesPart 1 + the lab · derived from failures rather than defined
SQLite / PostgreSQL documentation - constraint semantics, PRAGMA foreign_keysLab lever 4 · declared vs enforced constraints
Higher normal forms (BCNF, 4NF, 5NF)Part 1 · named with an honest reason they are out of scope
Kimball - deliberate denormalizationSelf-study · point-in-time capture vs redundancy; the full argument is b6 and b8
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · A wide table stores a product's category on every order line. Which anomaly does that expose you to?

The three anomalies share one root cause - a fact stored in more than one place, with nowhere of its own to live. That is why extracting products into its own table kills all three at once rather than one at a time.

2 · What does 3NF forbid that 2NF allows?

2NF removes partial dependencies on part of a composite key; 3NF removes transitive dependencies between non-key columns - category depending on product name rather than on the row's key. Composite keys are fine in both, and neither has anything to say about NULLs.

3 · Bazaar's order_items stores unit_price even though products has list_price. Why is that not a normalization error?

The test is whether the two copies are ever legitimately allowed to differ. Must always agree means redundancy - normalize it. Can differ over time means two distinct facts - store both, and name them so the difference is visible.

Builder session 2 cheat sheet · pin this

Three anomaliesUpdate (copies disagree), insert (cannot record without a parent), delete (losing a row loses a fact).
One root causeA fact stored twice, or a fact with no home of its own. Fix the cause, all three die.
1NFOne value per cell. No lists, no repeating groups, no split() to query.
2NFNothing depends on only part of a composite key.
3NFNo non-key column depends on another non-key column.
The shorthandThe key, the whole key, and nothing but the key.
Deliberate duplicationIf two copies may legitimately differ over time, they are different facts. Name them differently.
Declared ≠ enforcedSQLite needs PRAGMA foreign_keys = ON. Check yours. Next: b3, ER and keys.