Lesson 18 of 20

Database Design & Normalization

Why Design Comes Before Typing

Almost everybody's first database is one enormous table. Somebody building a college result portal creates records with columns for student name, roll number, branch, head of department, subject, marks and semester, and puts one row in it per subject per student. It works, until it does not.

Look at what that table forces you to accept. The head of department's name is repeated in every single row for that branch, so when the post changes hands you must find and update hundreds of rows, and if you miss ten the database now holds two contradictory answers to the same question. That is an update anomaly. A new branch that has not yet enrolled anybody cannot be recorded at all, because there is no row to put it in without a student — an insertion anomaly. And deleting the last student of a branch silently erases every trace that the branch existed, which is a deletion anomaly. Three different failures, all caused by one design decision.

The cure is a principle you can state in a sentence: every fact should be stored in exactly one place. The head of a department is a fact about a department, so it belongs in a departments table with one row per department. A student's name is a fact about a student. A mark is a fact about one student in one subject in one semester. Each fact gets a home, and everything else points at it.

So design starts on paper, not in a SQL client. Write down the nouns your system deals with — student, course, order, product, payment — and each of those is a candidate table. Then write down how they connect, in plain sentences: one student places many orders; one order contains many products; one product appears in many orders. Those sentences determine the shape of the schema, and changing them later is far more painful than getting them right now, because by then real data is sitting inside the mistake.

Example
-- The table that causes all three anomalies
-- CREATE TABLE records (
--     student_name VARCHAR(100),
--     roll_number  VARCHAR(20),
--     branch       VARCHAR(40),
--     hod_name     VARCHAR(100),   -- repeated in every row of the branch
--     subject      VARCHAR(60),
--     marks        INT
-- );

-- One fact, one place. A department is a thing, so it gets a table.
CREATE TABLE departments (
    id       INT AUTO_INCREMENT PRIMARY KEY,
    name     VARCHAR(60) NOT NULL UNIQUE,
    hod_name VARCHAR(100)
);

CREATE TABLE students (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    name          VARCHAR(100) NOT NULL,
    roll_number   VARCHAR(20)  NOT NULL UNIQUE,
    department_id INT,
    joined_on     DATE,
    FOREIGN KEY (department_id) REFERENCES departments(id)
);

-- Changing a head of department is now one row, and cannot go half done
UPDATE departments SET hod_name = 'Dr R Iyer' WHERE name = 'CSE';

-- A department with no students yet can still exist
INSERT INTO departments (name, hod_name) VALUES ('AI & DS', 'Dr S Menon');
Notes
  • You will hear that the aim of good design is 'avoiding duplicate data'. That is close but not quite it — the aim is avoiding duplicate facts. Two students may both be called Ananya Sharma and that is not a problem; those are two separate facts that happen to look alike. Repeating one head of department's name across four hundred rows is a problem, because there is only one fact there and now there are four hundred copies of it that can disagree.

Keys: What Identifies a Row

A primary key is the column, or set of columns, that identifies a row uniquely and never changes. Every table should have one. Without it you cannot reliably update or delete a single row, other tables have nothing to point at, and in MySQL's InnoDB engine you also lose control of the physical row ordering, since it will invent a hidden key if you do not supply one.

There are two ways to choose it. A natural key is data that already exists and is already unique — a roll number, an email address, a PAN. A surrogate key is a meaningless number the database generates. Natural keys look appealing because they save a column, and they are almost always a mistake as primary keys, for one reason: real-world identifiers change. Email addresses change. Roll numbers get reissued after a re-admission, or turn out to have a typo. When a primary key changes, every foreign key pointing at it has to change with it, and any of them you miss becomes a broken link.

So the standard advice is a surrogate integer primary key, plus a UNIQUE constraint on the natural key to keep the real-world rule enforced. You get a stable, small identifier for the database to use internally and a guarantee that no two students share a roll number. Both properties matter and they are separate concerns.

