Week 5: Schema Design & Normalization

Weeks 1–4 assumed a schema already existed. This week is where that schema actually gets designed — normalization isn't academic theory, it's a concrete framework for avoiding a specific, real class of bug: a database that lets the same fact be recorded twice and disagree with itself.

Module 5 of 10 Week 5 of 10 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Identify update, insert and delete anomalies in a poorly-designed table
  • Apply 1NF, 2NF and 3NF to fix them
  • Enforce referential integrity with foreign keys
  • Model one-to-many and many-to-many relationships correctly

1. Why Normalize: Update, Insert & Delete Anomalies

a poorly designed table — one row per order line, customer info repeated
orders_bad
+----+---------------+------------------------+----------+-------+
| id | customer_name | customer_email         | product  | price |
+----+---------------+------------------------+----------+-------+
| 1  | Asha          | asha@example.com       | Mouse    | 12.50 |
| 2  | Asha          | asha@example.com       | Keyboard | 39.99 |
+----+---------------+------------------------+----------+-------+

Three concrete problems this creates:

  • Update anomaly — if Asha changes her email, both rows need updating; miss one and the database now contains two different "true" emails for the same person.
  • Insert anomaly — Asha can't exist in the system until she places an order, because there's no row to put her in without a product and price.
  • Delete anomaly — deleting Asha's only order deletes all record that she exists as a customer at all.

Every one of these traces back to the same root cause: customer facts and order facts are mixed into one table instead of separated — exactly what normalization gives a systematic process for fixing.

2. 1NF, 2NF & 3NF

Each normal form fixes one specific kind of redundancy, building on the one before it.

  • 1NF (First Normal Form) — every column holds a single, atomic value; no repeating groups (a column holding a comma-separated list of products is a 1NF violation).
  • 2NF — every non-key column depends on the entire primary key, not just part of it (only relevant for tables with a composite primary key).
  • 3NF — no non-key column depends on another non-key column (a customer_email column that depends on customer_name rather than directly on the order's own key is a 3NF violation — exactly the pattern in the bad table above).
the fix — split into two tables, each holding one kind of fact
CREATE TABLE customers (
    id    SERIAL PRIMARY KEY,
    name  TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INT NOT NULL REFERENCES customers(id),
    product     TEXT NOT NULL,
    price       NUMERIC(10, 2) NOT NULL
);

Asha's email now lives in exactly one place — updating it is one write, she can exist with zero orders, and deleting an order never touches her customer record at all. All three anomalies from the previous section are gone by construction.

3. Foreign Keys & Referential Integrity

A foreign key doesn't just document a relationship — Postgres actively enforces it, refusing operations that would leave a reference pointing at nothing.

the database rejects an invalid reference
INSERT INTO orders (customer_id, product, price) VALUES (999, 'Mouse', 12.50);
-- ERROR: insert or update on table "orders" violates foreign key constraint
-- customer_id 999 doesn't exist in customers
controlling what happens on delete
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    -- ...
);
-- deleting a customer now automatically deletes their orders too,
-- rather than leaving orphaned rows or failing with an error

ON DELETE CASCADE is a real design decision, not a default to reach for automatically — ON DELETE RESTRICT (the implicit default, blocking the delete entirely while references exist) is often the safer choice for data you'd rather not lose silently as a side effect of an unrelated delete.

4. One-to-Many & Many-to-Many

One-to-many (one customer, many orders) is what every example this week has already modeled — a foreign key on the "many" side. Many-to-many (many students enrolled in many courses) needs a third table in between.

a join table for a many-to-many relationship
CREATE TABLE students (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE courses (id SERIAL PRIMARY KEY, title TEXT NOT NULL);

CREATE TABLE enrollments (
    student_id INT NOT NULL REFERENCES students(id),
    course_id  INT NOT NULL REFERENCES courses(id),
    PRIMARY KEY (student_id, course_id) -- a composite key: prevents duplicate enrollment
);

This join table (or "junction table") is the standard pattern — neither students nor courses references the other directly; enrollments sits between them, and each row represents one real enrollment. Its composite primary key (both columns together) is what structurally prevents the same student from enrolling in the same course twice.

5. Hands-on Exercise

Hands-on

Redesign a denormalized schema

Take a deliberately bad table, identify its anomalies, and fix it with a normalized design.

Requirements:

  1. Given a single enrollments_bad table with columns student_name, student_email, course_title, instructor_name (create it and insert a few rows yourself, with at least one student enrolled in two courses), identify in writing (a comment) at least one update, insert and delete anomaly it has.
  2. Redesign it into students, courses, and an enrollments join table with a composite primary key.
  3. Add a foreign key from enrollments to both students and courses, and confirm Postgres rejects an enrollment referencing a nonexistent student or course.
  4. Add an instructors table and a foreign key from courses to it, rather than storing instructor_name directly on courses.
  5. Write a query, using a join from Week 3, that reconstructs the original flat view (student name, course title, instructor name) from your normalized tables — proving no information was lost in the redesign.
Hint

If inserting a duplicate enrollment (the same student_id and course_id pair) doesn't get rejected, double check your composite primary key is actually declared as PRIMARY KEY (student_id, course_id) on both columns together — declaring two separate single-column primary keys isn't valid, and a missing composite key silently allows the exact duplicate-enrollment problem the join table is meant to prevent.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Using the bad orders_bad table as an example, why does changing a customer's email require finding and updating every one of their order rows, and what goes wrong if one is missed?

Because the email is repeated on every row that mentions that customer, rather than stored once — updating it means updating every copy. Missing even one leaves the database with two contradictory "true" emails for the same customer, with no way for a later reader to know which one is actually current — precisely the update anomaly normalization eliminates by storing each fact exactly once.

Q2

What specific redundancy does 3NF eliminate that 1NF alone does not catch?

3NF eliminates a non-key column depending on another non-key column rather than on the table's own key — like customer_email depending on customer_name rather than directly on the order's own identity. 1NF only requires atomic, non-repeating values per column; a table can satisfy 1NF while still having this kind of redundant, anomaly-prone dependency between its columns.

Q3

Why does ON DELETE CASCADE deserve to be a deliberate decision rather than a default choice on every foreign key?

It causes a delete on the referenced row to automatically and silently delete every row that references it — appropriate when the dependent rows have no meaning without their parent (order items without their order), but genuinely dangerous when the dependent data should survive independently or the delete should require explicit confirmation instead. Applying it everywhere by habit risks losing data nobody intended to delete.

Q4

Why does a many-to-many relationship need a separate join table, where a one-to-many relationship just needs a foreign key on one side?

A single foreign key column can only point to one row on the other side — perfect for "many orders, one customer each," but a student enrolled in multiple courses (and a course with multiple students) can't be expressed with a single foreign key in either direction. A join table's each row records one specific pairing, letting both sides have arbitrarily many relationships to the other.