Skip to main content

๐Ÿงฉ 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
Anatomy of a relational table A customers table where columns are attributes, each horizontal row is a tuple, and the customer_id column is the primary key. customer_id first_name last_name email 1JohnSmithjohn@ex.com 2SarahJohnsonsarah@ex.com 3MichaelLeemike@ex.com Primary key Each row is a tuple ยท each column is an attribute
Figure 1 โ€” A 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:

erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ ORDER_ITEM : contains PRODUCT ||--o{ ORDER_ITEM : "appears in" CUSTOMER { int customer_id PK string email string first_name string last_name } ORDER { int order_id PK int customer_id FK date order_date decimal total_amount } ORDER_ITEM { int order_id PK,FK int product_id PK,FK int quantity decimal price } PRODUCT { int product_id PK string name decimal price int stock }

Turning an ER diagram into a schema follows a reliable recipe:

  1. Each entity becomes a table.
  2. Each attribute becomes a column.
  3. The identifying attribute becomes the primary key.
  4. One-to-many relationships become a foreign key on the "many" side.
  5. Many-to-many relationships become a junction table.

Keys & Referential Integrity

Keys are how relational databases identify records and link tables together.

KeyWhat it isExample
Primary keyUniquely identifies each rowcustomer_id in customers
Foreign keyReferences a primary key in another tablecustomer_id in orders
Candidate keyAny column(s) that could be the primary keyemail (also unique)
Composite keyA primary key made of multiple columns(order_id, product_id)
Surrogate keyAn artificial ID with no business meaningauto-increment id
Natural keyA key with real-world meaningISBN, 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:

ActionEffect on child rows
ON DELETE CASCADEDelete them too
ON DELETE RESTRICT / NO ACTIONBlock the delete
ON DELETE SET NULLNull 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.

graph LR subgraph OneToOne["One-to-One"] P[Person] --- PP[Passport] end subgraph OneToMany["One-to-Many"] C[Customer] --- O1[Order 1] C --- O2[Order 2] C --- O3[Order 3] end subgraph ManyToMany["Many-to-Many"] S1[Student A] --- CA[Course X] S1 --- CB[Course Y] S2[Student B] --- CA end

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.

Normalization progression Data moves from an unnormalized wide table through 1NF, 2NF, and 3NF, removing repeating groups, partial dependencies, and transitive dependencies at each step. Unnormalized one wide table 1NF atomic values 2NF no partial dep. 3NF no transitive dep.
Figure 2 โ€” Each normal form removes one class of redundancy. For most applications, 3NF is the sweet spot.

โš ๏ธ 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:

  1. Identify the entities hiding in this table.
  2. Split it into separate tables, each with a primary key.
  3. Add foreign keys so a loan references a book and a member.
  4. Write the CREATE TABLE statements (PostgreSQL or MySQL).
  5. 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 UNIQUE on 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_case columns).
  • Pick precise data types (NUMERIC for money, DATE/TIMESTAMP for 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

๐Ÿš€ 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.