Third Normal Form (3NF)
Third Normal Form (3NF) builds on 2NF by removing a subtler kind of redundancy: transitive dependencies. A table is in 3NF when it is in 2NF and every non-key column depends only on the primary key, directly — not on another non-key column. If a non-key column's value can be derived from a different non-key column instead of from the key itself, that is a transitive dependency, and it is a sign that the derived information belongs in a table of its own.
A table that violates 3NF
Consider a customers table that stores each customer's address, including zip_code and city:
Violates 3NF: city is transitively dependent on zip_code
CREATE TABLE customers ( customer_id INT PRIMARY KEY, customer_name VARCHAR(100), zip_code VARCHAR(10), city VARCHAR(100) ); INSERT INTO customers (customer_id, customer_name, zip_code, city) VALUES (1, 'Alice Nguyen', '94103', 'San Francisco'), (2, 'Bob Smith', '10001', 'New York'), (3, 'Carol Diaz', '94103', 'San Francisco'), (4, 'Dan Lee', '94103', 'San Franciso');
The primary key is customer_id, and both zip_code and city depend on it — every customer has exactly one zip code and one city. But city does not depend on customer_id directly; it depends on zip_code, which in turn depends on customer_id. That chain — key → zip_code → city — is a transitive dependency. The data above already shows the damage: zip code 94103 is paired with "San Francisco" for two customers and with the misspelled "San Franciso" for a third. Nothing in the schema stops that inconsistency, because the relationship between a zip code and its city is never stated as a rule — it is just repeated, by hand, in every row.
Fixing it: a lookup table for the transitive fact
The fix is to recognize that "which city does this zip code belong to" is a fact about zip codes, not about customers, and give it its own table.
3NF-compliant: city lives with zip_code, not with the customer
CREATE TABLE zip_codes (
zip_code VARCHAR(10) PRIMARY KEY,
city VARCHAR(100) NOT NULL
);
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
customer_name VARCHAR(100),
zip_code VARCHAR(10),
FOREIGN KEY (zip_code) REFERENCES zip_codes(zip_code)
);
INSERT INTO zip_codes (zip_code, city) VALUES
('94103', 'San Francisco'),
('10001', 'New York');
INSERT INTO customers (customer_id, customer_name, zip_code) VALUES
(1, 'Alice Nguyen', '94103'),
(2, 'Bob Smith', '10001'),
(3, 'Carol Diaz', '94103'),
(4, 'Dan Lee', '94103');Now "94103 is San Francisco" is stated exactly once. It is no longer possible for two rows to disagree about which city a given zip code belongs to, and looking up the city for any customer is a simple join:
City is derived through the relationship, not duplicated
SELECT c.customer_name, z.city FROM customers c JOIN zip_codes z ON z.zip_code = c.zip_code;
Dependency | Type | 3NF verdict |
|---|---|---|
customer_id → zip_code | Direct (key to non-key) | Fine — stays in customers |
zip_code → city | Direct, but zip_code is not the primary key | Fine — belongs in its own table keyed by zip_code |
customer_id → city (via zip_code) | Transitive | Violation — must not be stored directly in customers |
A table must be in 2NF before 3NF is meaningful.
A transitive dependency exists when a non-key column depends on another non-key column rather than on the key itself.
The fix is to move the transitively-dependent fact into a lookup table keyed by the column it actually depends on.
3NF is the normal form most real-world schemas target in practice.