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 NULLConstraints
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