Database Normalization Explained
A customer changes their email address, support updates it, and a week later the invoices are still going to the old one. You dig in and find the email stored in three places: the customers table, a column on orders, and a JSON blob someone added for the mailer. Only one of them got updated. That's the kind of bug database normalization exists to prevent: each fact lives in one place, so there's only one place to change it.
This post goes through the normal forms from 1NF up to 5NF using small tables and the SQL to fix each one. It also covers functional dependencies (the idea everything else is built on) and when it's reasonable to denormalize on purpose.
- The three data anomalies and why they happen
- Functional dependencies and how to spot them
- 1NF, 2NF, 3NF, BCNF, 4NF and 5NF, each with a broken table and the fix
- Denormalization: when it pays off and what it costs
- A step-by-step workflow for designing a schema
Why Normalize? Understanding Data Anomalies
Normal forms make more sense once you've seen what goes wrong without them. A badly structured table runs into three kinds of data anomalies, and each one leaves you with data that's missing, contradictory, or quietly gone.
Insert, update and delete anomalies in an unnormalized table
Here's a typical orders table where everything got put in one place:
Unnormalized Orders Table (Problematic)
| OrderID | CustomerName | CustomerEmail | CustomerCity | ProductName | ProductPrice | Quantity |
|---|---|---|---|---|---|---|
| 1001 | John Smith | john@email.com | New York | Laptop | $999 | 1 |
| 1002 | John Smith | john@email.com | New York | Mouse | $29 | 2 |
| 1003 | Jane Doe | jane@email.com | Los Angeles | Laptop | $999 | 1 |
Insert Anomaly
There's no way to add a product to the catalog until someone orders it. Want to list a "Keyboard" at $79? With this table, you'd need a fake order first.
Update Anomaly
If John Smith moves to Boston, every one of his rows has to change. Miss one and the database now says he lives in both New York and Boston, with nothing to tell you which is right.
Delete Anomaly
Delete Jane's order (1003) and Jane Doe disappears as a customer too. If that had been the only Laptop order, the product's price would go with it.
Understanding Functional Dependencies
Every normal form is defined in terms of functional dependencies, so it's worth getting comfortable with them first. If knowing the value of one column (or set of columns) tells you exactly the value of another, the second depends on the first.
Functional Dependency Notation
X → Y means "X functionally determines Y" or "Y is functionally dependent on X"
StudentID → StudentName: a student ID gives you the student's nameISBN → BookTitle, Author: an ISBN gives you the book's title and authorEmployeeID → DepartmentID → DepartmentName: a transitive dependency (the name comes through the department, not the employee)
Full, partial and transitive dependencies: 2NF removes the partial ones, 3NF the transitive ones
1NF First Normal Form
Every other normal form assumes you've already got this one. A table is in 1NF when it follows these rules:
Atomic Values
Each cell holds one value. No comma-separated lists, sets, or nested data.
Unique Columns
Each column has a unique name with data of the same type.
No Repeating Groups
No repeating groups of columns like Product1, Product2, Product3.
Primary Key
Each row is uniquely identifiable by a primary key.
Violating 1NF: Multi-valued Attributes
NOT in 1NF: multiple values in cells
| StudentID | Name | Courses | PhoneNumbers |
|---|---|---|---|
| S101 | Alice | Math, Physics, Chemistry | 555-1234, 555-5678 |
| S102 | Bob | History, English | 555-9012 |
Converting to 1NF
-- Option 1: Separate rows for each value
CREATE TABLE StudentCourses (
StudentID VARCHAR(10),
Name VARCHAR(100),
Course VARCHAR(50),
PRIMARY KEY (StudentID, Course)
);
INSERT INTO StudentCourses VALUES
('S101', 'Alice', 'Math'),
('S101', 'Alice', 'Physics'),
('S101', 'Alice', 'Chemistry'),
('S102', 'Bob', 'History'),
('S102', 'Bob', 'English');
-- Option 2: Separate table for phone numbers
CREATE TABLE StudentPhones (
StudentID VARCHAR(10),
PhoneNumber VARCHAR(20),
PRIMARY KEY (StudentID, PhoneNumber),
FOREIGN KEY (StudentID) REFERENCES Students(StudentID)
);
The comma-separated column is the 1NF violation I run into most in real code, usually because it was the quickest way to ship a "tags" field. The pain shows up in queries. WHERE Courses LIKE '%Math%' can't use a normal index, so it scans the whole table, and it also matches "Applied Mathematics". MySQL's FIND_IN_SET() gets the matching right but still can't use an index, and removing one value means string surgery inside an UPDATE. Once you need to filter or join on the values, move them into their own table.
2NF Second Normal Form
A table is in Second Normal Form when it:
- Is already in 1NF
- Has no partial dependencies, meaning every non-key column depends on the entire primary key, not just part of it
2NF only applies to tables with composite primary keys (keys made of multiple columns). If your table has a single-column primary key and is in 1NF, it's already in 2NF. (Strictly, this applies to every candidate key, so watch for a table with an auto-increment id plus a unique pair of columns.)
2NF Violation Example
NOT in 2NF: partial dependency
| StudentID | CourseID | StudentName | CourseName | InstructorName | Grade |
|---|---|---|---|---|---|
| S101 | CS101 | Alice | Databases | Dr. Smith | A |
| S101 | CS102 | Alice | Algorithms | Dr. Jones | B+ |
Partial Dependencies:
• StudentID → StudentName (doesn't need CourseID)
• CourseID → CourseName, InstructorName (doesn't need StudentID)
• Only Grade depends on the full key {StudentID, CourseID}
Converting to 2NF
-- Students table (StudentID → StudentName)
CREATE TABLE Students (
StudentID VARCHAR(10) PRIMARY KEY,
StudentName VARCHAR(100)
);
-- Courses table (CourseID → CourseName, InstructorName)
CREATE TABLE Courses (
CourseID VARCHAR(10) PRIMARY KEY,
CourseName VARCHAR(100),
InstructorName VARCHAR(100)
);
-- Enrollments table (full key dependency)
CREATE TABLE Enrollments (
StudentID VARCHAR(10),
CourseID VARCHAR(10),
Grade VARCHAR(5),
PRIMARY KEY (StudentID, CourseID),
FOREIGN KEY (StudentID) REFERENCES Students(StudentID),
FOREIGN KEY (CourseID) REFERENCES Courses(CourseID)
);
3NF Third Normal Form
3NF goes after transitive dependencies: columns that depend on the key only by way of some other column. A table is in 3NF when:
- It's already in 2NF
- No non-key attribute depends on another non-key attribute
3NF Violation Example
NOT in 3NF: transitive dependency
| EmployeeID | EmployeeName | DepartmentID | DepartmentName | DepartmentLocation |
|---|---|---|---|---|
| E001 | Alice | D10 | Engineering | Building A |
| E002 | Bob | D10 | Engineering | Building A |
| E003 | Carol | D20 | Marketing | Building B |
Problem: DepartmentName and DepartmentLocation depend on DepartmentID, not EmployeeID
Converting to 3NF
-- Employees table (no transitive dependencies)
CREATE TABLE Employees (
EmployeeID VARCHAR(10) PRIMARY KEY,
EmployeeName VARCHAR(100),
DepartmentID VARCHAR(10),
FOREIGN KEY (DepartmentID) REFERENCES Departments(DepartmentID)
);
-- Departments table (contains department-specific data)
CREATE TABLE Departments (
DepartmentID VARCHAR(10) PRIMARY KEY,
DepartmentName VARCHAR(100),
DepartmentLocation VARCHAR(100)
);
-- Now querying with a JOIN:
SELECT
e.EmployeeID,
e.EmployeeName,
d.DepartmentName,
d.DepartmentLocation
FROM Employees e
JOIN Departments d ON e.DepartmentID = d.DepartmentID;
If you paste the script above into MySQL as written, it fails with ERROR 1824: Failed to open the referenced table 'Departments', because Employees points at a table that doesn't exist yet. Create the parent table first. The same thing bites during migrations: a foreign key can only be added once the referenced table and column exist, and in MySQL the column types have to match too (an INT pointing at a BIGINT UNSIGNED gives you error 3780).
Once you split things this way you'll write more JOINs. That's the normal trade, and with indexed foreign keys it's rarely the bottleneck people expect.
BCNF Boyce-Codd Normal Form
BCNF (sometimes called 3.5NF) closes a small gap that 3NF leaves open. A table is in BCNF when:
- It's in 3NF
- For every functional dependency X → Y, X must be a superkey
3NF still allows a dependency X → Y where X isn't a superkey, as long as Y is part of some candidate key. BCNF drops that exception: every determinant has to be a complete superkey. In practice you only hit the difference when a table has overlapping candidate keys, like the example below.
BCNF Violation Example
Consider a university scheduling system:
In 3NF but NOT BCNF
| Student | Subject | Professor |
|---|---|---|
| Alice | Physics | Dr. Einstein |
| Bob | Physics | Dr. Einstein |
| Alice | Math | Dr. Newton |
Rules: Each professor teaches only one subject. Each subject can have multiple professors.
Dependencies: {Student, Subject} → Professor AND Professor → Subject
BCNF Violation: Professor → Subject, but Professor is not a superkey
Converting to BCNF
-- Professor teaches one subject
CREATE TABLE ProfessorSubject (
Professor VARCHAR(100) PRIMARY KEY,
Subject VARCHAR(100)
);
-- Student enrolled with professor
CREATE TABLE StudentProfessor (
Student VARCHAR(100),
Professor VARCHAR(100),
PRIMARY KEY (Student, Professor),
FOREIGN KEY (Professor) REFERENCES ProfessorSubject(Professor)
);
One catch: this decomposition loses the rule that a student has only one professor per subject. Neither table can enforce it on its own, so you'd need application logic or a trigger. That's a known BCNF trade-off, and it's one reason some teams stop at 3NF for tables like this.
4NF Fourth Normal Form
4NF is about multi-valued dependencies: two independent lists crammed into the same table. A table is in 4NF when:
- It's in BCNF
- It has no multi-valued dependencies (or the multi-valued dependency is on a superkey)
Multi-valued Dependency (MVD)
X →→ Y means "X multi-determines Y"
For a given X value, there's a set of Y values that are independent of other attributes. Example: An employee can have multiple skills AND multiple languages, but skills and languages are independent of each other.
4NF Violation Example
NOT in 4NF: independent multi-valued facts
| EmployeeID | Skill | Language |
|---|---|---|
| E001 | Python | English |
| E001 | Python | Spanish |
| E001 | Java | English |
| E001 | Java | Spanish |
Problem: Skills and Languages have nothing to do with each other, yet every combination has to be stored. Add a third language and you need two more rows.
MVDs: EmployeeID →→ Skill AND EmployeeID →→ Language
-- Separate independent multi-valued facts
CREATE TABLE EmployeeSkills (
EmployeeID VARCHAR(10),
Skill VARCHAR(50),
PRIMARY KEY (EmployeeID, Skill)
);
CREATE TABLE EmployeeLanguages (
EmployeeID VARCHAR(10),
Language VARCHAR(50),
PRIMARY KEY (EmployeeID, Language)
);
-- 2 + 2 rows here vs 2 x 2 combined; the gap grows with more skills or languages
INSERT INTO EmployeeSkills VALUES
('E001', 'Python'), ('E001', 'Java');
INSERT INTO EmployeeLanguages VALUES
('E001', 'English'), ('E001', 'Spanish');
5NF Fifth Normal Form
5NF (also called Project-Join Normal Form, or PJNF) deals with join dependencies. A table is in 5NF when:
- It's in 4NF
- It cannot be decomposed into smaller tables without losing information (no join dependencies except those implied by candidate keys)
You'll rarely see a 5NF violation in an application database. They show up in three-way relationships (say, which supplier sells which part to which project) where a rule lets you rebuild the combinations from the pairwise tables. Most schemas are fine at BCNF or 4NF.
Normal Forms Comparison
| Normal Form | Requirements | Eliminates | Common Use |
|---|---|---|---|
| 1NF | Atomic values, unique rows | Repeating groups | Always required |
| 2NF | 1NF + no partial dependencies | Partial key dependencies | Composite keys |
| 3NF | 2NF + no transitive dependencies | Non-key → non-key deps | Most applications |
| BCNF | 3NF + every determinant is a superkey | Remaining anomalies | Critical data |
| 4NF | BCNF + no multi-valued dependencies | Independent MVDs | Complex relationships |
| 5NF | 4NF + no join dependencies | Lossless decomposition issues | Rare edge cases |
Denormalization: When to Break the Rules
Normalization protects integrity, but there are cases where copying data on purpose (denormalization) is the right call for performance. These are the usual ones:
Read-Heavy Workloads
When reads vastly outnumber writes, duplicating data can eliminate expensive JOINs.
Reporting/Analytics
Data warehouses often denormalize for faster aggregations and simpler queries.
Caching Data
Storing a computed value (an order total, a comment count) saves recalculating it on every page load.
NoSQL Patterns
Document databases often embed related data for single-query access.
-- Normalized: Calculate total on every query
SELECT
o.OrderID,
SUM(oi.Quantity * oi.UnitPrice) AS Total
FROM Orders o
JOIN OrderItems oi ON o.OrderID = oi.OrderID
GROUP BY o.OrderID;
-- Denormalized: Store calculated total
CREATE TABLE Orders (
OrderID INT PRIMARY KEY,
CustomerID INT,
OrderDate DATETIME,
TotalAmount DECIMAL(10,2) -- Cached/denormalized
);
-- Update the cached total when items change
CREATE TRIGGER UpdateOrderTotal
AFTER INSERT ON OrderItems
FOR EACH ROW
BEGIN
UPDATE Orders
SET TotalAmount = (
SELECT SUM(Quantity * UnitPrice)
FROM OrderItems
WHERE OrderID = NEW.OrderID
)
WHERE OrderID = NEW.OrderID;
END;
- Pro: Faster reads and simpler queries
- Con: Redundant data, more work on every write, and a real chance of the copies drifting apart
- Rule: Start normalized, and denormalize only when you've measured a performance problem
Notice the trigger above only fires AFTER INSERT. Update a quantity or delete a line item and TotalAmount is wrong, with no error anywhere. If you cache a value like this, you need matching AFTER UPDATE and AFTER DELETE triggers (or do it in one place in application code), plus a periodic job that recomputes totals and flags mismatches.
Practical Normalization Workflow
When I'm designing a schema from scratch, the process looks roughly like this:
Identify All Attributes
List every piece of data you need to store. Don't worry about structure yet.
Determine Functional Dependencies
For each attribute, ask: "What uniquely determines this value?"
Identify Candidate Keys
Find minimal sets of attributes that uniquely identify each row.
Apply Normal Forms Sequentially
Work through 1NF → 2NF → 3NF → BCNF, creating new tables as needed.
Define Relationships & Constraints
Add foreign keys, indexes, and constraints to enforce referential integrity.
Consider Denormalization (If Needed)
Once you have real queries and real data, denormalize the specific paths that are slow. Not before.
How far I'd normalize (and when I wouldn't)
Where I usually land
- 3NF for transactional tables. Users, orders, invoices, anything that gets updated. This is where the anomalies above actually cost you.
- BCNF only when the overlapping keys are real. If going to BCNF means losing a constraint you care about (like the professor example), 3NF plus a unique index is often the better deal.
- 4NF and 5NF almost never on purpose. If you model each independent list as its own table, you usually get 4NF for free.
Where I wouldn't normalize: reporting tables and data warehouses, where data is loaded in batches and rarely updated, so update anomalies aren't really a risk and fewer JOINs make queries simpler. Event logs and audit tables, where you want a snapshot of the customer's address as it was at the time, not a foreign key to whatever it is today. And order line items, which should copy the product price at checkout. Pointing at products.price means old invoices change when you raise prices.
The mistake I see most often isn't under- or over-normalizing. It's denormalizing without a plan for keeping the copies in sync. If you copy a value, decide up front what updates it (a trigger, a model event, a queued job) and how you'd find out it drifted. And whichever way you go, index your foreign key columns. PostgreSQL doesn't create those indexes for you, and unindexed foreign keys are a common reason a normalized schema feels slow.