Database Design & Normalization
Build efficient, maintainable schemas
Why Database Design Matters
Bad database design creates headaches: duplicate data, update anomalies, slow queries, and data inconsistencies. Good design prevents these problems from the start. Normalization is the process of organizing data to reduce redundancy and improve integrity. Learn these principles now, and you'll build databases that scale gracefully and remain maintainable for years.
The Problem: A Poorly Designed Table
Let's start with a bad design to understand what we're trying to fix.
Everything in one table:
orders (BAD DESIGN): ┌────┬─────────────┬──────────────────┬──────────────┬────────┬────────┐ │ id │ customer │ customer_email │ product_name │ price │ qty │ ├────┼─────────────┼──────────────────┼──────────────┼────────┼────────┤ │ 1 │ Alice Smith │ alice@email.com │ Laptop │ 999.99 │ 1 │ │ 2 │ Alice Smith │ alice@email.com │ Mouse │ 29.99 │ 2 │ │ 3 │ Bob Jones │ bob@email.com │ Laptop │ 999.99 │ 1 │ │ 4 │ Alice Smith │ alice@email.com │ Keyboard │ 79.99 │ 1 │ └────┴─────────────┴──────────────────┴──────────────┴────────┴────────┘
Problems with This Design
- Data Redundancy: Alice's name and email repeated 3 times
- Update Anomaly: If Alice changes email, must update 3 rows
- Insert Anomaly: Can't add a customer without an order
- Delete Anomaly: Delete Bob's order = lose Bob's info
- Wasted Space: Same data stored multiple times
What is Normalization?
Normalization is splitting data into multiple related tables to eliminate redundancy. There are different "normal forms" (1NF, 2NF, 3NF, BCNF), each solving specific problems.
The Goal
Each piece of information should be stored once and only once, in the most logical place. Related data is then connected through foreign keys.
First Normal Form (1NF): Atomic Values
Rule: Each cell must contain a single, indivisible value. No lists, no multiple values in one field. Problem solved: Repeating groups, non-unique data.
Violates 1NF
Multiple phone numbers in one cell.
customers (BAD): ┌────┬────────┬──────────────────────────┐ │ id │ name │ phone_numbers │ ├────┼────────┼──────────────────────────┤ │ 1 │ Alice │ 555-1234, 555-5678 │ ← Multiple values! │ 2 │ Bob │ 555-9999 │ └────┴────────┴──────────────────────────┘
Fixed: 1NF Compliant
Separate row for each phone number.
customers: ┌────┬────────┐ │ id │ name │ ├────┼────────┤ │ 1 │ Alice │ │ 2 │ Bob │ └────┴────────┘ customer_phones: ┌────┬─────────────┬──────────────┐ │ id │ customer_id │ phone_number │ ├────┼─────────────┼──────────────┤ │ 1 │ 1 │ 555-1234 │ │ 2 │ 1 │ 555-5678 │ │ 3 │ 2 │ 555-9999 │ └────┴─────────────┴──────────────┘
Now each cell contains exactly one value.
Second Normal Form (2NF): No Partial Dependencies
Rule: Must be in 1NF, and all non-key columns must depend on the entire primary key (not just part of it). Applies to composite keys. Problem solved: Data redundancy in composite keys.
Violates 2NF
Product price depends only on product_id, not the full key.
order_items (BAD): Primary Key: (order_id, product_id) ┌──────────┬────────────┬──────────────┬────────┬─────┐ │ order_id │ product_id │ product_name │ price │ qty │ ├──────────┼────────────┼──────────────┼────────┼─────┤ │ 1 │ 10 │ Laptop │ 999.99 │ 1 │ │ 1 │ 20 │ Mouse │ 29.99 │ 2 │ │ 2 │ 10 │ Laptop │ 999.99 │ 1 │ └──────────┴────────────┴──────────────┴────────┴─────┘ Problem: product_name and price only depend on product_id, not on the full composite key (order_id + product_id)
Fixed: 2NF Compliant
Separate product details into their own table.
products: ┌────────────┬──────────────┬────────┐ │ product_id │ product_name │ price │ ├────────────┼──────────────┼────────┤ │ 10 │ Laptop │ 999.99 │ │ 20 │ Mouse │ 29.99 │ └────────────┴──────────────┴────────┘ order_items: ┌──────────┬────────────┬─────┐ │ order_id │ product_id │ qty │ ├──────────┼────────────┼─────┤ │ 1 │ 10 │ 1 │ │ 1 │ 20 │ 2 │ │ 2 │ 10 │ 1 │ └──────────┴────────────┴─────┘
Now product info is stored once, referenced by foreign key.
Third Normal Form (3NF): No Transitive Dependencies
Rule: Must be in 2NF, and non-key columns must depend directly on the primary key, not on other non-key columns. Problem solved: Data dependency on non-key attributes.
Violates 3NF
Department location depends on department, not directly on employee_id.
employees (BAD): ┌─────────────┬────────┬─────────────┬───────────────────┐ │ employee_id │ name │ department │ dept_location │ ├─────────────┼────────┼─────────────┼───────────────────┤ │ 1 │ Alice │ Engineering │ Building A │ │ 2 │ Bob │ Engineering │ Building A │ │ 3 │ Charlie│ Sales │ Building B │ └─────────────┴────────┴─────────────┴───────────────────┘ Problem: dept_location depends on department, not directly on employee_id (transitive dependency)
Fixed: 3NF Compliant
Department details in separate table.
departments: ┌────────────────┬─────────────┬──────────┐ │ department_id │ dept_name │ location │ ├────────────────┼─────────────┼──────────┤ │ 1 │ Engineering │ Bldg A │ │ 2 │ Sales │ Bldg B │ └────────────────┴─────────────┴──────────┘ employees: ┌─────────────┬─────────┬───────────────┐ │ employee_id │ name │ department_id │ ├─────────────┼─────────┼───────────────┤ │ 1 │ Alice │ 1 │ │ 2 │ Bob │ 1 │ │ 3 │ Charlie │ 2 │ └─────────────┴─────────┴───────────────┘
If Engineering moves to Building C, update one row, not two.
Boyce-Codd Normal Form (BCNF): Stricter 3NF
Rule: Every determinant must be a candidate key. BCNF is slightly stricter than 3NF and handles edge cases where 3NF isn't enough. Problem solved: Anomalies from multiple overlapping keys.
Violates BCNF (but satisfies 3NF)
A professor teaches one subject, but a subject can be taught by multiple professors. For simplicity using `professor` but should be `professor_id` via a `professor` table.
teaching_assignments (BAD): ┌────────────┬─────────────┬───────────┐ │ student_id │ subject │ professor │ ├────────────┼─────────────┼───────────┤ │ 1 │ Math │ Dr. Smith │ │ 1 │ Physics │ Dr. Jones │ │ 2 │ Math │ Dr. Smith │ │ 3 │ Math │ Dr. Smith │ └────────────┴─────────────┴───────────┘ Primary Key: (student_id, subject) Problem: professor depends on subject (not the full key) But professor → subject (Dr. Smith always teaches Math) This dependency isn't captured by the key
Fixed: BCNF Compliant
Separate professor-subject relationship.
professor_subjects: ┌─────────────┬─────────┐ │ professor │ subject │ ├─────────────┼─────────┤ │ Dr. Smith │ Math │ │ Dr. Jones │ Physics │ └─────────────┴─────────┘ student_enrollments: ┌────────────┬─────────────┐ │ student_id │ professor │ ├────────────┼─────────────┤ │ 1 │ Dr. Smith │ │ 1 │ Dr. Jones │ │ 2 │ Dr. Smith │ │ 3 │ Dr. Smith │ └────────────┴─────────────┘
Fourth Normal Form (4NF): No Multi-Valued Dependencies
Rule: No multi-valued dependencies. A table shouldn't store two or more independent many-to-many relationships. Problem solved: Multi-valued facts independent of each other.
Violates 4NF
Employee skills and employee languages are independent of each other.
employee_attributes (BAD): ┌─────────────┬───────────┬──────────┐ │ employee_id │ skill │ language │ ├─────────────┼───────────┼──────────┤ │ 1 │ Python │ English │ │ 1 │ Python │ Spanish │ │ 1 │ SQL │ English │ │ 1 │ SQL │ Spanish │ └─────────────┴───────────┴──────────┘ Problem: Creating redundant combinations If Alice knows Python and SQL, and speaks English and Spanish, we get 2 × 2 = 4 rows for independent facts
Fixed: 4NF Compliant
Separate independent relationships into their own tables.
employee_skills: ┌─────────────┬───────────┐ │ employee_id │ skill │ ├─────────────┼───────────┤ │ 1 │ Python │ │ 1 │ SQL │ └─────────────┴───────────┘ employee_languages: ┌─────────────┬──────────┐ │ employee_id │ language │ ├─────────────┼──────────┤ │ 1 │ English │ │ 1 │ Spanish │ └─────────────┴──────────┘
Now 2 skills + 2 languages = 4 rows total (not 4 in one table)
When 4NF Matters
Whenever you have independent many-to-many relationships for the same entity. Example: products with multiple categories AND multiple suppliers.
Fifth Normal Form (5NF): No Join Dependencies
Rule: Table cannot be decomposed into smaller tables without losing information. Also called "Project-Join Normal Form" (PJ/NF). Rarely needed in practice. It's designed to eliminate redundancy in relational databases that arises from complex, multi-way relationships (join dependencies) that cannot be reduced to simple binary relationships. A table is in 5NF if it is in 4NF and every join dependency in the table is implied by its candidate keys.
Violates 5NF
Agent sells product in region, but relationships are complex.
agent_product_region (BAD): ┌───────┬─────────┬────────┐ │ agent │ product │ region │ ├───────┼─────────┼────────┤ │ Alice │ Laptop │ West │ │ Alice │ Mouse │ West │ │ Bob │ Laptop │ East │ └───────┴─────────┴────────┘ Business Rules: - Alice can sell Laptop and Mouse - Alice works in West region - Bob can sell Laptop - Bob works in East region But table stores ALL combinations, creating redundancy
Fixed: 5NF Compliant
Break into three binary relationships.
agent_products: ┌───────┬─────────┐ │ agent │ product │ ├───────┼─────────┤ │ Alice │ Laptop │ │ Alice │ Mouse │ │ Bob │ Laptop │ └───────┴─────────┘ agent_regions: ┌───────┬────────┐ │ agent │ region │ ├───────┼────────┤ │ Alice │ West │ │ Bob │ East │ └───────┴────────┘ product_regions: ┌─────────┬────────┐ │ product │ region │ ├─────────┼────────┤ │ Laptop │ West │ │ Laptop │ East │ │ Mouse │ West │ └─────────┴────────┘ To find "Can Alice sell Laptop in West?": JOIN all three tables
Which Normal Form Should You Use?
For Most Applications: 3NF
3NF eliminates most redundancy and is easy to understand. It's the sweet spot for 99% of business applications.
When to Use BCNF
When you have overlapping candidate keys or complex functional dependencies. Academic databases, scheduling systems.
When to Use 4NF
When you have multiple independent multi-valued facts about the same entity. Employee skills + languages, product categories + suppliers.
Rarely Use 5NF
Only for highly complex relationships with join dependencies. Most developers never need 5NF in their entire career.
Complete Example: E-commerce Schema
Let's normalize our original bad design completely.
Step 1: Identify Entities
Entities: Customers, Products, Orders
Relationships: Customers place Orders, Orders contain Products
Step 2: Create Tables
customers: ┌─────────────┬─────────────┬──────────────────┐ │ customer_id │ name │ email │ ├─────────────┼─────────────┼──────────────────┤ │ 1 │ Alice Smith │ alice@email.com │ │ 2 │ Bob Jones │ bob@email.com │ └─────────────┴─────────────┴──────────────────┘
Customer info stored once
products: ┌────────────┬──────────┬────────┐ │ product_id │ name │ price │ ├────────────┼──────────┼────────┤ │ 1 │ Laptop │ 999.99 │ │ 2 │ Mouse │ 29.99 │ │ 3 │ Keyboard │ 79.99 │ └────────────┴──────────┴────────┘
Product info stored once
orders: ┌──────────┬─────────────┬────────────┬────────┐ │ order_id │ customer_id │ order_date │ total │ ├──────────┼─────────────┼────────────┼────────┤ │ 1 │ 1 │ 2024-01-15 │ 1059.97│ │ 2 │ 2 │ 2024-01-16 │ 999.99 │ │ 3 │ 1 │ 2024-01-20 │ 79.99 │ └──────────┴─────────────┴────────────┴────────┘
Links customer to order
order_items: ┌──────────────┬──────────┬────────────┬─────────┬────────┐ │ order_item_id│ order_id │ product_id │ qty │ price │ ├──────────────┼──────────┼────────────┼─────────┼────────┤ │ 1 │ 1 │ 1 │ 1 │ 999.99 │ │ 2 │ 1 │ 2 │ 2 │ 29.99 │ │ 3 │ 2 │ 1 │ 1 │ 999.99 │ │ 4 │ 3 │ 3 │ 1 │ 79.99 │ └──────────────┴──────────┴────────────┴─────────┴────────┘
Junction table for many-to-many relationship. Stores price at purchase time.
Creating the Normalized Schema
Here's how to create our normalized e-commerce database.
Customers Table
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
);Products Table
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);Orders Table
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL,
total DECIMAL(10, 2),
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);Order Items Table
CREATE TABLE order_items (
order_item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
qty INTEGER NOT NULL,
price DECIMAL(10, 2) NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(order_id),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);When to Denormalize
Sometimes breaking normalization rules is intentional for performance.
✅ When to Denormalize
- Read-heavy systems: Avoid expensive joins on frequent queries
- Reporting/analytics: Pre-compute aggregates for dashboards
- Caching: Store calculated values to avoid recalculating
- Performance bottlenecks: When joins are measurably slow
Example: Storing Order Total
Instead of calculating SUM(price * qty) every time:
orders: ┌──────────┬─────────────┬────────┐ │ order_id │ customer_id │ total │ ← Denormalized (calculated value) ├──────────┼─────────────┼────────┤ │ 1 │ 1 │ 1059.97│ │ 2 │ 2 │ 999.99 │ └──────────┴─────────────┴────────┘
Trade-off: Faster reads, but must update total when order items change
Database Design Best Practices
✅ Use Descriptive Names
Tables: plural nouns (customers, orders). Columns: clear, unambiguous (customer_id not just id, created_at not date).
✅ Always Use Primary Keys
Every table needs a primary key. Prefer surrogate keys (auto-increment integers or UUIDs) over natural keys.
✅ Define Foreign Key Constraints
Always declare foreign key relationships. They enforce referential integrity automatically.
✅ Use NOT NULL Wisely
Mark required fields as NOT NULL. Don't allow NULL for fields that should always have values (email, created_at).
✅ Index Foreign Keys
Foreign key columns should almost always have indexes for fast joins and lookups.
✅ Choose Appropriate Data Types
Use the smallest data type that fits. Don't use VARCHAR(255) for everything. Use INTEGER for whole numbers, DECIMAL for money, DATE/TIMESTAMP for dates.
Common Design Patterns
One-to-Many: Orders → Order Items
One order has many items.
orders (1) order_items (Many) ┌──────────┐ ┌────────────┬──────────┐ │ order_id │───────<│ order_id │ │ │ ... │ │ product_id │ │ └──────────┘ └────────────┴──────────┘ Foreign key in order_items points to orders
Many-to-Many: Students ↔ Courses
Students take many courses, courses have many students. Requires junction table.
students (Many) enrollments (Junction) courses (Many) ┌────────────┐ ┌────────────┬───────────┐ ┌───────────┐ │ student_id │────<│ student_id │ course_id │>─│ course_id │ │ name │ │ grade │ │ │ name │ └────────────┘ └────────────┴───────────┘ └───────────┘ enrollments connects students to courses
Self-Referencing: Employee → Manager
Table references itself (employees have managers who are also employees).
employees:
┌─────────────┬────────┬────────────┐
│ employee_id │ name │ manager_id │──┐
├─────────────┼────────┼────────────┤ │
│ 1 │ Alice │ NULL │ │ (CEO)
│ 2 │ Bob │ 1 │<─┘ (reports to Alice)
│ 3 │ Charlie│ 1 │<─┐ (reports to Alice)
└─────────────┴────────┴────────────┘ │
└─────────────┘
manager_id is foreign key to employee_id in same tableAudit Columns: Tracking Changes
Add these columns to track when and who modified records.
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
-- Audit columns
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
updated_by INTEGER
);Standard Audit Columns
- created_at: When record was created
- updated_at: When record was last modified
- created_by: User who created it (optional)
- updated_by: User who last modified it (optional)
Soft Deletes: Never Lose Data
Instead of DELETE, mark records as deleted. Allows recovery and audit trail.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP NULL
);Using Soft Deletes
-- Instead of: DELETE FROM customers WHERE customer_id = 5; UPDATE customers SET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP WHERE customer_id = 5;
Record still exists, just marked as deleted
-- Always filter out deleted records SELECT * FROM customers WHERE is_deleted = FALSE;
Active customers only
Schema Evolution: Planning for Change
Databases evolve. Plan for adding columns, tables, and relationships over time.
Adding Columns Safely
-- Add nullable column (safe for existing data) ALTER TABLE customers ADD COLUMN phone VARCHAR(20); -- Add column with default (safe) ALTER TABLE customers ADD COLUMN loyalty_points INTEGER DEFAULT 0;
Use Migrations
Track schema changes with migration files (numbered scripts). Recommended tool: Alembic (lightweight database migration tool for usage with the SQLAlchemy Database Toolkit for Python).
migrations/ 001_create_customers.sql 002_create_products.sql 003_create_orders.sql 004_add_customer_phone.sql 005_add_loyalty_points.sql Each file: one change, with rollback script
Key Takeaways
- Normalization eliminates redundancy - store each fact once
- 1NF: Atomic values only (no lists in cells)
- 2NF: No partial dependencies (applies to composite keys)
- 3NF: No transitive dependencies (non-keys depend on key directly)
- Foreign keys enforce relationships - maintain referential integrity
- Denormalize selectively - only for measured performance needs
- Use surrogate keys - simple integers or UUIDs as primary keys
- Add audit columns - created_at, updated_at for tracking
- Consider soft deletes - mark deleted instead of removing
- Good database design is like good architecture, invest time upfront, and it pays dividends forever