How you generate that number differs by product, and this is one of the first things to check when moving a schema. MySQL uses AUTO_INCREMENT on the column. PostgreSQL traditionally used SERIAL, and now prefers the standard GENERATED ALWAYS AS IDENTITY. SQL Server uses IDENTITY(1,1). Oracle uses GENERATED BY DEFAULT AS IDENTITY or, on older versions, a sequence plus a trigger. UUIDs are the other common choice, useful when rows are created on many machines or in an app before reaching the server, but remember from the indexing lesson that a long random key makes every secondary index on the table bigger.

Example
-- Surrogate primary key, natural key kept UNIQUE alongside it
CREATE TABLE students (
    id          INT AUTO_INCREMENT PRIMARY KEY,   -- stable, meaningless
    roll_number VARCHAR(20)  NOT NULL UNIQUE,     -- real, and enforced
    email       VARCHAR(120) NOT NULL UNIQUE,
    name        VARCHAR(100) NOT NULL
);

-- A roll number typo is now a one-row correction, not a migration
UPDATE students SET roll_number = 'CS2026114' WHERE id = 42;

-- Generating the key, by dialect
-- MySQL:       id INT AUTO_INCREMENT PRIMARY KEY
-- PostgreSQL:  id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
--              id SERIAL PRIMARY KEY            (older style)
-- SQL Server:  id INT IDENTITY(1,1) PRIMARY KEY
-- Oracle:      id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

-- A composite primary key: the pair is what must be unique
CREATE TABLE enrollments (
    student_id  INT NOT NULL,
    course_id   INT NOT NULL,
    enrolled_on DATE,
    PRIMARY KEY (student_id, course_id)
);
Notes
  • AUTO_INCREMENT values are not promised to be gap-free. A rolled-back transaction or a failed insert consumes a number and does not give it back, so ids run 1, 2, 3, 7. That is normal and harmless. It only becomes a problem if somebody has decided the id is also an invoice number, which is a good reason to keep a separate, deliberately generated column for anything a human will read.

The Three Shapes of a Relationship

Every connection between two tables is one of three shapes, and knowing which one you have tells you exactly where the foreign key goes.

One-to-many is by far the most common: one student places many orders, one department has many students. The foreign key goes in the table on the many side. This trips people up because the instinct is to put the list where it conceptually belongs — a list of order ids on the student row — and SQL simply has no column type for 'a list'. Each order carries a student_id instead, and to find one student's orders you filter orders by that column.

Many-to-many cannot be expressed with a foreign key at all, because neither side can hold a list. It needs a third table, called a junction or bridge table, holding one row per pairing. Students enrol in many courses and courses hold many students, so enrollments exists with a student_id and a course_id. Making the pair the primary key means the same student cannot be enrolled twice in the same course, which is a real rule enforced by the database rather than a hope pinned on the application. Crucially, the junction table is usually an entity in its own right and will grow columns of its own — enrolled_on, grade, attendance_percent — because those are facts about the pairing, not about the student or the course separately.

One-to-one is the rarest, and worth pausing on, because most one-to-one relationships should just be extra columns on the same table. It earns its place when a group of columns is optional for most rows, when it is large enough that you would rather not load it with every query, or when it is sensitive and you want to grant access to it separately. The foreign key can go in either table, and a UNIQUE constraint on it is what makes the relationship one-to-one rather than one-to-many.

Example
-- One-to-many: the foreign key lives on the 'many' side
CREATE TABLE orders (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    student_id INT NOT NULL,
    ordered_at DATETIME NOT NULL,
    status     VARCHAR(20) NOT NULL DEFAULT 'pending',
    FOREIGN KEY (student_id) REFERENCES students(id)
);

-- One student's orders
SELECT * FROM orders WHERE student_id = 42;

