Guide contents

Lesson 13 of 30

Normalization

Normalization organizes tables so each fact is stored once. A table that repeats facts in many rows allows contradictory data and forces the same change in many places. The rules are built on functional dependencies.

A functional dependency A -> B means each value of A determines exactly one value of B. For example, customer_id -> customer_city.

Key term Meaning
Super key any set of columns that uniquely identifies a row
Candidate key a minimal super key (removing any column breaks uniqueness)
Primary key the candidate key chosen as the identifier
Composite key a key made of more than one column
Prime attribute a column that belongs to some candidate key
Normal form Rule
1NF every column holds one atomic value; no repeating groups; rows are identifiable
2NF 1NF, and no non-key column depends on only part of a composite key
3NF 2NF, and no non-key column depends on another non-key column (no transitive dependency)
BCNF for every dependency A -> B, A is a super key

The demos use demo_ tables, dropped at the end.

Example 1: A wide table with repeated facts

One row per order line, with customer and product details copied into each row.

SQL
CREATE TABLE demo_wide (
    order_id      int,
    product_id    int,
    qty           int,
    customer_id   int,
    customer_name text,
    customer_city text,
    product_name  text,
    product_price numeric(8,2),
    PRIMARY KEY (order_id, product_id)
);

INSERT INTO demo_wide VALUES
    (1, 10, 2, 100, 'Asha',  'Pune',   'Pen',      1.50),
    (1, 20, 1, 100, 'Asha',  'Pune',   'Notebook', 4.00),
    (2, 10, 5, 200, 'Bala',  'Mumbai', 'Pen',      1.50),
    (3, 30, 1, 100, 'Asha',  'Pune',   'Stapler',  8.00);

SELECT * FROM demo_wide ORDER BY order_id, product_id;
Output
 order_id | product_id | qty | customer_id | customer_name | customer_city | product_name | product_price 
----------+------------+-----+-------------+---------------+---------------+--------------+---------------
        1 |         10 |   2 |         100 | Asha          | Pune          | Pen          |          1.50
        1 |         20 |   1 |         100 | Asha          | Pune          | Notebook     |          4.00
        2 |         10 |   5 |         200 | Bala          | Mumbai        | Pen          |          1.50
        3 |         30 |   1 |         100 | Asha          | Pune          | Stapler      |          8.00
(4 rows)

The key is (order_id, product_id). Dependencies in this table:

  • order_id -> customer_id (partial: depends on part of the key)
  • product_id -> product_name, product_price (partial)
  • customer_id -> customer_name, customer_city (transitive: a non-key column determines other non-key columns)

Example 2: Update anomaly

Asha moves to Delhi. Updating only one row leaves the data contradicting itself.

SQL
UPDATE demo_wide SET customer_city = 'Delhi' WHERE order_id = 1 AND product_id = 10;

SELECT DISTINCT customer_id, customer_name, customer_city
FROM demo_wide
WHERE customer_id = 100
ORDER BY customer_city;
Output
 customer_id | customer_name | customer_city 
-------------+---------------+---------------
         100 | Asha          | Delhi
         100 | Asha          | Pune
(2 rows)

Customer 100 now has two cities. The correct update touches every row of the customer, and the number of rows grows with each order.

SQL
UPDATE demo_wide SET customer_city = 'Delhi' WHERE customer_id = 100;

Example 3: Insert anomaly

A new product cannot be stored until an order contains it, because order_id is part of the primary key and cannot be NULL.

SQL
INSERT INTO demo_wide (product_id, product_name, product_price) VALUES (40, 'Ruler', 0.75);
Output
ERROR:  null value in column "order_id" of relation "demo_wide" violates not-null constraint
DETAIL:  Failing row contains (null, 40, null, null, null, null, Ruler, 0.75).

Example 4: Delete anomaly

Deleting order 2 removes the only row that mentions customer Bala.

SQL
DELETE FROM demo_wide WHERE order_id = 2;

