Skip to main content

First Normal Form: Atomic Values

Level 3: Level 3: Data Modeling & Schema Designmedium25 min1NFatomic columnsrepeating-group removalcomposite key introduction

Eliminate repeating groups and multi-valued cells.

Why atomic values are the floor for every query

Relational operations (JOIN, SUM, GROUP BY, WHERE) and the indexes that speed them up all work on whole column values. The moment one cell hides several facts, those operations stop working. You cannot SUM a quantity trapped inside the string 'mouse:2, keyboard:1', and you cannot JOIN on a product that is buried next to another product in the same row. First Normal Form (1NF) is the rule that keeps every value reachable: one value per cell, and one row per fact.

What 1NF requires

Two conditions:

  • Every cell holds a single, atomic value. No comma-packed lists, no key:value blobs.
  • There are no repeating groups. A table must not store the same kind of fact in parallel columns like product_a, product_b, product_c.

Both failures are the same mistake in different clothes: a one-to-many relationship (one order has many line items) crammed into one row. 1NF fixes it by moving the "many" side into its own rows. More rows, never more columns.

The grain of the table changes when you fix it. raw_order has one row per order. The 1NF table has one row per line item, so order_id alone now repeats and can no longer identify a row. You declare a composite key (order_id, product): the pair is unique even though neither column is on its own.

Unpacking parallel slots

raw_order stores two slots per order:

order_idproduct_aqty_aproduct_bqty_b
1mouse2keyboard1
2mouse1NULLNULL

Unpack one slot per INSERT ... SELECT, and skip empty slots so they do not become phantom rows:

INSERT INTO order_item (order_id, product, qty)
SELECT order_id, product_a, qty_a FROM raw_order;

INSERT INTO order_item (order_id, product, qty)
SELECT order_id, product_b, qty_b FROM raw_order
WHERE product_b IS NOT NULL;

Result (one row per line item):

order_idproductqty
1mouse2
2mouse1
1keyboard1

Row order in a table is not meaningful; only ORDER BY at read time defines it.

Pitfall: the empty second slot

Order 2 has no slot B, so its product_b and qty_b are NULL. Without WHERE product_b IS NOT NULL, the second INSERT would add a row like (2, NULL, NULL): a line item for nothing. That row is meaningless, and a NULL in a key column defeats the composite key you are trying to build. Filtering empty slots is not optional cleanup; it is what keeps the grain honest.

The mirror pitfall is "fixing" a multi-valued column by adding more slot columns. That is still a repeating group, and it hard-caps you at however many slots you defined. The answer is always more rows.

Interview nuance: "atomic" is defined relative to how the domain queries the value, not by whether the string looks divisible. A full name in one name column is fine until you need to filter or join by last name, at which point it is a 1NF violation for that domain. A DATE is atomic even though it contains a year, month, and day, because you query it as one value. Interviewers probe this: atomicity is a modeling decision about access patterns, not a syntactic property of the data.

Apply

Your turn

The task this lesson builds to.

The raw_order table stores two line-item slots per order in packed columns (product_a/qty_a and product_b/qty_b). Unpack it into a 1NF order_item table with columns (order_id, product, qty) and a composite primary key (order_id, product): one row per line item. Use two INSERT … SELECT statements (one per slot); skip the empty second slot with a WHERE product_b IS NOT NULL. You're given exactly two slots to keep the SQL simple.

3 hints and 5 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

A "sales spreadsheet" export stores up to three line-item slots per order in packed columns (product_1/qty_1/price_1, product_2/qty_2/price_2, product_3/qty_3/price_3). Unpack raw_sales into a 1NF order_line table (order_id, product, qty, unit_price_cents) with a composite primary key (order_id, product): one row per line item. Use three INSERT … SELECT statements (one per slot); skip the empty slots with a WHERE product_N IS NOT NULL. The slot columns are already INTEGER, so qty and price_N carry straight into unit_price_cents: no parsing, no CAST.

4 hints and 6 automated checks are waiting in the workspace.