-- Many-to-many: a third table holds the pairings
CREATE TABLE courses (
    id    INT AUTO_INCREMENT PRIMARY KEY,
    code  VARCHAR(12) NOT NULL UNIQUE,
    title VARCHAR(120) NOT NULL
);

CREATE TABLE enrollments (
    student_id  INT  NOT NULL,
    course_id   INT  NOT NULL,
    enrolled_on DATE NOT NULL,
    grade       CHAR(2),            -- a fact about the pairing
    PRIMARY KEY (student_id, course_id),
    FOREIGN KEY (student_id) REFERENCES students(id),
    FOREIGN KEY (course_id)  REFERENCES courses(id)
);

-- Reading across a many-to-many needs both joins
SELECT s.name, c.title, e.grade
FROM students s
JOIN enrollments e ON e.student_id = s.id
JOIN courses     c ON c.id = e.course_id
WHERE s.id = 42;

-- One-to-one: UNIQUE is what stops it becoming one-to-many
CREATE TABLE student_documents (
    student_id     INT PRIMARY KEY,     -- also unique, by definition
    aadhaar_number VARCHAR(20),
    photo_url      VARCHAR(255),
    FOREIGN KEY (student_id) REFERENCES students(id)
);
Notes
  • If you ever find yourself creating subject_1, subject_2, subject_3 columns, or storing 'DBMS,OS,CN' in a single text column, you have met a one-to-many relationship and tried to flatten it. Both designs make the obvious questions unreasonably hard — how many students take DBMS, what is the average mark in OS — and both break the moment somebody needs a fourth subject. The answer is another table.

Foreign Keys, and What Happens on Delete

A foreign key does two jobs. It documents that one column points at another table's key, and it makes the database enforce that link — you cannot insert an order for a student who does not exist, and by default you cannot delete a student who still has orders. That second job is the valuable one, because it means bad data is rejected at the source rather than discovered six months later in a report.

What 'by default' means is configurable, through ON DELETE and ON UPDATE clauses. RESTRICT (the default in MySQL, along with its synonym NO ACTION) refuses the delete while children exist. CASCADE deletes the children too. SET NULL keeps the children but blanks their foreign key, which requires that the column allows NULL. Each is right somewhere, and picking the wrong one is a decision that only reveals itself when somebody presses delete.

CASCADE is the one to be careful with, and the danger is that it chains. Suppose categories cascades to products, and products cascades to order_items. Deleting one unused category now removes its products, and removing those products removes the order lines that reference them — which means deleting a category has just rewritten the value of past orders and destroyed financial history. Nothing warned you, because you asked for exactly this. Use CASCADE where the child genuinely cannot exist alone (order lines belong to their order; a profile row belongs to its user) and RESTRICT almost everywhere else. For records that must survive, the usual answer is not to delete at all: add an is_active or deleted_at column and hide the row instead.

Two setup traps worth knowing before you trust a foreign key. In MySQL, only the InnoDB engine enforces them — the older MyISAM engine parses the clause, accepts it and then ignores it entirely, so your constraint exists on paper and nowhere else. And SQLite does not enforce foreign keys unless you run PRAGMA foreign_keys = ON; on every connection. In both cases the schema looks correct and the rule is not actually in force.

Example
-- CASCADE where the child genuinely cannot exist alone.
-- An order line has no meaning without its order.
CREATE TABLE order_items (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    order_id   INT NOT NULL,
    product_id INT NOT NULL,
    quantity   INT NOT NULL DEFAULT 1,
    unit_price DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (order_id)   REFERENCES orders(id)   ON DELETE CASCADE,
    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT
);
-- Note the second one: deleting a product that appears on a past
-- order is refused, because that order line is a historical record.

-- SET NULL keeps the child and forgets the link.
-- The column must allow NULL for this to be legal.
CREATE TABLE products (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    price       DECIMAL(10,2) NOT NULL,
    category_id INT NULL,
    FOREIGN KEY (category_id) REFERENCES categories(id)
        ON DELETE SET NULL
);