SELECT count(*) AS rows_for_bala FROM demo_wide WHERE customer_name = 'Bala';
Output
 rows_for_bala 
---------------
             0
(1 row)

The customer record is lost along with the order.

Example 5: Checking a functional dependency

A -> B holds in the data when no value of A maps to more than one value of B. This query lists the violations, and an empty result means the dependency holds (for the current rows).

SQL
SELECT customer_id, count(DISTINCT customer_city) AS cities
FROM demo_wide
GROUP BY customer_id
HAVING count(DISTINCT customer_city) > 1;
Output
 customer_id | cities 
-------------+--------
(0 rows)

Data can only disprove a dependency. A dependency is a rule of the domain, so it is decided from the business rules and then enforced with keys.

Example 6: 1NF, atomic values

A column holding a list breaks 1NF. Searching, joining, and constraining the items becomes hard.

SQL
CREATE TABLE demo_orders_bad (order_id int PRIMARY KEY, products text);
INSERT INTO demo_orders_bad VALUES (1, 'Pen,Notebook'), (2, 'Pen');

SELECT order_id
FROM demo_orders_bad
WHERE products LIKE '%Pen%';
Output
 order_id 
----------
        1
        2
(2 rows)

LIKE '%Pen%' would also match Pencil. The 1NF form uses one row per item.

SQL
CREATE TABLE demo_order_items (
    order_id int,
    product  text,
    PRIMARY KEY (order_id, product)
);
INSERT INTO demo_order_items VALUES (1, 'Pen'), (1, 'Notebook'), (2, 'Pen');

SELECT order_id FROM demo_order_items WHERE product = 'Pen' ORDER BY order_id;
Output
 order_id 
----------
        1
        2
(2 rows)

Example 7: 2NF, 3NF, and BCNF

2NF removes partial dependencies. In demo_wide, product_name depends only on product_id, so product facts move to a products table.

3NF removes transitive dependencies. customer_city depends on customer_id, which depends on order_id, so customer facts move to a customers table. A common summary: every non-key column depends on the key, the whole key, and nothing but the key.

BCNF is stricter than 3NF: every determinant must be a super key. A table breaks BCNF but not 3NF only when a non-key column determines a column that belongs to a candidate key. Example: a table (student, course, teacher) where each teacher teaches one course (teacher -> course) and a student has one teacher per course, so (student, course) is a key. teacher is not a key, so the table violates BCNF. The fix splits it into (teacher, course) and (student, teacher).

SQL
CREATE TABLE demo_enroll (
    student text,
    course  text,
    teacher text,
    PRIMARY KEY (student, course)
);
INSERT INTO demo_enroll VALUES
    ('Isha', 'SQL', 'Rao'), ('Jay', 'SQL', 'Rao'), ('Isha', 'Python', 'Sen');

CREATE TABLE demo_teacher_course (teacher text PRIMARY KEY, course text NOT NULL);
CREATE TABLE demo_student_teacher (
    student text,
    teacher text REFERENCES demo_teacher_course(teacher),
    PRIMARY KEY (student, teacher)
);

INSERT INTO demo_teacher_course SELECT DISTINCT teacher, course FROM demo_enroll;
INSERT INTO demo_student_teacher SELECT student, teacher FROM demo_enroll;

SELECT st.student, tc.course, tc.teacher
FROM demo_student_teacher st
JOIN demo_teacher_course tc ON tc.teacher = st.teacher
ORDER BY st.student, tc.course;
Output
 student | course | teacher 
---------+--------+---------
 Isha    | Python | Sen
 Isha    | SQL    | Rao
 Jay     | SQL    | Rao
(3 rows)

The join reproduces the original three rows, and the rule teacher -> course is now enforced by the primary key of demo_teacher_course. BCNF decomposition can lose the ability to enforce some dependencies with keys alone, which is why 3NF is the usual practical target.

Example 8: Decompose the wide table and join it back

Rebuild demo_wide from its original data, split it into four normalized tables, and join to confirm no information is lost.

