Week 6: MySQL Fundamentals & Schema Design

This week is deliberately PHP-free — before any code talks to a database, you need to design one. Tables, keys, data types, normalization, and enough SQL to query across several related tables. Week 7 connects PHP to exactly the schema you'll design here.

Module 6 of 28 Week 6 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design normalized tables with appropriate keys & data types
  • Write SELECT/INSERT/UPDATE/DELETE statements & joins confidently
  • Design a schema for a real multi-table application from scratch

1. The Relational Model

A relational database stores data in tables — rows and columns, like a strict spreadsheet where every row in a table has the same set of typed columns. Tables relate to each other through keys: a orders table doesn't repeat a customer's full details on every row; it stores a customer_id that points back to a row in a customers table.

terminal
# Install MySQL (or MariaDB, a drop-in-compatible fork)
# macOS
brew install mysql && brew services start mysql

# Ubuntu/Debian
sudo apt install mysql-server

# Connect with the CLI client
mysql -u root -p

CREATE DATABASE week06_shop;
USE week06_shop;

This week uses the MySQL CLI directly — no PHP yet. The goal is thinking in tables and relationships before writing any application code against them.

2. Data Types & Keys

Common MySQL column types you'll reach for constantly:

  • INT / BIGINT — whole numbers, typically for IDs and counts
  • DECIMAL(10,2) — exact fixed-point numbers, essential for money (never use FLOAT for currency — rounding errors accumulate)
  • VARCHAR(n) — variable-length text up to n characters
  • TEXT — longer, unbounded text (descriptions, comments)
  • DATETIME / TIMESTAMP — date and time values
  • BOOLEAN — actually stored as TINYINT(1) under the hood
