๐งฉ Relational Database Design
A well-designed schema is the quiet foundation under every reliable app. This lesson takes you from the relational model and ER diagrams to keys, relationships, and normalization โ so you can model real data cleanly and avoid the mistakes that haunt projects for years.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Describe the relational model โ relations, tuples, and attributes
- Draw an entity-relationship (ER) diagram and convert it into tables
- Use primary and foreign keys to model one-to-many and many-to-many relationships
- Apply normalization through 1NF, 2NF, and 3NF, and know when to denormalize
Estimated Time: 35โ45 minutes โข Difficulty: Intermediate
Hands-on: Normalize a messy library table into a clean 3NF schema with real SQL.
In This Lesson
Why Design Matters
Relational databases are the backbone of most business software โ e-commerce, banking, healthcare, and beyond. Their staying power comes from a simple, mathematically grounded idea: store data in tables and connect those tables through shared values.
๐ก Design is architecture. A good schema is like a well-planned building โ it supports growth and change gracefully. A poor one leads to duplicated data, contradictory records, and queries that get slower every month. The good news: a handful of principles prevent almost all of it.
We'll build up those principles in order: the model, then a way to diagram it, then keys and relationships, then normalization to keep it clean.
The Relational Model
The relational model, introduced by E. F. Codd at IBM in 1970, organizes data into relations (tables). Each table has:
- Tuples (rows) โ individual records, one per real-world instance
- Attributes (columns) โ the properties of each record, each with a data type
customers table: columns are attributes, rows are tuples, and customer_id uniquely identifies each row.Well-formed relations obey a few rules: each cell holds a single atomic value (never a list), no two rows are identical, every value in a column shares the same type, and column/row order carries no meaning.
In practice you create tables with SQL. Here it is in PostgreSQL:
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY, -- auto-incrementing surrogate key
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
date_joined DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
total_amount NUMERIC(10, 2) NOT NULL
);
๐ SERIAL vs AUTO_INCREMENT
PostgreSQL uses SERIAL (or the modern GENERATED ALWAYS AS IDENTITY) for auto-incrementing IDs; MySQL uses AUTO_INCREMENT. Same idea, different keyword โ a good reminder that SQL is standardized but each engine has dialect quirks.
Entity-Relationship Modeling
Before writing a single CREATE TABLE, designers sketch an Entity-Relationship (ER) diagram โ a visual map of the data that's easy to discuss with teammates and stakeholders. Its pieces:
- Entities โ the things you store data about (customers, orders, products)
- Attributes โ the properties of each entity
- Relationships โ how entities connect, and with what cardinality
Here's an ER diagram for a small store, drawn with Mermaid:
Turning an ER diagram into a schema follows a reliable recipe:
- Each entity becomes a table.
- Each attribute becomes a column.
- The identifying attribute becomes the primary key.
- One-to-many relationships become a foreign key on the "many" side.
- Many-to-many relationships become a junction table.
Keys & Referential Integrity
Keys are how relational databases identify records and link tables together.
| Key | What it is | Example |
|---|---|---|
| Primary key | Uniquely identifies each row | customer_id in customers |
| Foreign key | References a primary key in another table | customer_id in orders |
| Candidate key | Any column(s) that could be the primary key | email (also unique) |
| Composite key | A primary key made of multiple columns | (order_id, product_id) |
| Surrogate key | An artificial ID with no business meaning | auto-increment id |
| Natural key | A key with real-world meaning | ISBN, SSN |
Referential integrity
A foreign key constraint guarantees that every reference points to a row that actually exists โ you can never have an order for a customer who isn't in the database. You also decide what happens when the referenced row is deleted or updated:
| Action | Effect on child rows |
|---|---|
ON DELETE CASCADE | Delete them too |
ON DELETE RESTRICT / NO ACTION | Block the delete |
ON DELETE SET NULL | Null out the foreign key |
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
FOREIGN KEY (customer_id)
REFERENCES customers(customer_id)
ON DELETE CASCADE -- deleting a customer removes their orders
ON UPDATE CASCADE
);
โ Surrogate vs natural keys
Modern practice favors surrogate keys (auto-increment integers or UUIDs) for most tables: they're compact, fast to join, never change, and reveal nothing sensitive. Still enforce a UNIQUE constraint on natural candidate keys like email so duplicates can't sneak in.
Modeling Relationships
Three relationship shapes cover almost everything.
One-to-many (1:N)
The most common shape: one customer has many orders, but each order belongs to one customer. Put the foreign key on the "many" side (orders.customer_id). We already did this above.
Many-to-many (N:M)
A student enrolls in many courses; a course has many students. Relational tables can't store this directly, so you introduce a junction table whose composite key pairs the two sides โ and which can carry its own attributes (like a grade):
CREATE TABLE students (
student_id SERIAL PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE courses (
course_id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
credits INTEGER NOT NULL
);
-- Junction table resolves the many-to-many relationship
CREATE TABLE enrollments (
student_id INTEGER NOT NULL REFERENCES students(student_id),
course_id INTEGER NOT NULL REFERENCES courses(course_id),
enrollment_date DATE NOT NULL DEFAULT CURRENT_DATE,
grade VARCHAR(2),
PRIMARY KEY (student_id, course_id) -- composite key: one enrollment per pair
);
One-to-one (1:1)
Rarer โ used to split rarely-used or sensitive columns into a separate table. Put a foreign key that's also UNIQUE on the dependent side (e.g. a user_profiles table with a unique user_id).
Normalization (1NF โ 3NF)
Normalization reorganizes tables to eliminate redundancy so every fact lives in exactly one place. Cramming everything into one wide table causes four classic problems:
- Redundancy โ the same customer address repeated on every order
- Update anomalies โ changing that address means editing many rows
- Insertion anomalies โ you can't add a customer who has no order yet
- Deletion anomalies โ deleting the last order erases the customer
First Normal Form (1NF) โ atomic values
Every cell holds one value; no repeating groups or comma-lists. A phone_numbers column holding "555-1234, 555-5678" violates 1NF. Fix it by moving phones to their own table, one row per number.
Second Normal Form (2NF) โ no partial dependencies
Be in 1NF, and every non-key column must depend on the whole composite key, not part of it. In a table keyed by (student_id, course_id), storing student_name is a partial dependency (it depends only on student_id). Split names into a students table.
Third Normal Form (3NF) โ no transitive dependencies
Be in 2NF, and no non-key column may depend on another non-key column. If an order-items table stores category_name, and category name depends on category_id (a non-key), that's transitive โ move categories into their own table.
โ ๏ธ When to denormalize
Normalization optimizes for integrity, sometimes at the cost of read speed (more joins). In read-heavy or reporting workloads you may deliberately reintroduce redundancy โ e.g. caching a product's average_rating instead of recomputing it from every review. Rule of thumb: normalize first, denormalize only when profiling proves you need to, and add controls to keep the copies in sync.
Hands-on Exercise
๐๏ธ Normalize a Library Table
Objective: Turn one messy, redundant table into a clean 3NF schema.
You're handed this unnormalized table for a library:
loans(loan_id, book_title, author_name, author_born,
member_name, member_email, borrowed_on, due_on)
Instructions:
- Identify the entities hiding in this table.
- Split it into separate tables, each with a primary key.
- Add foreign keys so a loan references a book and a member.
- Write the
CREATE TABLEstatements (PostgreSQL or MySQL). - Explain which redundancy each split removed.
๐ก Hint
Author details repeat for every book they wrote (transitive dependency through the book). Member details repeat for every loan. Each is a separate entity that deserves its own table.
โ Example solution
CREATE TABLE authors (
author_id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
born DATE
);
CREATE TABLE books (
book_id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
author_id INTEGER NOT NULL REFERENCES authors(author_id)
);
CREATE TABLE members (
member_id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE loans (
loan_id SERIAL PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(book_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
borrowed_on DATE NOT NULL DEFAULT CURRENT_DATE,
due_on DATE NOT NULL
);
Removed: author fields no longer repeat per book (3NF, transitive dependency gone); member fields no longer repeat per loan; and a member can now exist before their first loan (insertion anomaly gone).
Best Practices
โ Do
- Sketch an ER diagram before writing SQL โ it catches design flaws cheaply.
- Use surrogate primary keys, but still enforce
UNIQUEon natural keys. - Always define foreign keys โ let the database protect referential integrity.
- Normalize to 3NF by default; it prevents the anomalies that cause data bugs.
- Use consistent, descriptive naming (plural table names,
snake_casecolumns). - Pick precise data types (
NUMERICfor money,DATE/TIMESTAMPfor time โ never strings).
โ Don't
- Don't store lists in a single column (violates 1NF) โ use a related table.
- Don't duplicate data across tables without a deliberate, measured reason.
- Don't skip foreign keys "to keep it simple" โ you'll get orphaned rows.
- Don't denormalize before you have a proven performance problem.
Summary & Quiz
๐ Key Takeaways
- The relational model stores data in tables of tuples and attributes, linked by keys.
- ER diagrams map entities and relationships before you write SQL.
- Primary and foreign keys identify rows and enforce referential integrity.
- One-to-many uses a foreign key; many-to-many needs a junction table.
- Normalization (through 3NF) removes redundancy; denormalize only when profiling demands it.
๐ฏ Quick Quiz
Question 1: How do you model a many-to-many relationship between students and courses?
Question 2: A table stores category_name alongside category_id, where the name depends on the ID (a non-key column). Which rule does this violate?
Question 3: Why do most designers prefer surrogate keys over natural keys?
๐ Further Reading
- PostgreSQL โ Data Definition (tables, keys, constraints)
- MySQL โ Foreign Key Examples
- Use The Index, Luke! โ indexing for developers
- "Database Design for Mere Mortals" by Michael J. Hernandez (book)
๐ What's Next?
You can now model structured data cleanly. But not all data is neat and tabular โ next we cross into the other world. Up next: NoSQL Database Principles.
๐ Well done!
Clean schemas are a superpower. Your future self (debugging at 2 a.m.) thanks you.