Databases & SQL

Normalization — designing a schema that doesn't corrupt itself

The joins lesson showed splitting customer data into its own table to avoid repetition. Normalization is that same instinct, made systematic — a set of concrete rules for deciding what belongs in which table, and why getting it wrong causes real data corruption, not just wasted space.

Intermediate

5 min read

The problem: repeated data that can go out of sync

-- One big table — customer info repeated on every order
orders: id | customer_name | customer_email | product | amount
1      | Ada            | ada@old.com     | Widget  | 50
2      | Ada            | ada@new.com     | Gadget  | 75    <- same customer, different email!

If a customer's email is stored redundantly on every one of their orders, updating it means finding and updating every row — miss one, and the same real-world customer now has two different emails on file, with no way to know which one is actually current. This isn't a hypothetical edge case; it's what inevitably happens once the same fact is stored in more than one place and something updates only one of the copies. Normalization is a systematic set of rules for structuring tables so this specific kind of corruption becomes structurally impossible, not just something to be careful about.

First Normal Form (1NF): one value per cell

-- Violates 1NF — multiple phone numbers crammed into one column
customers: id | name | phone_numbers
1          | Ada  | "555-1234, 555-5678"
 
-- Satisfies 1NF — one phone number per row, in its own table
customer_phones: customer_id | phone_number
1                | 555-1234
1                | 555-5678

1NF requires every column to hold a single, atomic value — not a list, not a comma-separated string standing in for multiple values. The comma-separated version above looks like it "works" (it fits in one column), but it breaks basic SQL operations: WHERE phone_number = '555-1234' can't reliably find this row without fragile string-matching, and there's no way to enforce "phone numbers must be unique" at the database level (from the constraints lesson) on values buried inside a larger string. Splitting each phone number into its own row is what actually lets ordinary SQL — filtering, joining, constraints — work correctly on this data.

Second Normal Form (2NF): every column depends on the whole key

-- Violates 2NF — product_name depends only on product_id, not on the full
-- (order_id, product_id) key this table is actually keyed by
order_items: order_id | product_id | product_name | quantity
 
-- Satisfies 2NF — product_name moved to its own table, keyed by product_id alone
products:    product_id | product_name
order_items: order_id | product_id | quantity

2NF applies specifically to tables with a composite key (more than one column together forming the primary key) — every non-key column must depend on the entire key, not just part of it. product_name only actually depends on product_id, not on the combination of order_id and product_id together, which is the sign it belongs in its own products table instead. Left as-is, the same product_name gets repeated on every order line for that product — the exact same "repeated data, 1000 places it could go stale" problem 1NF and the joins lesson's customer-splitting example both address, just showing up specifically when a table has a composite key.

Third Normal Form (3NF): no column depends on another non-key column

-- Violates 3NF — zip_code determines city, but city is stored redundantly
-- alongside zip_code instead of being derived from it
customers: id | name | zip_code | city
 
-- Satisfies 3NF — city is looked up from zip_code via its own table, not duplicated
zip_codes: zip_code | city
customers: id | name | zip_code

3NF catches a subtler case: city isn't determined by the table's key (id) — it's determined by zip_code, another regular column. Storing both zip_code and city directly on customers means they can drift apart (a typo, a stale value) even though city is fully implied by zip_code and should never need to be entered independently. The fix is the same instinct as before: if one column's value is fully determined by another non-key column, that relationship belongs in its own table.

Put together, these rules produce a schema where each fact lives in exactly one place:

Why this all matters: anomalies, not just tidiness

Update anomaly:  updating a customer's email requires finding every order row that has it
Insert anomaly:  can't add a new product until someone places an order for it
                 (if product info only lives inside order_items)
Delete anomaly:  deleting a customer's only order accidentally deletes their contact info too

Every normalization rule exists to prevent one of these three concrete failure modes — not as academic tidiness, but because each one is a real way a poorly-structured schema actively corrupts or loses data as an application runs. A normalized schema makes each of these structurally impossible: updating a customer's email touches exactly one row, adding a new product doesn't require an order to exist first, and deleting an order never accidentally deletes contact information stored nowhere else.

When denormalization is a deliberate, reasonable trade

-- Deliberately storing a redundant total, instead of always recomputing it live
orders: id | customer_id | cached_total   -- computed once at order time, stored directly

Normalization isn't a rule to follow to the maximum extreme in every case — a fully normalized schema sometimes requires more JOINs to answer common questions, which has a real performance cost, especially for read-heavy reporting queries run constantly against rarely-changing data. Denormalization — deliberately storing some redundant or precomputed data to avoid recomputing it on every read — is a legitimate, conscious trade of some update-anomaly risk for read performance, exactly the same kind of speed-for-guarantee trade the caching lesson and the hash-map-pattern lesson both make elsewhere. The difference between "bad, unintentional repetition" and "deliberate, reasoned denormalization" is whether the trade-off was actually considered, not whether any redundancy exists at all.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is normalization about more than tidiness — what real problem does it prevent?

2. Why does packing multiple phone numbers into one comma-separated column violate 1NF and cause real problems?

3. Why does product_name belong in its own products table rather than order_items, given order_items is keyed by (order_id, product_id)?

4. Is storing a precomputed cached_total on an orders table always a normalization violation to avoid?