schema.sql
CREATE TABLE customers (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(150) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    stock_count INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    customer_id INT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

A primary key (PRIMARY KEY) uniquely identifies each row in a table — AUTO_INCREMENT means MySQL assigns the next integer automatically. A foreign key (FOREIGN KEY ... REFERENCES) points from one table to another's primary key, and MySQL enforces that the referenced row must actually exist — you can't insert an order with a customer_id that doesn't correspond to a real customer.

3. CRUD in SQL

The four basic operations, directly in SQL — this is exactly what PDO will wrap in PHP next week:

crud.sql
-- Create
INSERT INTO customers (name, email) VALUES ('Ada Lovelace', 'ada@example.com');
INSERT INTO products (name, price, stock_count) VALUES ('Mechanical Keyboard', 89.99, 12);

-- Read
SELECT * FROM products WHERE price < 100 ORDER BY price ASC;
SELECT name, price FROM products WHERE stock_count > 0 LIMIT 10;

-- Update
UPDATE products SET stock_count = stock_count - 1 WHERE id = 1;

-- Delete
DELETE FROM products WHERE stock_count = 0 AND id NOT IN (
    SELECT DISTINCT product_id FROM order_items
);
Never run UPDATE or DELETE without WHERE

Without a WHERE clause, both statements apply to every row in the table — instantly, with no confirmation prompt. Before running either against real data, it's worth running the equivalent SELECT with the same WHERE clause first, to see exactly which rows would be affected.

4. Joins

A join combines rows from two or more tables based on a related column — this is how you turn a normalized schema (data split across tables) back into one combined result:

joins.sql
CREATE TABLE order_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- Every order with the customer's name attached
SELECT orders.id, orders.status, customers.name AS customer_name
FROM orders
INNER JOIN customers ON orders.customer_id = customers.id;

-- Every order item with product details
SELECT order_items.quantity, products.name, products.price,
       (order_items.quantity * products.price) AS line_total
FROM order_items
INNER JOIN products ON order_items.product_id = products.id
WHERE order_items.order_id = 1;

-- All customers, including those with zero orders
SELECT customers.name, COUNT(orders.id) AS order_count
FROM customers
LEFT JOIN orders ON orders.customer_id = customers.id
GROUP BY customers.id;

INNER JOIN only returns rows that match on both sides. LEFT JOIN returns every row from the left table regardless of whether a match exists on the right — customers with no orders still appear, with NULLs (or 0, via COUNT) for the missing order data.

5. Normalization

Normalization is the discipline of splitting data into tables so each fact is stored exactly once. Consider this un-normalized alternative to the schema above:

what NOT to do
CREATE TABLE orders_bad (
    id INT AUTO_INCREMENT PRIMARY KEY,
    customer_name VARCHAR(100),   -- repeated on every order from the same customer
    customer_email VARCHAR(255),  -- repeated, and now can drift out of sync
    product_name VARCHAR(150),    -- one order, one product -- can't have 2 items
    product_price DECIMAL(10,2)
);

This design repeats customer data on every order (update their email, and you must update every historical row or leave it inconsistent), and can't represent an order with multiple products at all. Splitting into customers, products, orders and order_items — as built above — fixes both problems: each customer's data lives in exactly one row, and an order can reference any number of products through order_items.

A practical rule of thumb

If you find yourself about to store the same real-world fact (a customer's email, a product's price at catalog level) in more than one table, that's usually a sign it belongs in its own table with a foreign key pointing to it instead. Full normal-form theory (1NF/2NF/3NF) is worth reading eventually, but this single instinct — "store each fact once" — resolves most real schema design decisions.

6. Hands-on Exercise

Hands-on

Design and query a blog schema

Apply this week's concepts to a schema you design yourself, then write queries against it.

Requirements:

  1. Design and create four tables: authors, posts (belongs to an author), tags, and post_tags (a many-to-many join table between posts and tags).
  2. Choose appropriate data types and constraints for every column, with PRIMARY KEY and FOREIGN KEY relationships correctly declared.
  3. Insert at least 3 authors, 5 posts, 4 tags, and enough post_tags rows that at least one post has multiple tags and at least one tag applies to multiple posts.
  4. Write a query listing every post with its author's name attached (INNER JOIN).
  5. Write a query listing every author with a count of their posts, including authors with zero posts (LEFT JOIN + GROUP BY).
  6. Write a query returning all tags attached to a specific post, joining through post_tags.
Hint

A many-to-many relationship (posts ↔ tags) always needs its own join table with two foreign keys — there's no way to represent "many posts can share many tags" with a foreign key on either side alone.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why use DECIMAL(10,2) instead of FLOAT for a price column?

FLOAT stores an approximate binary representation of a decimal number, which can introduce tiny rounding errors that accumulate across many calculations — unacceptable for money. DECIMAL(10,2) stores an exact fixed-point value (10 total digits, 2 after the decimal point), guaranteeing 19.99 + 0.01 equals exactly 20.00 every time.

Q2

What does a FOREIGN KEY constraint actually enforce?

That any value stored in the foreign key column must correspond to an existing row in the referenced table's primary key column. Trying to insert an order with customer_id = 999 when no customer with id = 999 exists fails at the database level — this is called referential integrity, and it's enforced regardless of what the application code does or forgets to check.

Q3

Why does LEFT JOIN return customers with zero orders, but INNER JOIN wouldn't?

INNER JOIN only keeps rows where a match exists on both sides — a customer with no matching row in orders simply drops out of the result entirely. LEFT JOIN keeps every row from the left table (customers) no matter what, filling in NULL for any columns from the right table when no match exists — which is exactly why it's the right choice for "every customer, including ones with no orders yet."

Q4

Why does representing a many-to-many relationship (posts ↔ tags) require a separate join table, rather than a foreign key on either side?

A single foreign-key column can only point to one row. Putting tag_id directly on posts would limit each post to exactly one tag; putting post_id on tags would limit each tag to one post. A join table like post_tags, with a foreign key to each side, can hold as many rows as needed — any post can pair with any number of tags, and vice versa.