Schema design & normalization
Design a schema that stays correct and fast: from spotting anomalies, through 1NF→3NF and constraints/indexes, to deliberate denormalization — every step runnable in sqlite3.
Learning objectives
- Explain the anomalies that duplicated data causes.
- Normalize a schema 1NF→2NF→3NF and model relationships correctly.
- Design keys, constraints, and indexes for correctness and speed.
- Know when to deliberately denormalize, and the trade-off it makes.
code/sd2-schema-design/ — pure Python / sqlite3, runs with no setup.1 · Why not one big table? essential
Start with a support ticket you'll eventually get. A customer writes in: "Your invoice spells my name 'Ava', but your shipping label says 'Ava Lee', and my account page shows both." Nobody typed it wrong on purpose. What happened is that somewhere in the system her name was stored in a dozen places — once on every order she ever placed — and one day someone updated a few of them and missed the rest. Now the database quietly disagrees with itself, and no amount of careful code will fix it, because the shape of the data invited the mistake. That is the problem this entire chapter exists to prevent.
Here's the tempting move that causes it. When you're new, one big flat table feels obviously simplest: one place to look, one row per order, everything you need on the same line. It's the spreadsheet instinct — and for a spreadsheet you fill in by hand, it's fine. The trouble starts when the same fact (Ava's name, her city, her email) gets copied onto row after row. Think of it like writing your home address on every single envelope in a drawer of a hundred letters: the day you move house, you don't have one address to change — you have a hundred, and you will miss some.
The database name for "the same truth stored in more than one place, drifting apart" is an anomaly, and it comes in three flavours worth naming so you can spot them in a design review. The update anomaly: you change a value in one row and the duplicated copies elsewhere are now wrong (two names for one person). The delete anomaly: a fact you cared about only survived because it was riding along on some other row — delete Ava's last order and you've also erased that she exists at all. The insert anomaly: you can't record a customer until she has an order, because the customer only exists as a column on the orders table — there's nowhere to put a customer who hasn't bought anything yet.
The common mistake isn't ignorance of these rules — it's assuming "I'll just be careful to update every copy." You won't. Nobody does, forever, across every code path and every 2 a.m. hotfix. The fix isn't discipline, it's design: store each fact exactly once, and let everything that needs it point to that one copy. That process is normalization, and when a fact lives in only one place, it is structurally impossible for two copies to disagree — there are no copies. The rest of this section shows the disease in code; the sections after it administer the cure.
Beginners often put everything in one wide table — the customer's name repeated on every order row. That causes anomalies: update a name in one place and it's inconsistent elsewhere (update anomaly); delete the last order and lose the customer entirely (delete anomaly); can't add a customer who has no orders yet (insert anomaly). Normalization stores each fact once so these can't happen.
This picture shows the whole point of the lesson in three steps, read left to right following the arrows. It turns one messy table into two clean, connected tables. A table is just a grid of data (rows and columns), like a spreadsheet.
- The left box, "One wide table (repeats)", is the beginner mistake: one big table where the same customer name is copied onto every order row. The small word anomalies underneath means the problems that repetition causes.
- The first arrow points to the middle box, "Normalize". "Normalize" is the process of reorganizing the data so each fact is stored only once.
1NF→3NFnames the three tidy-up stages you'll do in section 2. - The second arrow points to the right box, "customers + orders (linked)": the finished result — two separate tables that are linked together so you can still see which customer made which order.
- The colours just mark the journey: the problem state on the left, the action in the middle, the healthy result (clean + linked) on the right.
In short: Duplicated data is the disease; "normalizing" is the cure. Splitting one repetitive table into linked tables means every fact lives in exactly one place.
anomaly.pyimport sqlite3
db = sqlite3.connect(":memory:")
# BAD: customer name duplicated on every order row
db.execute("CREATE TABLE bad (order_id INT, cust_name TEXT, amount REAL)")
db.executemany("INSERT INTO bad VALUES (?,?,?)",
[(1,"Ava",40),(2,"Ava",60),(3,"Ava",20)])
db.commit()
# Ava changes her name -> must update EVERY row, or data is inconsistent:
db.execute("UPDATE bad SET cust_name='Ava Lee' WHERE order_id=1") # forgot the rest!
print(db.execute("SELECT DISTINCT cust_name FROM bad").fetchall()) # two names for one person!
[('Ava Lee',), ('Ava',)]
This short program demonstrates the problem that normalization solves. It builds a tiny in-memory database with one badly-designed table, then shows how easily the data becomes inconsistent. sqlite3 is a small database that comes free with Python; ":memory:" means "build it in RAM and throw it away when the program ends" — nothing is saved to disk.
CREATE TABLE bad (...)makes a table with three columns: an order id, the customer's name, and an amount. The flaw is that the name lives inside every order row instead of in its own place.executemany(...)inserts three rows at once. All three are Ava's orders, so the name"Ava"is written three separate times — the duplication the comment warns about.- The
UPDATE ... WHERE order_id=1line renames Ava to "Ava Lee", but only on order 1. The other two rows are left untouched — exactly the mistake a real person makes when a value is stored in many places. - The final
SELECT DISTINCT cust_nameasks "what different names are in the table?" —DISTINCTmeans "list each unique value once". Because the update was incomplete, it finds two names for one real person.
What the output means: It prints [('Ava Lee',), ('Ava',)] — two names for a single customer. That is the update anomaly: when a fact is duplicated, changing it in one spot leaves the copies wrong.
Try this: Add the two missing updates (for order_id=2 and 3) and re-run — now the query returns just one name. Then imagine doing that across a million rows, and you'll feel why we split the table instead.
2 · 1NF → 2NF → 3NF intermediate
"Normal form" sounds like abstract theory, so let me hand you the one sentence that makes it practical. Generations of students have memorized 1NF, 2NF, 3NF as three intimidating definitions and then forgotten them the day after the exam — because they learned the letters, not the idea. The idea, said in plain English by database pioneer Bill Kent, is a tiny oath every column swears: each non-key column depends on the key, the whole key, and nothing but the key. Learn that one line and you can re-derive all three forms; the rest is just naming which part of the oath a given mess violates.
Think of it as tidying a house in three passes, each stricter than the last. First pass (1NF): one thing per drawer. No cramming a list of five phone numbers into a single cell, no "products" column holding "apple, pear, milk". Every cell holds a single, atomic value — because the moment you stuff a list into one cell, you can't search it, join it, or constrain it without string-surgery. Second pass (2NF): don't file a fact under half a label. This only bites when your key is made of two columns together (like order_id + product_id). If a column actually depends on only part of that key — say the product's name depends only on product_id, not on which order it's in — it's in the wrong table, duplicated across every order that product appears in. Move it out. Third pass (3NF): no facts filed under other facts. A non-key column must depend on the key directly, not by way of another non-key column. The textbook trap: storing a customer's city and their zip_code on the orders table, when city really depends on zip, which depends on the customer — a chain (a "transitive dependency") that means updating one city means hunting down every order.
Here's the analogy that makes the whole ladder click: a key is the label on a filing folder, and every other fact in that folder should be a fact about the thing named on the label — nothing else. A customer's folder holds facts about the customer. An order's folder holds facts about the order. When you catch a fact about a customer sitting in an order's folder, you've found the thing to move, and you didn't need to remember whether it's a "2NF" or "3NF" violation to know it's wrong. The common mistake beginners make is the opposite reflex — over-normalizing into a maze of tiny tables to feel "correct" — but the honest target for almost every transactional schema is simply 3NF: each fact stored once, in the folder it belongs to. The table below names the three passes formally; read it as a checklist for the oath, not as three separate things to memorize.
Normalization proceeds in normal forms. The practical rule: every non-key column should depend on the key, the whole key, and nothing but the key.
Read left to right: the single messy table on the left is pulled apart, pass by pass, into the tidy tables on the right.
- The red box on the left is the wide table where every fact — customer, product, and line-item — is crammed together and repeated on every row.
- The arrow labelled
1NF→3NFis the three tidy-up passes: split lists into single values, then move each fact into the table it truly depends on ("one fact, one folder"). - The green boxes are the resulting tables —
customers,products,orders— each holding only facts about that one thing, stored once. - The amber join table (
order_items) is the payoff element: it stores just the two ids plusqtyand the dashed arrows show it pointing at orders and products instead of copying their data.
In short: A wide table of repeated facts becomes four tables where every fact lives once, linked by ids — that is what 1NF→3NF buys you.
| Form | Rule (plain) | Fixes |
|---|---|---|
| 1NF | each cell holds ONE value; no repeating groups | lists crammed in a column |
| 2NF | 1NF + non-key columns depend on the WHOLE key | partial-key duplication |
| 3NF | 2NF + non-key columns depend on nothing but the key | transitive duplication |
schema.pyimport sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE customers (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL);
CREATE TABLE products (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL CHECK (price >= 0));
CREATE TABLE orders (
id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id),
created TEXT NOT NULL);
-- many-to-many via a join table (an order has many products; a product is in many orders)
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
qty INTEGER NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, product_id));
""")
print("tables:", [r[0] for r in db.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()])
tables: ['customers', 'products', 'orders', 'order_items']
This is the fixed design — the same data, but split across four small tables that link to each other so no fact is ever duplicated. executescript(...) runs several CREATE TABLE statements at once. Read each table as "a list of one kind of thing": customers, products, orders, and the lines that connect orders to products.
- A PRIMARY KEY is a column whose value uniquely identifies each row — like a customer number.
id INTEGER PRIMARY KEYgives every customer, product, and order its own unique id so you can always point to exactly one row. NOT NULLmeans "this cell can't be left empty", andUNIQUE(onemail) means "no two customers may share this value". These are rules the database itself will enforce.customer_id INTEGER ... REFERENCES customers(id)is a FOREIGN KEY: instead of copying the customer's name onto the order, the order just stores the customer's id and points to the customers table. This is a one-to-many link — one customer can have many orders. Change the name once incustomersand every order sees the new value.order_itemsis a join table that solves a many-to-many relationship (one order holds many products; one product appears in many orders). It holds two foreign keys, andPRIMARY KEY (order_id, product_id)uses the pair together as the key, so the same product can't be listed twice on the same order.
What the output means: It prints tables: ['customers', 'products', 'orders', 'order_items'] — the query asks the database to list its own tables, confirming all four were created.
Try this: Trace where Ava's name lives now: only once, in customers. An order never repeats it — it just stores her id. That single change is what makes the update anomaly from Step 1 impossible here.
3 · Keys, constraints & indexes advanced
Here's a lesson every team eventually learns the expensive way. A validation rule — "email must be unique," "price can't be negative," "an order must belong to a real customer" — starts life in one place: the signup form. Then a second team adds an admin panel that inserts customers too, and they forget the check. Then a nightly import script writes rows directly. Then someone runs a one-off SQL fix at 3 a.m. Each new door into the database is a new chance to let bad data in, and the rule you "enforced in the app" is only enforced in the one app path that happened to remember it. Six months later you've got a customer with a blank email and an order pointing at a customer id that doesn't exist, and no one can say how.
The fix is to stop guarding the doors and instead put the rule where the data actually lives. A constraint is a rule you declare on the table itself, and the database refuses — for everyone, through every door, forever — to store a row that breaks it. Think of it as the difference between telling each guest "please wipe your feet" and installing a floor that physically won't let mud through. NOT NULL ("this can't be empty"), UNIQUE ("no duplicates of this value"), CHECK ("this must satisfy a condition, like price >= 0"), and FOREIGN KEY ("this must point at a row that really exists") together make whole categories of bad data unrepresentable. The invariant holds no matter which script, panel, or panicked hotfix does the writing — that's the entire point.
The second half of this section is the flip side of correctness: speed. When you ask the database "find all orders for customer 42," its default move is to read every single row and check each one — a "full scan" that's fine for a hundred rows and catastrophic for a hundred million. An index is the cure, and the analogy is exact: it's the alphabetical index at the back of a textbook. Without it, finding every mention of a word means reading the whole book; with it, you flip to the index, find the word, and jump straight to the right pages. A database index on customer_id lets the engine leap to the matching rows instead of scanning past the misses.
But — and this is the mistake that separates juniors from seniors — an index is not free, so you don't index everything "to be safe." Every index is a second sorted copy of that column the database must keep up to date, which means every insert and update now has extra bookkeeping to do, and every index eats storage. The discipline is: index the columns you actually filter on or join on (the ones in your WHERE and JOIN clauses), and leave the rest alone. And crucially, you justify an index by measuring, not guessing — which is why the lab below uses EXPLAIN QUERY PLAN to make the database show you whether it's scanning or seeking, before and after. Let's prove both correctness and speed, and reason about the trade-off.
Constraints make the database enforce your rules (so bad data can't get in), and indexes make queries fast. Let's prove both, and reason about the trade-off.
Compare the two halves split by the dashed line: the left is the same query with no index, the right is with one.
- The left row of boxes is the
orderstable; the two red cells are the rows you actually want. With no index the DB must read every box — the red arrow sweeping across all of them is the fullSCAN. - The right side adds an index (the sorted lookup box): the green arrow jumps from the index straight to the matching rows — a
SEARCH ... USING INDEX— touching only the green cells. - The bottom line is the payoff-and-price: the index is a second sorted copy of the column, so reads get fast but every insert/update must also update the index, and it costs storage.
In short: Index the columns you filter or join on to turn a whole-table scan into a direct seek — but not every column, because each index taxes writes and storage.
constraints.py# CHECK/UNIQUE/FK constraints reject bad writes at the DB layer:
try:
db.execute("INSERT INTO products(name, price) VALUES ('bad', -5)") # violates CHECK
except sqlite3.IntegrityError as e:
print("rejected:", e)
# Seed + index the FK we filter on:
db.execute("INSERT INTO customers(name,email) VALUES ('Ava','a@x.com')")
for i in range(1000): db.execute("INSERT INTO orders(customer_id,created) VALUES (1,'2026-01-01')")
db.commit()
db.execute("CREATE INDEX idx_orders_cust ON orders(customer_id)")
plan = db.execute("EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id=1").fetchall()
print("uses index:", "USING INDEX" in plan[0][-1])
rejected: CHECK constraint failed: price >= 0
uses index: True
This lab proves two things about a good schema: the constraints (rules) from Step 2 actually block bad data, and an index makes lookups fast. It continues the same database from Step 2, so the four tables already exist.
- The
try: ... except sqlite3.IntegrityErrorblock deliberately inserts a product with price-5. The schema saidCHECK (price >= 0), so the database refuses the write and raises an error, which we catch and print instead of crashing. That's the rule protecting your data automatically. - Next it inserts one customer and then 1000 orders for that customer, using a
forloop. This gives us enough rows that speed differences become measurable. CREATE INDEX idx_orders_cust ON orders(customer_id)builds an index — think of the alphabetical index at the back of a book. Without it the database scans every row to find matches; with it, it jumps straight to the right ones.EXPLAIN QUERY PLANasks the database how it would run a query without actually returning the data. We then check whether the plan text contains"USING INDEX"— proof the index is being used.
What the output means: It prints rejected: CHECK constraint failed: price >= 0 (the bad write was blocked) and uses index: True (the fast lookup path was chosen).
Try this: Comment out the CREATE INDEX line and re-run — the last line flips to uses index: False, meaning the database now scans all 1000 rows. That contrast is exactly why indexes matter.
4 · When to denormalize expert
Now for the plot twist that trips up anyone who's just learned normalization. You spend three sections learning "store every fact exactly once," you internalize it as gospel — and then a senior engineer looks at your beautifully normalized schema and says "add a total column to orders." Wait, isn't that duplicating a value you could compute from the line items? Yes. On purpose. This is the moment you learn that normalization was never the goal; a correct, fast system was the goal, and normalization is the tool you reach for first because it's usually right — not a religion.
Here's the concrete pain that justifies breaking the rule. Imagine the checkout page. Every time it loads, to show the order total, the pure-3NF way is to fetch every line item and re-add them: SUM(price × qty) over maybe dozens of rows, on every single page view, for every customer, all day. The number rarely changes, but you recompute it constantly. It's like recounting the cash in the register from scratch every time someone asks the balance, instead of keeping a running total on a sticky note. Denormalization is that sticky note: you store the computed order_total right on the order row, and reads become a single instant lookup instead of a scan-and-sum. You've traded a little duplication for a lot of read speed — deliberately, on a path you know is hot.
So why not do it everywhere? Because every duplicated value reintroduces exactly the demon section 1 exorcised: the chance that the copy and the truth drift apart. The sticky note is a lie the instant someone adds an item and forgets to update it — and now your cached total silently disagrees with the actual line items, which is arguably worse than being slow, because it's wrong and you trust it. The professional answer is to never rely on remembering: you let the database keep the copy correct automatically, with a trigger (a rule that fires on every insert or update to the source rows) or tightly-controlled application logic, so the cache can't go stale.
The mistake to avoid isn't denormalizing — it's denormalizing by accident, or prematurely, or without a guard. The honest workflow is: normalize to 3NF first (correct by default), then denormalize only a specific path you've measured to be a bottleneck, keep the duplicate correct with a trigger, and leave a comment saying why the exception exists so the next engineer doesn't "clean it up." Denormalize consciously, or not at all. The lab below builds exactly that — a cached total that a trigger keeps honest.
3NF is the default, but deliberate denormalization — storing a computed or duplicated value — trades write complexity for read speed on hot paths. The classic example: a cached order_total so the checkout page doesn't re-sum items on every view.
orders.total and a trigger updates it on each insert — reads become an instant lookup that can't drift, because the DB maintains it.
The dashed line splits two ways to answer "what is this order's total?" — the pure-3NF way on the left, the deliberately denormalized way on the right.
- The left side keeps only line items; every read fires a
SUM(price×qty)(the amber box) over all of them — and the amber label warns this runs on every page view. - The right side stores a cached
orders.total(green). When an item is inserted, the trigger (the purple box) fires and updates that total automatically — the blue arrows trace insert → trigger → cached total. - The payoff element is the bottom-right green box: reads are now
O(1), a single lookup, and because the trigger maintains the copy it can never silently drift out of sync.
In short: Denormalization trades a little write-time bookkeeping (the trigger) for fast, correct reads — worth it only on a measured hot path.
denormalize.pyimport sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL DEFAULT 0);
CREATE TABLE items (order_id INT, price REAL, qty INT);
-- keep the denormalized total correct automatically on every insert:
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
UPDATE orders SET total = total + NEW.price * NEW.qty WHERE id = NEW.order_id;
END;
""")
db.execute("INSERT INTO orders(id) VALUES (1)")
db.executemany("INSERT INTO items VALUES (?,?,?)", [(1,10.0,2),(1,5.0,3)])
db.commit()
print("cached total:", db.execute("SELECT total FROM orders WHERE id=1").fetchone()[0]) # 35.0
cached total: 35.0
This final lab shows a deliberate exception to the "store each fact once" rule. Re-summing an order's items on every page view can be slow, so we cache (store a ready-made copy of) the order total. The danger is the copy drifting out of date — so we use a trigger to keep it correct by itself.
- Two tables are created:
ordershas atotalcolumn that starts at0, anditemsholds each line's price and quantity. Thetotalis the duplicated ("denormalized") value we're choosing to store. - A TRIGGER is a rule that runs automatically when something happens.
CREATE TRIGGER items_ai AFTER INSERT ON itemsmeans "every time a new row is inserted intoitems, run the following step". - Inside,
UPDATE orders SET total = total + NEW.price * NEW.qty WHERE id = NEW.order_idadds the new line's cost (price × quantity) onto that order's running total.NEWrefers to the row just inserted. So the total updates itself — no separate code needed. - We insert one order, then two item lines (2 × 10.0 = 20, plus 3 × 5.0 = 15). The trigger fires once per line, so the stored total climbs to 35 without us ever calculating it in Python.
What the output means: It prints cached total: 35.0 — the value was maintained entirely by the trigger, matching what re-summing the items by hand would give.
Try this: Insert a third item line, e.g. (1, 2.0, 4), before the print — the total becomes 43.0 automatically. The trigger is what lets you safely keep a duplicated value without it ever going stale.
Exercise SD2.1 — Normalize, constrain, and speed up
Context: A production schema task bundles everything: take a messy wide table and make it correct, safe, and fast — normalized, constrained, indexed, and selectively denormalized where reads demand it.
Your task: Starting from a wide sales(cust_name, cust_email, product, price, qty, date) table, normalize it to 3NF with a join table, add constraints that reject bad data, add the right index and confirm it, and add a denormalized per-order total kept correct with a trigger.
Requirements:
- Split
salesinto customers, products, and an orders/line-items join table in 3NF - Add CHECK, UNIQUE, and FOREIGN KEY constraints and prove each rejects invalid data
- Add an index on a filtered/joined column and confirm it via
EXPLAIN QUERY PLAN - Add a denormalized per-order total maintained by a trigger
- Show the total stays correct as line items are inserted
💡 Hint: Decide which columns describe the customer, which describe the product, and which describe the line item — that partition is your 3NF split before you add constraints and the trigger.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Before you can appreciate normalization you have to feel the pain it prevents. A single wide table that repeats the same fact on every row is how quietly-corrupt data gets born.
Your task: Build one wide orders_wide table that stores the customer's city on every order row, then demonstrate an update anomaly by changing the city on a single row and showing the table now disagrees with itself.
Requirements:
- Create one denormalized table repeating
custandcityper order - Insert at least two orders for the same customer with the same city
- Update the city on only one of those rows, as a careless UPDATE would
- Query distinct
(cust, city)pairs and show the customer now has two cities - Articulate that duplicated data is duplicated truth, and truth drifts
💡 Hint: The anomaly appears the moment you update "the row you happened to touch" instead of a single source of truth — a targeted WHERE oid=1 makes it obvious.
Show solution
Duplicated data is duplicated truth — and truth drifts:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders_wide (oid INTEGER, cust TEXT, city TEXT, amt REAL)")
db.executemany("INSERT INTO orders_wide VALUES (?, ?, ?, ?)",
[(1, "Ada", "London", 250), (2, "Ada", "London", 500)])
# Ada moves; we update only the row we happened to touch
db.execute("UPDATE orders_wide SET city='Berlin' WHERE oid=1")
print(db.execute("SELECT DISTINCT cust, city FROM orders_wide WHERE cust='Ada'").fetchall())
# [('Ada', 'Berlin'), ('Ada', 'London')] <-- inconsistent!
Two "cities" for one customer is the anomaly normalization removes by storing each fact once.
Context: Third normal form is the default target for transactional schemas: every fact is stored exactly once, so an update touches exactly one place and no anomaly is possible.
Your task: Split the wide table into customers (holding the city) and orders (holding a foreign key to the customer), reinsert the data, and show the city update now touches a single row.
Requirements:
- Separate the entities so city lives on
customers, not on every order - Give
ordersacust_idthat references the customer - Reload the same data across the two normalized tables
- Update the city in one place and read it back through a JOIN across all orders
- Show every order reflects the new city, making the earlier anomaly impossible
💡 Hint: 3NF's rule of thumb — every non-key column depends on the key, the whole key, and nothing but the key — tells you city belongs with the customer, not the order.
Show solution
3NF: every non-key column depends on the key, the whole key, and nothing but the key:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)")
db.execute("CREATE TABLE orders (oid INTEGER PRIMARY KEY, cust_id INTEGER, amt REAL)")
db.execute("INSERT INTO customers (name, city) VALUES ('Ada', 'London')")
db.executemany("INSERT INTO orders (cust_id, amt) VALUES (?, ?)", [(1, 250), (1, 500)])
db.execute("UPDATE customers SET city='Berlin' WHERE id=1") # one place
rows = db.execute(
"SELECT c.name, c.city, o.amt FROM orders o JOIN customers c ON c.id=o.cust_id"
).fetchall()
print(rows) # [('Ada', 'Berlin', 250.0), ('Ada', 'Berlin', 500.0)]
City is stored once; every order sees the update through the join. No anomaly is possible.
Context: Correctness should be the database's job, not something every code path remembers to check. Keys and constraints make invalid states literally unrepresentable.
Your task: Add a FOREIGN KEY, a NOT NULL, and a UNIQUE constraint to the schema, then show the database rejecting both a duplicate email and an order that points at a non-existent customer.
Requirements:
- Declare
emailasNOT NULL UNIQUEon customers - Declare a
FOREIGN KEYfromorders.cust_idto customers, withPRAGMA foreign_keys = ON - Attempt a duplicate-email insert and catch the resulting
IntegrityError - Attempt an orphan-order insert (a customer id that doesn't exist) and catch it too
- Show the invariant holds no matter which code path attempts the bad insert
💡 Hint: SQLite only enforces foreign keys when you turn them on; wrap each bad insert in a try/except sqlite3.IntegrityError to prove the rejection.
Show solution
Constraints make invalid states unrepresentable:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON") # sqlite needs this on
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, "
"email TEXT NOT NULL UNIQUE)")
db.execute("CREATE TABLE orders (oid INTEGER PRIMARY KEY, cust_id INTEGER NOT NULL, "
"FOREIGN KEY(cust_id) REFERENCES customers(id))")
db.execute("INSERT INTO customers (email) VALUES ('ada@x.com')")
for bad, label in [("INSERT INTO customers (email) VALUES ('ada@x.com')", "dup email"),
("INSERT INTO orders (cust_id) VALUES (999)", "orphan order")]:
try:
db.execute(bad)
except sqlite3.IntegrityError as e:
print(label, "->", type(e).__name__)
# dup email -> IntegrityError
# orphan order -> IntegrityError
The database refuses to persist a duplicate email or an order pointing at a non-existent customer — the invariant holds no matter which code path inserts.
Context: Indexes are the main read-speed lever, and they are a deliberate trade: faster reads in exchange for slower writes and extra storage. You justify them by measuring, not guessing.
Your task: Insert many rows, then compare the query plan for a filtered lookup before and after adding an index, confirming the engine switches to an indexed search.
Requirements:
- Load a substantial number of rows (e.g. tens of thousands) into an events table
- Capture the plan for a filtered query and see a full
SCAN - Create an index on the filtered column
- Capture the plan again and see it become
SEARCH ... USING INDEX - State the trade-off: each index also slows inserts, so index only columns you actually filter or join on
💡 Hint: Even offline, the query plan is the proof — you don't need a stopwatch when EXPLAIN QUERY PLAN shows the access path changing.
Show solution
Measure, don't guess — even offline the plan proves the change:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, user_id INTEGER, kind TEXT)")
db.executemany("INSERT INTO events (user_id, kind) VALUES (?, ?)",
[(i % 1000, "click") for i in range(20000)])
before = db.execute(
"EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id = 42").fetchall()
db.execute("CREATE INDEX idx_user ON events(user_id)")
after = db.execute(
"EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id = 42").fetchall()
print("before:", before[0][-1]) # SCAN events
print("after :", after[0][-1]) # SEARCH events USING INDEX idx_user (user_id=?)
The index turns a full scan of 20k rows into a targeted search. Each index also slows inserts, so index the columns you actually filter/join on.
Context: Sometimes you copy data on purpose. When a derived value is read constantly but expensive to compute, caching it on the parent row — kept correct by a trigger — is a legitimate, deliberate denormalization.
Your task: Add a cached order_count column to customers, keep it correct with an AFTER INSERT trigger on orders, and show the counter updating as orders arrive.
Requirements:
- Add an
order_countcolumn defaulting to zero on customers - Create an
AFTER INSERTtrigger that increments the owning customer's count - Insert several orders and read the cached count back as a single lookup
- Explain the trade: the read is now O(1) instead of a COUNT scan, paid for by write-time bookkeeping
- Note this is only worth it when the read is hot and the derived value is expensive
💡 Hint: The trigger references NEW.cust_id to know which customer to bump; the point is that the cache can never silently drift because the database maintains it.
Show solution
Denormalization is a conscious trade: faster reads, more write-time bookkeeping:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, order_count INTEGER DEFAULT 0)")
db.execute("CREATE TABLE orders (oid INTEGER PRIMARY KEY, cust_id INTEGER)")
db.execute("INSERT INTO customers (id) VALUES (1)")
db.execute(
"CREATE TRIGGER bump_count AFTER INSERT ON orders "
"BEGIN "
" UPDATE customers SET order_count = order_count + 1 WHERE id = NEW.cust_id; "
"END")
db.executemany("INSERT INTO orders (cust_id) VALUES (?)", [(1,), (1,), (1,)])
print(db.execute("SELECT order_count FROM customers WHERE id=1").fetchone()) # (3,)
Reads of the count are now O(1) instead of a COUNT scan — paid for by the trigger keeping the cache correct. Only do this when the read is hot and the derived value is expensive.
Context: Teams catch the same schema mistakes over and over in review: a table with no primary key, a foreign key to a table that doesn't exist, a hot filter column with no index. Codifying the review makes it a machine check instead of a hope.
Your task: Given a schema described as data, write a review function that asserts every table has a primary key, every foreign key references an existing table, and every hot filter column is indexed — returning the list of issues found.
Requirements:
- Represent the schema and the set of hot filter columns as plain data structures
- Flag any table missing a primary key
- Flag any foreign key whose referenced table is absent from the schema
- Flag any hot filter column that is not present in that table's index list
- Return a list of human-readable issue strings rather than raising
- Demonstrate it catching a real, common bug: a filtered foreign-key column with no index
💡 Hint: Iterate the schema dict once, appending a descriptive string per violation; the value is that the check runs before a migration ships, not after.
Show solution
A machine-checkable review catches the classic mistakes before migration:
SCHEMA = {
"customers": {"pk": "id", "fks": [], "indexes": ["email"]},
"orders": {"pk": "oid", "fks": [("cust_id", "customers")], "indexes": ["cust_id"]},
"events": {"pk": "id", "fks": [("user_id", "customers")], "indexes": []}, # gap
}
HOT_FILTER_COLS = {"events": ["user_id"]}
def review(schema):
issues = []
for t, meta in schema.items():
if not meta.get("pk"):
issues.append(f"{t}: no primary key")
for col, ref in meta.get("fks", []):
if ref not in schema:
issues.append(f"{t}.{col}: FK to missing table {ref}")
for col in HOT_FILTER_COLS.get(t, []):
if col not in meta.get("indexes", []):
issues.append(f"{t}.{col}: hot filter column not indexed")
return issues
print(review(SCHEMA)) # ['events.user_id: hot filter column not indexed']
The one issue flagged is a real, common performance bug — a foreign-key column that's filtered on but not indexed.
✓ Checkpoint — you can move on when you can…
- Explain the three anomalies and how normalization removes them.
- Normalize to 3NF and model a many-to-many with a join table.
- Enforce rules with constraints and speed reads with indexes.
- Decide when to denormalize and keep the copy correct.
Knowledge check check yourself
Name the three anomalies that duplicating data (e.g. one wide table) causes, and explain what each one is.
Show answer
How do you model a many-to-many relationship (such as orders and products) in a normalized schema, and what serves as the primary key?
Show answer
order_items) that holds a foreign key to each side. Its primary key is the pair of those foreign keys together — PRIMARY KEY (order_id, product_id) — which also prevents the same product being listed twice on the same order.