Database Design and Normalization

Good database design prevents data duplication, ensures consistency, and makes your app maintainable. Normalization is the process of organizing data efficiently.

Normal Forms

First Normal Form (1NF)

Each cell contains a single value. No repeating groups.

SQL
-- ❌ NOT 1NF: Repeating group
CREATE TABLE orders_bad (
    id SERIAL PRIMARY KEY,
    customer_name VARCHAR(100),
    products TEXT,           -- "Laptop,Mouse,Keyboard" — bad!
    amounts TEXT             -- "999,29,79" — bad!
);

-- ✅ 1NF: Each value in its own row
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_name VARCHAR(100),
    product VARCHAR(100),
    amount DECIMAL(10,2)
);

Second Normal Form (2NF)

No partial dependencies — every non-key column depends on the entire primary key.

SQL
-- ❌ NOT 2NF: customer_name depends only on customer_id, not order_id
CREATE TABLE order_items_bad (
    order_id INTEGER,
    product_id INTEGER,
    customer_id INTEGER,
    customer_name VARCHAR(100),   -- Depends on customer_id, not (order_id, product_id)
    quantity INTEGER,
    PRIMARY KEY (order_id, product_id)
);

-- ✅ 2NF: Separate tables
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    order_date DATE
);

CREATE TABLE order_items (
    order_id INTEGER REFERENCES orders(id),
    product_id INTEGER REFERENCES products(id),
    quantity INTEGER,
    PRIMARY KEY (order_id, product_id)
);

Third Normal Form (3NF)

No transitive dependencies — non-key columns don't depend on each other.

SQL
-- ❌ NOT 3NF: city and country depend on state, not directly on user_id
CREATE TABLE users_bad (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    state VARCHAR(50),
    city VARCHAR(50),        -- Depends on state
    country VARCHAR(50)      -- Depends on state
);

-- ✅ 3NF: Separate tables
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    location_id INTEGER REFERENCES locations(id)
);

CREATE TABLE locations (
    id SERIAL PRIMARY KEY,
    state VARCHAR(50),
    city VARCHAR(50),
    country VARCHAR(50)
);

Practical Design: E-Commerce

SQL
-- Customers
CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(200) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Products
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    description TEXT,
    price DECIMAL(10, 2) NOT NULL CHECK (price > 0),
    stock INTEGER DEFAULT 0 CHECK (stock >= 0),
    category_id INTEGER REFERENCES categories(id),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Categories
CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,
    parent_id INTEGER REFERENCES categories(id)
);

-- Orders
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending'
        CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled')),
    total DECIMAL(10, 2),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    shipped_at TIMESTAMP
);

-- Order Items
CREATE TABLE order_items (
    order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER REFERENCES products(id),
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    unit_price DECIMAL(10, 2) NOT NULL,  -- Price at time of order
    PRIMARY KEY (order_id, product_id)
);

-- Reviews
CREATE TABLE reviews (
    id SERIAL PRIMARY KEY,
    product_id INTEGER REFERENCES products(id),
    customer_id INTEGER REFERENCES customers(id),
    rating INTEGER CHECK (rating BETWEEN 1 AND 5),
    comment TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(product_id, customer_id)  -- One review per customer per product
);

Common Design Patterns

SQL
-- Audit log (track all changes)
CREATE TABLE audit_log (
    id SERIAL PRIMARY KEY,
    table_name VARCHAR(50),
    record_id INTEGER,
    action VARCHAR(10),  -- INSERT, UPDATE, DELETE
    old_data JSONB,
    new_data JSONB,
    changed_by INTEGER,
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Tags (many-to-many)
CREATE TABLE tags (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) UNIQUE
);

CREATE TABLE product_tags (
    product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
    tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (product_id, tag_id)
);

-- Soft delete (keep records, mark as deleted)
ALTER TABLE products ADD COLUMN deleted_at TIMESTAMP NULL;
-- Filter with: WHERE deleted_at IS NULL

Constraints

SQL
-- Primary Key
id SERIAL PRIMARY KEY

-- Foreign Key with actions
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT

-- Unique
email VARCHAR(200) UNIQUE

-- Check
CHECK (age >= 0 AND age <= 150)
CHECK (status IN ('active', 'inactive', 'banned'))

-- Not Null
name VARCHAR(100) NOT NULL

-- Default
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
is_active BOOLEAN DEFAULT true

💡 Tip: Start with 3NF, then denormalize only when profiling shows a specific query needs it. Premature denormalization creates data consistency nightmares.

Next: Advanced SQL Techniques