AI EngineeringZero to ProductionHome·About·What’s new·Contact
System Design · Chapter SD2

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.

⏱️ ~2.5 hours🧪 4 labs🎯 Beginner→Expert

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.
▶ Runnable companionEvery code block here is also saved under 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.

One wide table(repeats) anomalies Normalize 1NF→3NF customers + orders(linked) clean + linked
🗺️ How to read this diagram

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→3NF names 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · See an anomaly (runs standalone)
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',)]
▶ How this works

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.

  1. 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.
  2. 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.
  3. The UPDATE ... WHERE order_id=1 line 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.
  4. The final SELECT DISTINCT cust_name asks "what different names are in the table?" — DISTINCT means "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.

wide table (all facts mixed) cust_name cust_email product price qty date 1NF→3NF one fact, one folder customers id · name · email products id · name · price orders id · customer_id · date order_items (join) order_id · product_id order_items holds only the two ids + qty; it points at the others Normalization is filing each fact in the folder it belongs to. The one red column-list becomes four green tables — customer facts with the customer, product facts with the product — and a join table (amber) links orders to products by id, never by copying.
🗺️ How to read this diagram

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→3NF is 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 plus qty and 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.

FormRule (plain)Fixes
1NFeach cell holds ONE value; no repeating groupslists crammed in a column
2NF1NF + non-key columns depend on the WHOLE keypartial-key duplication
3NF2NF + non-key columns depend on nothing but the keytransitive duplication
Step 2 · A normalized schema (runs standalone)
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']
▶ How this works

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.

  1. A PRIMARY KEY is a column whose value uniquely identifies each row — like a customer number. id INTEGER PRIMARY KEY gives every customer, product, and order its own unique id so you can always point to exactly one row.
  2. NOT NULL means "this cell can't be left empty", and UNIQUE (on email) means "no two customers may share this value". These are rules the database itself will enforce.
  3. 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 in customers and every order sees the new value.
  4. order_items is 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, and PRIMARY 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.

The join table is the key insightA many-to-many relationship (orders ↔ products) needs a third join table whose primary key is the pair of foreign keys. This one pattern appears in almost every real schema.

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.

no index: full SCAN — read every row check all 1000 rows with index on customer_id: SEARCH index (sorted) cust=1 jump straight to matches same query, two access paths — the index is a second sorted copy the DB keeps updated cost of the index: every INSERT/UPDATE must also maintain it, and it uses extra storage An index is the book's back-of-book index. Without one, "orders for customer 1" reads every row (red scan); with one, the engine seeks straight to the matches (green). The trade: faster reads, but slower writes and more storage.
🗺️ How to read this diagram

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 orders table; 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 full SCAN.
  • 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.

Step 3 · Constraints reject bad data; index speeds reads (continues Step 2)
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
▶ How this works

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.

  1. The try: ... except sqlite3.IntegrityError block deliberately inserts a product with price -5. The schema said CHECK (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.
  2. Next it inserts one customer and then 1000 orders for that customer, using a for loop. This gives us enough rows that speed differences become measurable.
  3. 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.
  4. EXPLAIN QUERY PLAN asks 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.

pure 3NF: re-sum items on every read line items SUM(price×qty) recomputed every page view denormalized: cached total + trigger insert item trigger orders.total (cached) read = O(1) left pays on every read; right pays once per write and the trigger keeps the copy honest Denormalization moves the cost from read-time to write-time. Pure 3NF re-sums the line items on every view (amber); the denormalized version stores orders.total and a trigger updates it on each insert — reads become an instant lookup that can't drift, because the DB maintains it.
🗺️ How to read this diagram

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.

Step 4 · A denormalized total, kept correct with a trigger (runs standalone)
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
▶ How this works

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.

  1. Two tables are created: orders has a total column that starts at 0, and items holds each line's price and quantity. The total is the duplicated ("denormalized") value we're choosing to store.
  2. A TRIGGER is a rule that runs automatically when something happens. CREATE TRIGGER items_ai AFTER INSERT ON items means "every time a new row is inserted into items, run the following step".
  3. Inside, UPDATE orders SET total = total + NEW.price * NEW.qty WHERE id = NEW.order_id adds the new line's cost (price × quantity) onto that order's running total. NEW refers to the row just inserted. So the total updates itself — no separate code needed.
  4. 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.

Denormalize consciously, never by accidentEvery duplicated value is a chance for data to drift out of sync. Only denormalize a proven hot path, keep it correct with triggers/app logic, and document why. Normalize first; denormalize when the numbers (SD6) demand it.

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 sales into 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.

Exercise 1 · Show an update anomalyBeginner

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 cust and city per 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.

Exercise 2 · Normalize to 3NFIntermediate

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 orders a cust_id that 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.

Exercise 3 · Enforce with keys & constraintsAdvanced

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 email as NOT NULL UNIQUE on customers
  • Declare a FOREIGN KEY from orders.cust_id to customers, with PRAGMA 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.

Exercise 4 · Index the read path and measureExpert

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.

Exercise 5 · When to denormalize (deliberately)Professional

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_count column defaulting to zero on customers
  • Create an AFTER INSERT trigger 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.

Exercise 6 · Schema review checklist (validated)Industry scenario

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

✓ Knowledge check

Name the three anomalies that duplicating data (e.g. one wide table) causes, and explain what each one is.

Show answer
The update anomaly: changing a value in one place leaves duplicated copies inconsistent (e.g. two names for one customer). The delete anomaly: deleting the last order also loses the customer entirely. The insert anomaly: you can't add a customer who has no orders yet. Normalization stores each fact once so none can occur.
✓ Knowledge check

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
You introduce a third join table (e.g. 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in