Normalization Basics
7 examples to get you started with Normalization - 5 basic and 2 intermediate.
Search across all documentation pages
7 examples to get you started with Normalization - 5 basic and 2 intermediate.
CREATE TABLE, PRIMARY KEY, and FOREIGN KEY.Duplicate data forces multi-row updates when a fact changes.
CREATE TABLE order_lines_bad (
order_id integer,
customer_email text,
customer_city text,
product_sku text,
product_name text,
quantity integer,
PRIMARY KEY (order_id, product_sku)
);
-- Customer moves cities: must update every historical row
UPDATE order_lines_bad SET customer_city = 'Portland' WHERE customer_email = 'a@example.com';customer_city on every line creates an update anomaly.Related: 3NF & BCNF - formal normal forms
Move repeating customer attributes to a parent table.
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
city text NOT NULL
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (customer_id)
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id),
product_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);ON DELETE.Related: Entity-Relationship Modeling - FK placement
You cannot add a catalog product until someone orders it in a denormalized design.
CREATE TABLE products (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL
);
-- Catalog team can INSERT products before any order exists
INSERT INTO products (sku, name) VALUES ('WIDGET-1', 'Widget');order_items references products when a sale happens.UNIQUE on sku enforces catalog identity.Related: Intentional Denormalization - when to break rules deliberately
Deleting the last order for a product must not delete the product definition.
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products (product_id) ON DELETE RESTRICT,
quantity integer NOT NULL,
PRIMARY KEY (order_id, product_id)
);ON DELETE RESTRICT on product_id prevents removing products still referenced.ON DELETE CASCADE on order_id removes line items when an order is cancelled.If sku -> product_name, product_name belongs with sku, not on every fact row.
CREATE TABLE products (
product_id bigint PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL
);
-- name is determined by sku (and product_id), not by order_idA -> B means B belongs in the same table as A.quantity), not descriptive duplicates.City determined by zip code should not sit on the customer if zip determines city.
CREATE TABLE zip_codes (
zip text PRIMARY KEY,
city text NOT NULL,
state text NOT NULL
);
CREATE TABLE customers (
customer_id bigint PRIMARY KEY,
email text NOT NULL UNIQUE,
zip text NOT NULL REFERENCES zip_codes (zip)
);zip -> city is transitive when both sat on customers with only customer_id as key.zip_codes holds location attributes once.zip on customers.Related: 3NF & BCNF - transitive dependencies
Find contradictory duplicates before they reach production reports.
SELECT customer_email, COUNT(DISTINCT customer_city) AS city_variants
FROM order_lines_bad
GROUP BY customer_email
HAVING COUNT(DISTINCT customer_city) > 1;Related: Denormalization Best Practices - safe denorm rules
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026