-- The alternative to deleting: stop showing it
ALTER TABLE products ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE products SET is_active = FALSE WHERE id = 17;
SELECT * FROM products WHERE is_active = TRUE;

-- Adding a constraint to a table that already exists
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_student
    FOREIGN KEY (student_id) REFERENCES students(id)
    ON DELETE RESTRICT;

-- Is the constraint actually being enforced?
SHOW CREATE TABLE orders;        -- MySQL: check ENGINE=InnoDB
-- SQLite: PRAGMA foreign_keys = ON;   (per connection, every time)
Notes
  • Before adding a foreign key to a table that already holds data, look for rows that would violate it: SELECT * FROM orders o LEFT JOIN students s ON s.id = o.student_id WHERE s.id IS NULL;. If that returns anything, the ALTER TABLE will fail, and the rows it returns are orphans that have been quietly accumulating. Deciding what to do with them is part of adding the constraint, not a separate problem.

Normalization, One Form at a Time

Normalization is the formal name for the tidying-up this lesson has been doing by instinct. It is a sequence of rules, called normal forms, each removing one specific way that data can contradict itself. Interviewers ask about the first three, and the first three are also the ones that matter in practice.

First normal form asks that every column hold a single, indivisible value, and that there be no repeating groups. A subjects column containing 'DBMS, OS, CN' breaks it, and so do subject_1, subject_2 and subject_3. The test is whether you can answer a normal question easily: 'how many students take OS?' against a comma-separated column requires string matching that will also find 'OSY' and cannot use an index. The fix is a separate row per value, which usually means a separate table.

Second normal form only has anything to say when the primary key is made of more than one column, and it asks that every non-key column depend on the whole key rather than part of it. Take enrollments, keyed on (student_id, course_id). A grade column depends on both — it is the grade for that student in that course — so it belongs. A course_title column depends only on course_id, so it does not, and leaving it there means the title is repeated once per enrolled student and can be spelled differently in each. It belongs in courses.

Third normal form asks that no non-key column depend on another non-key column. In a students table with pincode and city, the city is determined by the pincode, not by the student — so a pincode typed once with the wrong city sits there contradicting every other row with that pincode. Moving pincode and city into their own table makes the relationship a single fact again. That is the whole of 3NF, and the classroom summary is fair: every non-key column depends on the key, the whole key, and nothing but the key.

Example
-- Not 1NF: several values crammed into one column
-- CREATE TABLE bad_students (
--     id       INT PRIMARY KEY,
--     name     VARCHAR(100),
--     subjects VARCHAR(200)      -- 'DBMS, OS, CN'
-- );

-- 1NF: one row per value, in a table of its own
CREATE TABLE enrollments (
    student_id INT NOT NULL,
    course_id  INT NOT NULL,
    grade      CHAR(2),
    PRIMARY KEY (student_id, course_id)
);

-- The question is now answerable, and can use an index
SELECT COUNT(*) FROM enrollments WHERE course_id = 7;

-- Not 2NF: course_title depends on course_id alone, not on the pair
-- PRIMARY KEY (student_id, course_id)
-- student_id | course_id | course_title | grade

-- 2NF: the title moves to the table its key belongs to
-- courses:     id | code | title
-- enrollments: student_id | course_id | grade

-- Not 3NF: city is determined by pincode, not by the student
-- students: id | name | pincode | city | state

-- 3NF: the pincode fact gets its own home
CREATE TABLE pincodes (
    pincode CHAR(6) PRIMARY KEY,
    city    VARCHAR(60) NOT NULL,
    state   VARCHAR(60) NOT NULL
);

ALTER TABLE students ADD COLUMN pincode CHAR(6);
ALTER TABLE students
    ADD CONSTRAINT fk_students_pincode
    FOREIGN KEY (pincode) REFERENCES pincodes(pincode);

