Normalization: one fact, one place
- Spot the update, insert and delete anomalies that a fact stored in more than one place causes
- Normalize a table to third normal form in working terms: one value per cell, every column about the whole key, and nothing but the key
- Replace a column that can disagree with another by deriving it — in the query, a view, or a generated column (virtual by default since PostgreSQL 18)
- Tell a deliberate copy (a price at the time of sale, a cached counter) from accidental duplication, and keep a cache honest
- Store instants as
timestamptzand dates asdate, not text, and predict the rows a text comparison or an inclusive date bound silently drops
Read
One fact, one place — what duplication costs
FlagA spreadsheet-shaped orders table, with the customer's email copied onto every order:
| order_id | customer_id | customer_email | total |
|---|---|---|---|
| 1 | 7 | ada@old.io | 30.00 |
| 2 | 7 | ada@old.io | 12.50 |
| 3 | 8 | grace@x.io | 99.00 |
It looks harmless, and reading it is easy. Writing to it is where it breaks — in three ways, traditionally called anomalies:
-
Update anomaly. Ada changes her email. The account page updates the row for her latest order. Now the database holds two emails for customer 7 and no way to tell which is current:
UPDATE orders SET customer_email = 'ada@new.io' WHERE order_id = 2; SELECT DISTINCT customer_email FROM orders WHERE customer_id = 7; -- ada@new.io -- ada@old.io -
Insert anomaly. A customer signs up but hasn't ordered. There's nowhere to store their email without inventing an order.
-
Delete anomaly. Grace cancels her only order and you delete the row. Her email is gone with it.
All three have one cause: a fact about the customer is stored in a table about orders, once per order. The fix is to give each fact one home and point at it:
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (id),
total numeric(10,2) NOT NULL
);
Now an email change is one UPDATE of one row, a customer can exist with no orders, and deleting
an order can't lose a customer. Reading the email next to an order costs a join — that's the
trade normalization makes: a little more work to read, so that a write can't make the data
contradict itself.
That's the whole idea behind the normal forms you'll meet next: they're rules for spotting a fact that's stored in more than one place.
From one wide table to third normal form, step by step
FlagAn import from a shop's old system:
| order_id | ordered_at | customer_id | customer_email | items |
|---|---|---|---|---|
| 1001 | 2026-03-10 | 7 | ada@x.io | PEN x2, PAD x1 |
| 1002 | 2026-03-11 | 7 | ada@x.io | PEN x5 |
The first three normal forms are three questions you ask of every column. In working terms: one value per cell; every column about the whole key; and nothing but the key.
1NF — one value per cell, no repeating groups
items packs a list into one cell. You can't put a foreign key on "PEN", you can't index "how
many pads were sold", and LIKE '%PEN%' also matches PENCIL. The same smell appears as numbered
columns — sku_1, qty_1, sku_2, qty_2 — which cap the list at however many columns someone
guessed. Give each item its own row; the key becomes (order_id, sku):
| order_id | sku | qty | product_name | ordered_at | customer_id | customer_email |
|---|---|---|---|---|---|---|
| 1001 | PEN | 2 | Gel pen | 2026-03-10 | 7 | ada@x.io |
| 1001 | PAD | 1 | A5 pad | 2026-03-10 | 7 | ada@x.io |
| 1002 | PEN | 5 | Gel pen | 2026-03-11 | 7 | ada@x.io |
2NF — every non-key column depends on the whole key
This only bites when the key has more than one column. Go column by column and ask "to know
this, do I need the whole (order_id, sku), or only part of it?"
qty— needs both: how many of this product on this order. Stays.product_name— needs onlysku. It's repeated on every line that sells a pen →products.ordered_at,customer_id,customer_email— need onlyorder_id, and are repeated on every line of the order →orders.
3NF — and nothing but the key
Now look inside orders(id, ordered_at, customer_id, customer_email). customer_email doesn't
depend on the order at all; it depends on customer_id, which isn't the key. That's the
customer's fact stored once per order — exactly the update anomaly from the simple tier.
Move it to customers.
The result
CREATE TABLE customers (
id bigint PRIMARY KEY,
email text NOT NULL UNIQUE
);
CREATE TABLE products (
sku text PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
ordered_at timestamptz NOT NULL,
customer_id bigint NOT NULL REFERENCES customers (id)
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
sku text NOT NULL REFERENCES products (sku),
qty int NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, sku)
);
Every fact now lives in one place: an email in customers, a product name in products, when an
order was placed in orders, and how many of what in order_items. The original wide table is
one query away — a join across the four tables — and can be a view if a report wants that shape.
Microsoft's normalization guide makes the practical point worth keeping: third normal form is the level most applications need, and if you break one of the first three rules on purpose, make sure the application anticipates the redundant data that follows.
Deliberate copies — snapshots, caches, and keeping a cache honest
FlagNot every repeated value is duplication. The test is whether the two values describe the same fact.
A snapshot is a different fact
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (id),
sku text NOT NULL REFERENCES products (sku),
quantity int NOT NULL,
unit_price numeric(10,2) NOT NULL, -- what this customer was charged
PRIMARY KEY (order_id, sku)
);
order_items.unit_price starts out equal to products.price, but they answer different questions:
"what does a pen cost today?" vs "what did order 1001 charge for a pen?". When the catalogue price
changes, last month's invoices must not. "Normalizing" unit_price away by joining to products
would rewrite history. The same goes for a shipping address copied onto an order (the customer may
move), and a tax rate applied at checkout.
Ask: if the source changes later, should this value change too? No → it's a snapshot; store it. Yes → it's a copy; derive it.
A cache is the same fact, stored again on purpose
posts.comment_count is the same fact as SELECT count(*) FROM comments WHERE post_id = …. You
might store it anyway because a feed shows 50 posts with their counts on every page view. That's
a legitimate trade, but you now own the job normalization was doing for you: keeping two copies
in agreement. Three rules keep a cache honest:
-
Update it in the same transaction as the source. If the comment insert commits and the counter update doesn't, they disagree forever. A trigger does this for every writer — including the ones that don't go through your API:
CREATE FUNCTION bump_comment_count() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id; ELSE UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id; END IF; RETURN NULL; END $$; CREATE TRIGGER comments_count AFTER INSERT OR DELETE ON comments FOR EACH ROW EXECUTE FUNCTION bump_comment_count();(A comment that can move between posts also needs an
UPDATEbranch.) -
Change it relative to itself —
comment_count = comment_count + 1, never "read the count in the app, add one, write it back". Two concurrent comments would both read 5 and both write 6. The transactions unit covers why. -
Keep a rebuild. The source is the truth; the cache must be reproducible from it:
UPDATE posts p SET comment_count = (SELECT count(*) FROM comments c WHERE c.post_id = p.id);
When to denormalise at all
Only with a measured read problem that an index or a better query can't fix — and with all three
rules above in place. The cost isn't the extra column; it's every future write path that has to
know about it. A counter that one UPDATE keeps in step is cheap. A customer's email copied into
five tables is five update anomalies waiting for the next feature.
Quiz
orders stores customer_email on every order. Ada changes her email, and the account page updates it on her most recent order only. What has happened?
Course titles and room numbers are stored only on enrollments rows, one copy per enrolled student. Which problems does that cause? (Pick two.)
products.tags is a text column holding values like "red,outdoor,sale". Which normal form does that break, and what's the practical cost?
order_items has PRIMARY KEY (order_id, sku) and columns qty, product_name. What's wrong with product_name here?
orders(id, ordered_at, customer_id, customer_city) — id is the key. Which normal form does customer_city break?
invoices has both is_paid boolean and paid_at timestamptz, and production has rows where they disagree. What's the most robust fix?
You try orders.total numeric GENERATED ALWAYS AS ((SELECT sum(quantity * unit_price) FROM order_items WHERE order_id = id)). What happens?
A reviewer says order_items.unit_price duplicates products.price and should be dropped in favour of a join. Are they right?
posts.comment_count is a deliberate cache. The API inserts a comment and commits, then runs UPDATE posts SET comment_count = $1 with a count it computed in Node. Counts drift. Which change keeps the cache honest?
Fill in the blank
Derive is_paid from paid_at so it can never disagree with it.
CREATE invoices_v AS SELECT id, amount, paid_at, paid_at AS is_paid FROM invoices;
A same-row derived value that you also need to index. In PostgreSQL 18, finish the definition.
Flagline_total numeric(12,2) GENERATED ALWAYS (quantity * unit_price)
Pick column types that make the database understand the values: a due date (no time of day) and the moment a row was created.
Flagdue_on NOT NULL, created_at NOT NULL DEFAULT now()
All rows created from March 1 to March 10 inclusive, correct for any timestamp precision.
FlagWHERE created_at >= '2026-03-01' AND created_at '2026-03-11'
Predict the output
-- created_at is TEXT holding ISO strings: -- '2026-03-09T22:00:00Z', '2026-03-10T09:15:00Z', -- '2026-03-10T23:59:00Z', '2026-03-11T00:01:00Z' SELECT count(*) FROM events WHERE created_at BETWEEN '2026-03-01' AND '2026-03-10';
-- due_on is TEXT holding date-only values: '2026-03-09', '2026-03-10', '2026-03-11'
-- The app passes new Date('2026-03-10').toISOString():
SELECT due_on FROM tasks
WHERE due_on >= '2026-03-10T00:00:00.000Z';SET timezone = 'UTC'; -- created_at is TIMESTAMPTZ now, same four instants: -- 2026-03-09 22:00, 2026-03-10 09:15, 2026-03-10 23:59, 2026-03-11 00:01 (UTC) SELECT count(*) FROM events WHERE created_at <= '2026-03-10';