3NF & BCNF
Practical normalization without academic overkill: decompose tables until non-key attributes depend on the whole key, nothing but the key.
Search across all documentation pages
Practical normalization without academic overkill: decompose tables until non-key attributes depend on the whole key, nothing but the key.
Quick-reference recipe card - copy-paste ready.
-- Violates 3NF: city depends on zip, not on customer_id alone
-- customers(customer_id, email, zip, city)
-- 3NF fix: remove transitive dependency zip -> city
CREATE TABLE zip_codes (zip text PRIMARY KEY, city text NOT NULL);
CREATE TABLE customers (
customer_id bigint PRIMARY KEY,
email text NOT NULL UNIQUE,
zip text NOT NULL REFERENCES zip_codes (zip)
);
-- BCNF fix: when determinant is not a superkey
-- enrollments(student_id, course_id, instructor_id) with rule course_id -> instructor_id
CREATE TABLE course_instructors (
course_id text PRIMARY KEY,
instructor_id text NOT NULL
);
CREATE TABLE enrollments (
student_id text NOT NULL,
course_id text NOT NULL REFERENCES course_instructors (course_id),
PRIMARY KEY (student_id, course_id)
);When to reach for this:
BEGIN;
-- Starting point: not 3NF
CREATE TABLE employee_assignments_bad (
employee_id text,
department_id text,
department_name text,
department_head text,
PRIMARY KEY (employee_id, department_id)
);
-- 3NF decomposition
CREATE TABLE departments (
department_id text PRIMARY KEY,
department_name text NOT NULL,
department_head text NOT NULL
);
CREATE TABLE employee_departments (
employee_id text NOT NULL,
department_id text NOT NULL REFERENCES departments (department_id),
PRIMARY KEY (employee_id, department_id)
);
INSERT INTO departments (department_id, department_name, department_head)
VALUES ('D1', 'Engineering', 'Alex');
INSERT INTO employee_departments (employee_id, department_id)
VALUES ('E100', 'D1');
-- Verify: department attributes appear once
SELECT department_id, COUNT(*) FROM departments GROUP BY 1;
COMMIT;What this demonstrates:
department_name depends on department_id, not on (employee_id, department_id) - transitive dependency removed.departments) separated from facts (employee_departments).| Form | Rule (practical) | Typical fix |
|---|---|---|
| 1NF | Atomic columns, no repeating groups | Split tag1,tag2 into rows |
| 2NF | No partial dependency on composite key | Move product name off (order_id, product_id) if it depends only on product_id |
| 3NF | No transitive dependency on non-key attrs | zip_codes lookup table |
| BCNF | Every determinant is a candidate key | Decompose course_id -> instructor_id |
X -> Y means two rows with the same X must have the same Y.X -> Y, X must be a superkey.-- Find duplicate determinants with conflicting values (3NF smell)
SELECT product_id, COUNT(DISTINCT product_name) AS names
FROM order_items_bad
GROUP BY product_id
HAVING COUNT(DISTINCT product_name) > 1;(order_id, line_no) tables. Fix: ask what each non-key column depends on.sku alone fails in multi-tenant SaaS. Fix: (tenant_id, sku) composite or surrogate plus scoped UNIQUE.zip on customers but no zip_codes table. Fix: add reference table or CHECK against known set.| Alternative | Use When | Don't Use When |
|---|---|---|
| Stop at 2NF | Simple schemas, no transitive deps visible | Location attrs duplicated on facts |
| Denormalize for reads | Proven hot join path | Before normalization is understood |
jsonb for sparse attrs | Rare optional fields | Deterministic reporting on those fields |
| Data warehouse star schema | Analytics | OLTP write path |
No. Most production OLTP tables reach 3NF and stop. Apply BCNF when you discover non-key determinants causing update anomalies.
On composite key (A, B), column C depends only on B. Move C to a table keyed by B.
key -> X -> Y. Column Y should not stay on the original table if X is not a superkey.
Include tenant_id in keys and dependencies. UNIQUE (tenant_id, sku) not global UNIQUE (sku) unless SKUs are global.
They help invariants but do not remove update anomalies. You still update many rows when duplicated facts change.
Surrogate keys hide composite business keys. You still must decompose transitive dependencies among non-key columns.
Load to staging, SELECT DISTINCT candidate keys, create dimension tables, then insert facts with FKs.
More joins can cost reads; index FK columns and measure. Denormalize selectively after evidence, not preemptively.
Add valid_from / valid_to on dimension tables. Normalization still applies; history tables are not an excuse for wide duplicates.
Keep OLTP 3NF; project denormalized shapes into materialized views for read-heavy dashboards.
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