What Is Normalization?#

Normalization is the process of organizing database tables to reduce redundancy and prevent anomalies (insertion, update, and deletion anomalies). Each normal form builds on the previous one.

Why Normalize?#

ProblemWithout NormalizationWith Normalization
Update anomalyChange department name in 500 employee rowsChange it in 1 row in departments table
Insertion anomalyCan’t add a department with no employeesDepartment exists independently
Deletion anomalyDeleting last employee removes department infoDepartment persists regardless
StorageRepeated data wastes spaceEach fact stored once
ConsistencySame data might disagree across rowsSingle source of truth

Normal Forms#

First Normal Form (1NF)#

Rule: Every column contains only atomic (indivisible) values. No repeating groups or arrays.

❌ Violates 1NF:

idnamephone_numbers
1Alice555-0001, 555-0002
2Bob555-0003

✅ Satisfies 1NF:

idnamephone_number
1Alice555-0001
1Alice555-0002
2Bob555-0003

Or better — separate table:

CREATE TABLE contacts (id SERIAL PRIMARY KEY, name VARCHAR(100));
CREATE TABLE phone_numbers (
    id SERIAL PRIMARY KEY,
    contact_id INTEGER REFERENCES contacts(id),
    phone VARCHAR(20)
);

Second Normal Form (2NF)#

Rule: 1NF + every non-key column depends on the entire primary key (no partial dependencies). Only relevant for composite keys.

❌ Violates 2NF (composite key: student_id + course_id):

student_idcourse_idstudent_namegrade
1CS101AliceA
1MA201AliceB

student_name depends only on student_id, not the full composite key.

✅ Satisfies 2NF:

CREATE TABLE students (id INTEGER PRIMARY KEY, name VARCHAR(100));
CREATE TABLE enrollments (
    student_id INTEGER REFERENCES students(id),
    course_id VARCHAR(10) REFERENCES courses(id),
    grade CHAR(2),
    PRIMARY KEY (student_id, course_id)
);

Third Normal Form (3NF)#

Rule: 2NF + no transitive dependencies — non-key columns must depend directly on the primary key, not on other non-key columns.

❌ Violates 3NF:

employee_idnamedepartment_iddepartment_namedept_building

department_name and dept_building depend on department_id, not directly on employee_id.

✅ Satisfies 3NF:

CREATE TABLE departments (
    id INTEGER PRIMARY KEY,
    name VARCHAR(100),
    building VARCHAR(100)
);
CREATE TABLE employees (
    id INTEGER PRIMARY KEY,
    name VARCHAR(100),
    department_id INTEGER REFERENCES departments(id)
);

Boyce-Codd Normal Form (BCNF)#

Rule: 3NF + every determinant is a candidate key. Handles edge cases where 3NF still allows anomalies with overlapping candidate keys.

In practice, if your tables satisfy 3NF, they almost always satisfy BCNF too.


Quick Reference#

Normal FormEliminatesSimple Rule
1NFRepeating groupsAtomic values only
2NFPartial dependenciesNo column depends on part of a composite key
3NFTransitive dependenciesNon-key columns depend only on the PK
BCNFNon-candidate-key determinantsEvery determinant is a candidate key

Denormalization#

Sometimes you intentionally violate normalization for read performance. This is a deliberate tradeoff, not a mistake.

When to Denormalize#

ScenarioDenormalization Strategy
Dashboard queries joining 10+ tablesPre-computed summary table
Read-heavy, write-rare dataDuplicate frequently-read fields
Full-text search requirementsSeparate search-optimized table
Reporting over timeSnapshot tables (store computed values at a point in time)

Common Techniques#

-- Store computed total on order (avoid recalculating from line items)
ALTER TABLE orders ADD COLUMN total_amount NUMERIC(12,2);

-- Store latest status on parent (avoid joining to status history)
ALTER TABLE projects ADD COLUMN current_status VARCHAR(20);

-- Counter cache (avoid COUNT queries)
ALTER TABLE posts ADD COLUMN comment_count INTEGER DEFAULT 0;

Rules:

  • Always normalize first, denormalize for measured performance problems
  • Document why the denormalization exists
  • Maintain consistency (triggers, application logic, or accept eventual consistency)

Schema Design Process#

From Requirements to Tables#

flowchart TD
    REQ["Business Requirements"] --> ENT["Identify Entities"]
    ENT --> ATTR["Define Attributes"]
    ATTR --> REL["Identify Relationships"]
    REL --> NORM["Normalize (3NF)"]
    NORM --> IDX["Add Indexes"]
    IDX --> DENORM["Denormalize<br/>(if measured need)"]

Entity-Relationship Modeling#

  1. Identify entities — nouns in requirements (Customer, Order, Product)
  2. Identify attributes — properties of each entity
  3. Identify relationships — how entities connect (1:1, 1:N, M:N)
  4. Determine cardinality — “one customer has many orders”
  5. Define keys — PK for each entity, FKs for relationships

Example: E-Commerce Schema#

CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(200) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(300) NOT NULL,
    price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
    stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    category_id INTEGER REFERENCES categories(id)
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    status VARCHAR(20) NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
    total NUMERIC(12, 2) NOT NULL,
    ordered_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER NOT NULL REFERENCES products(id),
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(10, 2) NOT NULL,  -- Snapshot price at time of order
    UNIQUE (order_id, product_id)
);

CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    parent_id INTEGER REFERENCES categories(id)  -- Self-referencing for hierarchy
);

Common Design Patterns#

Soft Deletes#

ALTER TABLE employees ADD COLUMN deleted_at TIMESTAMP;
-- "Delete": UPDATE employees SET deleted_at = NOW() WHERE id = 5;
-- Query active: SELECT * FROM employees WHERE deleted_at IS NULL;

Audit Trail#

CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    record_id INTEGER NOT NULL,
    action VARCHAR(10) NOT NULL,  -- INSERT, UPDATE, DELETE
    old_values JSONB,
    new_values JSONB,
    changed_by INTEGER REFERENCES users(id),
    changed_at TIMESTAMP DEFAULT NOW()
);

Polymorphic Associations#

-- Option 1: Separate FKs (type-safe but sparse)
CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    body TEXT NOT NULL,
    post_id INTEGER REFERENCES posts(id),
    photo_id INTEGER REFERENCES photos(id),
    CHECK (
        (post_id IS NOT NULL AND photo_id IS NULL) OR
        (post_id IS NULL AND photo_id IS NOT NULL)
    )
);

-- Option 2: Type + ID (flexible but no FK constraint)
CREATE TABLE comments (
    id SERIAL PRIMARY KEY,
    body TEXT NOT NULL,
    commentable_type VARCHAR(50) NOT NULL,  -- 'post', 'photo'
    commentable_id INTEGER NOT NULL
);

Key Takeaways#

  1. Normalize to 3NF by default — it prevents data anomalies and is sufficient for most applications
  2. Denormalize deliberately — only for measured performance needs, never out of laziness
  3. Store each fact once — redundancy leads to inconsistency
  4. Design from the domain — identify entities and relationships from business language
  5. Snapshot mutable values — store the price at time of order, not a reference to the current price
  6. Use constraints — let the database enforce business rules (CHECK, UNIQUE, FK)