Second Normal Form (2NF)

The reporting team adds product names and prices to the 1NF table, order_lines. The key is still the pair (order_id, product_id); every other column is a non-key column.

order_idproduct_idorder_datecustomer_idproduct_nameunit_priceqty
1001P72024-03-0242Desk Lamp34.001
1001P22024-03-0242USB Cable9.003
1002P72024-03-0517Desk Lamp34.002
1003P92024-03-0642Felt Pad4.5010
1004P22024-03-0942USB Cable9.001

What depends on what

Column B depends on column A when each value of A goes with exactly 1 value of B. Product P7 always means Desk Lamp at 34.00; order 1001 always means March 2 and customer 42. But order 1001 has 2 lines with different quantities, so qty needs both halves of the key. Writing down what each column depends on is the whole technique.

ColumnDetermined byPortion of the key
order_dateorder_idhalf
customer_idorder_idhalf
product_nameproduct_idhalf
unit_priceproduct_idhalf
qtyorder_id and product_idwhole

Of the 5 non-key columns, 4 depend on half the key: a partial dependency, and the mechanism behind section 0's duplication. order_date is copied once per line on the order, product_name and unit_price once per order containing the product. Raise the Desk Lamp's price and 2 rows must change; add P12 with no orders and there is no row to put it on.

Give each half of the key its own table

Second normal form says: in a table with a multi-column key, every non-key column depends on the whole key. Columns that depend on order_id alone move to a table keyed by order_id. Columns that depend on product_id alone move to a table keyed by product_id. What depends on both stays where it is.

Schema
  • orders: order_id BIGINT PK, order_date DATE, customer_id BIGINT
  • order_lines: order_id BIGINT PK, product_id VARCHAR PK, qty INTEGER
  • products: product_id VARCHAR PK, product_name VARCHAR, unit_price DECIMAL(10,2)
  • order_lines.order_id references orders.order_id (N:1)
  • order_lines.product_id references products.product_id (N:1)

order_lines keeps both halves of the old key and nothing that depended on only 1 of them. customer_id moved with order_date, since the order alone determines which customer placed it. Against 5 order lines, orders holds 4 rows and products holds 3, so each date and each price appears once.

Each product's price now lives on 1 row. Repricing the Desk Lamp is a single-row change that every order line picks up through the join. P12 gets a row in products with no order attached. Deleting order 1003 removes 1 row from orders and 1 from order_lines, and P9 stays in products. The cost is that a report which read 1 table now reads 3.

SELECT
  o.order_id,
  o.order_date,
  SUM(l.qty * p.unit_price) AS order_total
FROM order_lines AS l
JOIN orders AS o
  ON o.order_id = l.order_id
JOIN products AS p
  ON p.product_id = l.product_id
GROUP BY
  o.order_id,
  o.order_date
order_idorder_dateorder_total
10012024-03-0261.00
10022024-03-0568.00
10032024-03-0645.00
10042024-03-099.00

The join reads the price from products once per line, so a price that changes tomorrow changes every total computed tomorrow.

If the store needs the price the customer actually paid, that's a fact about the line, since the catalog price can change after the sale. It belongs on order_lines as sale_price, next to qty. The catalog price and sale_price can match today and still be 2 different facts.

An exam_results table has the key (student_id, course_id) and columns student_name, course_title and score. Which columns break second normal form?

About this lesson

This section is part of the Normalization lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.