SQL
TRUNCATE demo_wide;
INSERT INTO demo_wide VALUES
    (1, 10, 2, 100, 'Asha',  'Pune',   'Pen',      1.50),
    (1, 20, 1, 100, 'Asha',  'Pune',   'Notebook', 4.00),
    (2, 10, 5, 200, 'Bala',  'Mumbai', 'Pen',      1.50),
    (3, 30, 1, 100, 'Asha',  'Pune',   'Stapler',  8.00);

CREATE TABLE demo_customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL);
CREATE TABLE demo_products  (id int PRIMARY KEY, name text NOT NULL, price numeric(8,2) NOT NULL);
CREATE TABLE demo_orders    (id int PRIMARY KEY, customer_id int NOT NULL REFERENCES demo_customers(id));
CREATE TABLE demo_lines (
    order_id   int REFERENCES demo_orders(id),
    product_id int REFERENCES demo_products(id),
    qty        int NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

INSERT INTO demo_customers SELECT DISTINCT customer_id, customer_name, customer_city FROM demo_wide;
INSERT INTO demo_products  SELECT DISTINCT product_id, product_name, product_price FROM demo_wide;
INSERT INTO demo_orders    SELECT DISTINCT order_id, customer_id FROM demo_wide;
INSERT INTO demo_lines     SELECT order_id, product_id, qty FROM demo_wide;

SELECT l.order_id, c.name AS customer, c.city, p.name AS product, l.qty, p.price
FROM demo_lines l
JOIN demo_orders o    ON o.id = l.order_id
JOIN demo_customers c ON c.id = o.customer_id
JOIN demo_products p  ON p.id = l.product_id
ORDER BY l.order_id, l.product_id;
Output
 order_id | customer |  city  | product  | qty | price 
----------+----------+--------+----------+-----+-------
        1 | Asha     | Pune   | Pen      |   2 |  1.50
        1 | Asha     | Pune   | Notebook |   1 |  4.00
        2 | Bala     | Mumbai | Pen      |   5 |  1.50
        3 | Asha     | Pune   | Stapler  |   1 |  8.00
(4 rows)

A lossless check compares the join against the original table. Zero rows in both directions means identical contents.

SQL
SELECT count(*) AS differences FROM (
    (SELECT l.order_id, l.product_id, l.qty, o.customer_id, c.name, c.city, p.name, p.price
     FROM demo_lines l
     JOIN demo_orders o ON o.id = l.order_id
     JOIN demo_customers c ON c.id = o.customer_id
     JOIN demo_products p ON p.id = l.product_id
     EXCEPT SELECT * FROM demo_wide)
    UNION ALL
    (SELECT * FROM demo_wide
     EXCEPT
     SELECT l.order_id, l.product_id, l.qty, o.customer_id, c.name, c.city, p.name, p.price
     FROM demo_lines l
     JOIN demo_orders o ON o.id = l.order_id
     JOIN demo_customers c ON c.id = o.customer_id
     JOIN demo_products p ON p.id = l.product_id)
) d;
Output
 differences 
-------------
           0
(1 row)

The anomalies are gone. A city change is one row, and a product can exist without an order (product 40 has no lines).

SQL
UPDATE demo_customers SET city = 'Delhi' WHERE id = 100;
INSERT INTO demo_products VALUES (40, 'Ruler', 0.75);

SELECT id, name, city FROM demo_customers ORDER BY id;
Output
 id  | name |  city  
-----+------+--------
 100 | Asha | Delhi
 200 | Bala | Mumbai
(2 rows)

Example 9: Denormalizing for reads

Denormalization deliberately stores redundant data to avoid joins or aggregation at read time. It fits read-heavy workloads where the cost of a join or a COUNT on every request is too high. The cost is extra writes and the risk of the copies drifting apart.

Typical forms: a summary column (orders.total), a precomputed count (customers.order_count), or a copied value that is expensive to look up.

SQL
ALTER TABLE demo_customers ADD COLUMN order_count int NOT NULL DEFAULT 0;

UPDATE demo_customers c
SET order_count = (SELECT count(*) FROM demo_orders o WHERE o.customer_id = c.id);

SELECT id, name, order_count FROM demo_customers ORDER BY id;
Output
 id  | name | order_count 
-----+------+-------------
 100 | Asha |           2
 200 | Bala |           1
(2 rows)

Something must keep order_count correct on every insert and delete: application code, a trigger (see Views and Routines), or a periodic rebuild. Normalize first, then denormalize measured hot spots.

Example 10: Star schema versus normalized OLTP

Normalized OLTP Star schema (analytics)
Purpose many small reads and writes large scans and aggregates
Shape many narrow tables, 3NF one central fact table plus wide dimension tables
Redundancy minimal dimensions repeat attributes (city, category)
Joins many, deep few, one hop from fact to each dimension
Writes cheap, one place per fact batch loads

The fact table holds measurements (quantity, amount) and foreign keys to dimensions (date, product, customer).

SQL
CREATE TABLE demo_dim_product (product_key int PRIMARY KEY, name text, category text);
CREATE TABLE demo_dim_customer (customer_key int PRIMARY KEY, name text, city text, region text);
CREATE TABLE demo_fact_sales (
    sale_date    date,
    product_key  int REFERENCES demo_dim_product,
    customer_key int REFERENCES demo_dim_customer,
    quantity     int,
    amount       numeric(10,2)
);

INSERT INTO demo_dim_product VALUES (1, 'Pen', 'Stationery'), (2, 'Stapler', 'Office');
INSERT INTO demo_dim_customer VALUES (100, 'Asha', 'Pune', 'West'), (200, 'Bala', 'Mumbai', 'West');
INSERT INTO demo_fact_sales VALUES
    ('2026-01-05', 1, 100, 2, 3.00),
    ('2026-01-06', 2, 100, 1, 8.00),
    ('2026-02-01', 1, 200, 5, 7.50);

SELECT p.category, c.region, sum(f.amount) AS revenue
FROM demo_fact_sales f
JOIN demo_dim_product p  ON p.product_key = f.product_key
JOIN demo_dim_customer c ON c.customer_key = f.customer_key
GROUP BY p.category, c.region
ORDER BY p.category;
Output
  category  | region | revenue 
------------+--------+---------
 Office     | West   |    8.00
 Stationery | West   |   10.50
(2 rows)

Cleanup:

SQL
DROP TABLE demo_fact_sales, demo_dim_product, demo_dim_customer,
           demo_lines, demo_orders, demo_products, demo_customers, demo_wide,
           demo_orders_bad, demo_order_items,
           demo_student_teacher, demo_teacher_course, demo_enroll;

Interview points

  • Define normalization: remove redundancy by placing each fact in one table, guided by functional dependencies.
  • The three anomalies: update (inconsistent copies), insert (cannot record a fact without an unrelated one), delete (losing one fact deletes another).
  • 1NF: atomic values. 2NF: no partial dependency on a composite key. 3NF: no transitive dependency. BCNF: every determinant is a super key.
  • A table with a single-column primary key and 1NF is automatically in 2NF.
  • 3NF and BCNF differ only when a table has overlapping composite candidate keys.
  • Candidate key versus primary key: several candidate keys can exist; one is chosen as primary.
  • Denormalize for read performance after measuring; the price is write cost and consistency risk.
  • OLTP favors normalization; data warehouses favor star schemas.
  • A lossless decomposition must join back to exactly the original rows.

Summary

  • Normalization stores each fact once, based on functional dependencies, to prevent update, insert, and delete anomalies.
  • 1NF requires atomic values, 2NF removes partial key dependencies, 3NF removes transitive ones, and BCNF requires every determinant to be a super key.
  • Decomposition is correct when joining the parts returns the original rows.
  • Denormalize selectively (summary columns, precomputed counts) and keep the copies in sync.
  • Star schemas trade redundancy for fast analytic scans; normalized schemas suit transactional workloads.