-- Reading it back is one join
SELECT s.name, p.city, p.state
FROM students s
LEFT JOIN pincodes p ON p.pincode = s.pincode;
Notes
  • Higher normal forms exist — BCNF, 4NF, 5NF — and they address genuinely obscure cases involving overlapping candidate keys and independent multi-valued facts. A schema in 3NF is almost always in BCNF already. Know that the others exist so the words are not a surprise in an interview, and spend your attention on getting 3NF right.

Where to Stop: Deliberate Denormalization

Normalization has a cost, and the cost is joins. A fully normalised schema answers 'show me this order with product names, the customer's name and the delivery city' by joining five tables, and on a read-heavy system that runs thousands of times a minute, those joins are the workload. Denormalization is the deliberate decision to store something in two places because reading it is worth more to you than the risk of the two copies disagreeing.

Notice the word deliberate. Denormalization is a choice made after measuring, with a plan for keeping the copies in step. It is not the same as never having normalised in the first place, even though the resulting table can look identical. The typical cases are a cached aggregate — students.order_count, so a dashboard does not recount every time — or a copied label that saves a join on a very hot query. Both need something that updates the copy: a trigger, a scheduled job, or application code that is disciplined about it.

There is a third case that looks like denormalization and is not, and it is the one students most often get wrong. order_items stores unit_price, even though products already has a price column. That is not a redundant copy. The product's price is what it costs today; the order line's price is what this customer actually paid on that date. They are two different facts that merely happened to be equal at one moment. Take the column away and every historical invoice silently rewrites itself the next time somebody changes a price. Ask yourself whether the value is a copy of a current fact or a record of a past one — copies are redundancy, records are data.

The same reasoning drives the type choices that hold a design together. Money goes in DECIMAL(10,2) and never in FLOAT or DOUBLE, because binary floating point cannot represent 0.1 exactly and a column of rounded errors eventually fails to balance. NOT NULL on everything that is genuinely required saves you from writing defensive checks forever afterwards. And a CHECK constraint states rules like a rating between 1 and 5 in the one place nobody can bypass — with the caveat that MySQL parsed and silently ignored CHECK constraints until version 8.0.16, so on an older server it is decoration.

Example
-- Normalised, and correct: three joins to build one invoice line
SELECT o.id, p.name, oi.quantity, oi.unit_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products    p  ON p.id = oi.product_id
WHERE o.id = 1001;

-- Deliberate denormalization: a counter kept beside the students row
ALTER TABLE students ADD COLUMN order_count INT NOT NULL DEFAULT 0;

-- ...and something that keeps it honest
UPDATE students s
SET order_count = (
    SELECT COUNT(*) FROM orders o WHERE o.student_id = s.id
);

-- NOT denormalization: unit_price records what was actually paid
CREATE TABLE order_items (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    order_id   INT NOT NULL,
    product_id INT NOT NULL,
    quantity   INT NOT NULL DEFAULT 1,
    unit_price DECIMAL(10,2) NOT NULL,   -- the price on the day
    FOREIGN KEY (order_id)   REFERENCES orders(id) ON DELETE CASCADE,
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- Types and constraints that protect the design
CREATE TABLE payments (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    order_id   INT NOT NULL,
    amount     DECIMAL(10,2) NOT NULL,   -- never FLOAT for money
    method     VARCHAR(20)   NOT NULL,
    paid_at    DATETIME      NOT NULL,
    CONSTRAINT chk_amount_positive CHECK (amount > 0),
    FOREIGN KEY (order_id) REFERENCES orders(id)
);
-- MySQL enforces CHECK from 8.0.16 onward. Older versions accept
-- the clause and ignore it, so verify before relying on it.
Notes
  • The order of work is: normalise first, then measure, then denormalise the specific thing that is slow. Starting denormalised to save future joins is how you get a schema nobody can change, because by then the same fact lives in six places and no one is certain which of them is authoritative. It is far easier to add a cached column to a clean design than to untangle a messy one.
Ask AI