Database Normalization Explained: 1NF, 2NF, 3NF in Plain English
Normalization means storing each fact once, so updates can't leave your data contradicting itself. What 1NF, 2NF and 3NF actually require, with one example table taken through each step, the anomalies they prevent, and when deliberately denormalizing is the right call.
Normalization sounds academic, but the idea is practical: store each fact in exactly one place. When the same fact lives in several rows, sooner or later one copy gets updated and the others don't, and your data starts contradicting itself.
AI-generated schemas often get this wrong in both directions — a single giant table, or a dozen tables for something simple. Knowing the normal forms helps you spot both.
The starting point: one big table
An orders spreadsheet, turned into a table:
| order_id | customer_name | customer_email | products | product_prices | order_date |
|---|---|---|---|---|---|
| 1 | Ada | ada@ex.com | Mug, Poster | 12, 18 | 2026-09-01 |
| 2 | Ada | ada@ex.com | T-shirt | 25 | 2026-09-03 |
| 3 | Grace | grace@ex.com | Mug | 12 | 2026-09-04 |
It works — until it doesn't:
- Update anomaly: Ada changes her email. You must update every one of her orders; miss one and she has two emails.
- Insert anomaly: you can't add a product to the catalogue until someone orders it.
- Delete anomaly: delete Grace's only order and you lose the fact that the Mug costs 12 — and that Grace exists.
Normalization fixes these step by step.
First normal form (1NF): one value per cell
Rule: every column holds a single, atomic value; no lists in a cell; no repeating groups (product1, product2, product3 columns).
products = "Mug, Poster" breaks this. You can't easily query "all orders containing a Mug" or sum prices. Split into one row per order item:
| order_id | customer_email | customer_name | product | price | order_date |
|---|---|---|---|---|---|
| 1 | ada@ex.com | Ada | Mug | 12 | 2026-09-01 |
| 1 | ada@ex.com | Ada | Poster | 18 | 2026-09-01 |
| 2 | ada@ex.com | Ada | T-shirt | 25 | 2026-09-03 |
Now each row is identified by (order_id, product).
Second normal form (2NF): depend on the whole key
Rule: be in 1NF, and every non-key column must depend on the entire primary key, not just part of it.
The key is (order_id, product). But order_date and customer_email depend only on order_id. price depends only on product. They're repeated in every item row. Split:
orders: order_id, customer_email, customer_name, order_date
products: product, price
order_items: order_id, product, quantity
Third normal form (3NF): nothing depends on a non-key
Rule: be in 2NF, and non-key columns must depend only on the key — not on another non-key column.
In orders, customer_name depends on customer_email (the customer), not on the order. That's a transitive dependency. Split customers out:
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
price_cents integer NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
product_id bigint NOT NULL REFERENCES products(id),
quantity integer NOT NULL CHECK (quantity > 0),
unit_price_cents integer NOT NULL,
PRIMARY KEY (order_id, product_id)
);
Now Ada's email lives in one row. Products exist without orders. Deleting an order deletes only the order. (Primary key vs foreign key, SQL joins)
The classic summary: every non-key column depends on the key, the whole key, and nothing but the key.
Wait — why is unit_price_cents in order_items?
That looks like duplication of products.price_cents. It isn't: it records the price at the time of the order. When the Mug's price changes next month, old orders must still show what the customer paid. Historical facts are different facts. Recognising this is the difference between normalizing mechanically and modelling correctly.
Beyond 3NF
BCNF, 4NF and 5NF handle rarer cases (overlapping candidate keys, independent multi-valued facts). For most application schemas, 3NF is the practical target.
When to denormalize on purpose
Normalized data is consistent; reading it sometimes needs joins and aggregation. Deliberate denormalization is fine when you know why:
- Read performance — store
order_totalorcomment_countto avoid summing on every page load. Keep it correct with transactions or triggers, and be ready to recompute it. - Snapshots — prices, addresses, names as they were at a point in time.
- Reporting tables — copies shaped for analytics, rebuilt from the source of truth.
- JSON for genuinely variable attributes — product specs that differ by category. (Postgres JSONB)
Normalize first; denormalize specific things when measurements say you need to. (Database indexes usually fix slow joins first.)
The summary
- Normalization = each fact stored once, preventing update, insert and delete anomalies.
- 1NF: single values, no lists in cells.
- 2NF: no columns depending on part of a composite key.
- 3NF: no columns depending on other non-key columns.
- Snapshots of historical values aren't duplication; denormalize deliberately for reads.
EasySpawn servers come with PostgreSQL and daily backups, so you (or Claude Code) can design, migrate and test a properly normalized schema against a real database. See how it works or join the waitlist.
Related: How to Design Your First Database · Primary Key vs Foreign Key · SQL Joins Explained · Database Indexes
Keep reading
Optimistic vs Pessimistic Locking: Preventing Lost Updates
Two users edit the same record and one silently overwrites the other. Pessimistic locking (SELECT FOR UPDATE) blocks the second writer; optimistic locking (a version column) detects the conflict. How each works, code for both, atomic updates that avoid locks entirely, and how to choose.
UUID vs Auto-Increment IDs: Which Primary Key Should You Use?
Sequential integers or UUIDs for your primary keys? The real trade-offs — size, index performance, guessability, merging data, leaking business metrics — why UUIDv7 changes the answer, Postgres 18's uuidv7(), and the common hybrid of internal IDs plus public IDs.