The Complete MySQL Mastery Course
Structured Query Language · Database Design · Real-World SQL
From Absolute Beginner → Professional Database Developer
Introduction & Setup
1.1 What is MySQL?
MySQL is the world's most popular open-source relational database management system (RDBMS). It stores data in tables with rows and columns — like a highly structured, powerful spreadsheet — and you communicate with it using SQL (Structured Query Language).
It powers billions of applications — from WordPress to Airbnb, Twitter, Facebook, and YouTube.
1.2 Installing MySQL
# macOS (Homebrew) brew install mysql brew services start mysql # Ubuntu / Debian sudo apt update sudo apt install mysql-server sudo systemctl start mysql # Windows: download installer from https://dev.mysql.com/downloads/installer/ # Verify install mysql --version # → mysql Ver 8.0.35 for Linux on x86_64 (MySQL Community Server - GPL) # Connect to MySQL shell mysql -u root -p
- MySQL Workbench — official, free GUI for all platforms
- DBeaver — universal, excellent free database client
- TablePlus — sleek, fast (Mac/Windows)
- phpMyAdmin — browser-based, great for learning
1.3 Your First SQL Commands
-- Show all databases SHOW DATABASES; -- Show current date and time SELECT NOW(); -- Simple calculation SELECT 2 + 2 AS result; -- MySQL version SELECT VERSION();
| result |
|---|
| 4 |
1.4 SQL Syntax Rules — Memorise These
| Rule | Detail |
|---|---|
| Case-insensitive keywords | SELECT = select = Select — convention: UPPERCASE keywords |
| Statements end with ; | Always end with a semicolon |
| Single-line comment | -- This is a comment |
| Multi-line comment | /* This spans multiple lines */ |
| String literals | Use single quotes: 'Alice' NOT "Alice" |
| NULL | Represents absence of a value — NOT zero, NOT empty string |
| Whitespace ignored | SQL ignores extra spaces and newlines |
Databases & Tables
2.1 Create & Select a Database
CREATE DATABASE bookstore; -- create database CREATE DATABASE IF NOT EXISTS bookstore; -- safe version USE bookstore; -- switch to it SHOW TABLES; -- list tables in current DB DROP DATABASE bookstore; -- delete (irreversible!)
2.2 Data Types — The Building Blocks
| Type | Description | Example |
|---|---|---|
| INT | Whole number (–2B to 2B) | 42, -7, 0 |
| BIGINT | Large whole number | user_id, order_id |
| TINYINT(1) | Boolean (0 = false, 1 = true) | is_active |
| DECIMAL(p,s) | Exact decimal — use for money! | DECIMAL(10,2) → 99999999.99 |
| FLOAT / DOUBLE | Approximate decimal (not for money) | scientific data |
| VARCHAR(n) | Variable-length string up to n chars | VARCHAR(100) |
| CHAR(n) | Fixed-length string | country_code CHAR(2) |
| TEXT | Long text (up to 65KB) | description, bio |
| DATE | Date only (YYYY-MM-DD) | '2024-03-15' |
| DATETIME | Date + time | '2024-03-15 14:30:00' |
| TIMESTAMP | Auto-records time of change | created_at, updated_at |
| BOOLEAN | Alias for TINYINT(1) | TRUE / FALSE |
| ENUM | One value from a fixed list | ENUM('S','M','L','XL') |
| JSON | JSON documents | metadata, settings |
2.3 CREATE TABLE — Full Example
USE bookstore; CREATE TABLE authors ( author_id INT AUTO_INCREMENT PRIMARY KEY, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL, email VARCHAR(120) UNIQUE, country CHAR(2) DEFAULT 'US', bio TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE books ( book_id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, author_id INT NOT NULL, genre VARCHAR(50), price DECIMAL(8,2) NOT NULL, stock INT DEFAULT 0, published DATE, FOREIGN KEY (author_id) REFERENCES authors(author_id) ON DELETE CASCADE );
2.4 Constraints — Rules on Your Data
| Constraint | What it enforces |
|---|---|
| PRIMARY KEY | Unique + NOT NULL identifier for each row |
| AUTO_INCREMENT | MySQL automatically assigns the next integer |
| NOT NULL | Column must have a value — never NULL |
| UNIQUE | All values in column must be distinct |
| DEFAULT value | Value used when INSERT doesn't specify |
| FOREIGN KEY | Value must exist in the referenced table |
| CHECK | Value must satisfy a condition: CHECK (price > 0) |
2.5 ALTER TABLE — Modify Existing Tables
-- Add a column ALTER TABLE books ADD COLUMN rating DECIMAL(3,2); -- Modify column type ALTER TABLE books MODIFY COLUMN genre VARCHAR(80) NOT NULL; -- Rename a column ALTER TABLE books RENAME COLUMN published TO publish_date; -- Drop a column ALTER TABLE books DROP COLUMN rating; -- Show table structure DESCRIBE books;
| Field | Type | Null | Key | Default | Extra |
|---|---|---|---|---|---|
| book_id | int | NO | PRI | NULL | auto_increment |
| title | varchar(200) | NO | NULL | ||
| author_id | int | NO | MUL | NULL | |
| genre | varchar(80) | NO | NULL | ||
| price | decimal(8,2) | NO | NULL | ||
| stock | int | YES | 0 | ||
| publish_date | date | YES | NULL |
Scenario: Design a database for an online clothing store.
- Create a database called
fashion_store - Create a
customerstable: id, name, email (unique), phone, city, created_at - Create a
productstable: id, name, category, price, size (ENUM S/M/L/XL), stock, description - Create an
orderstable: id, customer_id (FK), order_date, status (ENUM), total_amount - Add a
discount_pctcolumn to products with a CHECK that it's between 0 and 100
CREATE DATABASE IF NOT EXISTS fashion_store; USE fashion_store; CREATE TABLE customers ( customer_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(150) UNIQUE NOT NULL, phone VARCHAR(20), city VARCHAR(80), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE products ( product_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(150) NOT NULL, category VARCHAR(60) NOT NULL, price DECIMAL(8,2) NOT NULL CHECK (price > 0), size ENUM('XS','S','M','L','XL','XXL'), stock INT DEFAULT 0, description TEXT ); CREATE TABLE orders ( order_id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, status ENUM('pending','shipped','delivered','cancelled') DEFAULT 'pending', total_amount DECIMAL(10,2) NOT NULL, FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ); ALTER TABLE products ADD COLUMN discount_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_pct BETWEEN 0 AND 100);
Inserting Data
3.1 INSERT INTO — Basic Syntax
USE bookstore; -- Single row — specify all columns INSERT INTO authors (first_name, last_name, email, country) VALUES ('George', 'Orwell', 'gorwell@mail.com', 'GB'); -- Multiple rows in one statement (much faster!) INSERT INTO authors (first_name, last_name, email, country) VALUES ('J.K.', 'Rowling', 'jkr@mail.com', 'GB'), ('Harper', 'Lee', 'hlee@mail.com', 'US'), ('Gabriel','Garcia', 'gmarquez@mail.com','CO'), ('Agatha', 'Christie', 'achristie@mail.com','GB'); -- Get the auto-generated ID of last insert SELECT LAST_INSERT_ID();
INSERT INTO books (title, author_id, genre, price, stock, publish_date) VALUES ('1984', 1, 'Dystopian', 12.99, 150, '1949-06-08'), ('Animal Farm', 1, 'Satire', 8.99, 200, '1945-08-17'), ("Harry Potter and the PS", 2, 'Fantasy', 14.99, 300, '1997-06-26'), ('To Kill a Mockingbird', 3, 'Classic', 11.99, 120, '1960-07-11'), ('One Hundred Years', 4, 'Magical Realism',13.99,80,'1967-05-30'), ('And Then There Were None', 5, 'Mystery', 9.99, 175, '1939-11-06'), ('Murder on the Orient', 5, 'Mystery', 10.99, 160, '1934-01-01');
3.2 INSERT … ON DUPLICATE KEY UPDATE
-- Insert if not exists, update if duplicate key found INSERT INTO books (book_id, title, price, stock) VALUES (1, '1984', 12.99, 200) ON DUPLICATE KEY UPDATE stock = stock + VALUES(stock), -- add 200 to existing stock price = VALUES(price); -- update price
SELECT Queries
4.1 Basic SELECT
-- Select all columns SELECT * FROM books; -- Select specific columns SELECT title, price, stock FROM books; -- Aliases — rename columns in output SELECT title AS 'Book Title', price AS 'Price ($)', stock * price AS 'Inventory Value' FROM books;
| Book Title | Price ($) | Inventory Value |
|---|---|---|
| 1984 | 12.99 | 1948.50 |
| Animal Farm | 8.99 | 1798.00 |
| Harry Potter and the PS | 14.99 | 4497.00 |
| To Kill a Mockingbird | 11.99 | 1438.80 |
| One Hundred Years | 13.99 | 1119.20 |
| And Then There Were None | 9.99 | 1748.25 |
| Murder on the Orient | 10.99 | 1758.40 |
4.2 ORDER BY — Sort Results
-- Sort by price ascending (default) SELECT title, price FROM books ORDER BY price ASC; -- Sort by price descending SELECT title, price, stock FROM books ORDER BY price DESC; -- Sort by multiple columns SELECT genre, title, price FROM books ORDER BY genre ASC, price DESC;
| genre | title | price |
|---|---|---|
| Classic | To Kill a Mockingbird | 11.99 |
| Dystopian | 1984 | 12.99 |
| Fantasy | Harry Potter and the PS | 14.99 |
| Magical Realism | One Hundred Years | 13.99 |
| Mystery | Murder on the Orient | 10.99 |
| Mystery | And Then There Were None | 9.99 |
| Satire | Animal Farm | 8.99 |
4.3 LIMIT & OFFSET — Pagination
-- Top 3 most expensive books SELECT title, price FROM books ORDER BY price DESC LIMIT 3; -- Page 2 (rows 4-6) — OFFSET skips rows SELECT title, price FROM books ORDER BY price DESC LIMIT 3 OFFSET 3; -- skip first 3, return next 3
| title | price |
|---|---|
| Harry Potter and the PS | 14.99 |
| One Hundred Years | 13.99 |
| 1984 | 12.99 |
4.4 DISTINCT — Remove Duplicates
-- Unique genres in the books table SELECT DISTINCT genre FROM books ORDER BY genre;
| genre |
|---|
| Classic |
| Dystopian |
| Fantasy |
| Magical Realism |
| Mystery |
| Satire |
Filtering & Operators
5.1 WHERE Clause
-- Comparison operators: = != < > <= >= SELECT title, price FROM books WHERE price > 12; -- AND / OR / NOT SELECT title, genre, price FROM books WHERE genre = 'Mystery' AND price < 11;
| title | genre | price |
|---|---|---|
| And Then There Were None | Mystery | 9.99 |
5.2 BETWEEN, IN, NOT IN
-- BETWEEN is inclusive on both ends SELECT title, price FROM books WHERE price BETWEEN 10 AND 13; -- IN — match any value in a list SELECT title, genre FROM books WHERE genre IN ('Mystery', 'Fantasy', 'Dystopian'); -- NOT IN — exclude values SELECT title, genre FROM books WHERE genre NOT IN ('Satire', 'Classic');
| title | price |
|---|---|
| To Kill a Mockingbird | 11.99 |
| And Then There Were None | 9.99 |
| Murder on the Orient | 10.99 |
5.3 LIKE — Pattern Matching
-- % matches any number of characters (including none) -- _ matches exactly ONE character SELECT title FROM books WHERE title LIKE '%Harry%'; -- contains "Harry" SELECT title FROM books WHERE title LIKE '1%'; -- starts with "1" SELECT title FROM books WHERE title LIKE '%Farm'; -- ends with "Farm" SELECT email FROM authors WHERE email LIKE '%@mail.com'; -- RLIKE / REGEXP — full regular expressions SELECT title FROM books WHERE title REGEXP '^(Harry|Animal)';
| title |
|---|
| Harry Potter and the PS |
5.4 NULL — The Special Case
-- ALWAYS use IS NULL / IS NOT NULL — never = NULL SELECT * FROM authors WHERE bio IS NULL; SELECT * FROM authors WHERE bio IS NOT NULL; -- IFNULL — replace NULL with a default SELECT first_name, IFNULL(bio, 'No bio available') AS bio FROM authors; -- COALESCE — returns first non-NULL from a list SELECT COALESCE(bio, email, 'Unknown') AS contact FROM authors;
| first_name | bio |
|---|---|
| George | No bio available |
| J.K. | No bio available |
| Harper | No bio available |
| Gabriel | No bio available |
| Agatha | No bio available |
Using the bookstore database:
- Find all books published before 1960
- Find books where price is between $9 and $12 (inclusive)
- Find all books by Agatha Christie (author_id = 5)
- Find books whose title starts with "And" or contains "Hundred"
- Find books with stock greater than 150 sorted by stock descending
- Show all authors whose country is NOT 'US' or 'GB'
-- 1. Books before 1960 SELECT title, publish_date FROM books WHERE publish_date < '1960-01-01' ORDER BY publish_date; -- → Animal Farm (1945), And Then There Were None (1939), Murder on the Orient (1934) -- 2. Price between $9 and $12 SELECT title, price FROM books WHERE price BETWEEN 9 AND 12 ORDER BY price; -- → Animal Farm $8.99 (excluded), And Then $9.99, Murder $10.99, To Kill $11.99 -- 3. Agatha Christie books SELECT title, price FROM books WHERE author_id = 5; -- 4. Title starts with "And" OR contains "Hundred" SELECT title FROM books WHERE title LIKE 'And%' OR title LIKE '%Hundred%'; -- 5. Stock > 150 descending SELECT title, stock FROM books WHERE stock > 150 ORDER BY stock DESC; -- 6. Authors not from US or GB SELECT first_name, last_name, country FROM authors WHERE country NOT IN ('US', 'GB'); -- → Gabriel Garcia, CO
Aggregate Functions
6.1 Core Aggregate Functions
SELECT COUNT(*) AS total_books, COUNT(genre) AS books_with_genre, -- ignores NULLs SUM(stock) AS total_stock, AVG(price) AS avg_price, MIN(price) AS cheapest, MAX(price) AS most_expensive, ROUND(AVG(price), 2) AS avg_price_rounded FROM books;
| total_books | books_with_genre | total_stock | avg_price | cheapest | most_expensive | avg_price_rounded |
|---|---|---|---|---|---|---|
| 7 | 7 | 1185 | 11.990000 | 8.99 | 14.99 | 11.99 |
6.2 GROUP BY — Aggregate by Category
-- Count and total stock per genre SELECT genre, COUNT(*) AS num_books, SUM(stock) AS total_stock, ROUND(AVG(price), 2) AS avg_price FROM books GROUP BY genre ORDER BY total_stock DESC;
| genre | num_books | total_stock | avg_price |
|---|---|---|---|
| Fantasy | 1 | 300 | 14.99 |
| Satire | 1 | 200 | 8.99 |
| Mystery | 2 | 335 | 10.49 |
| Dystopian | 1 | 150 | 12.99 |
| Classic | 1 | 120 | 11.99 |
| Magical Realism | 1 | 80 | 13.99 |
6.3 HAVING — Filter Groups (not rows)
-- WHERE filters rows BEFORE grouping -- HAVING filters groups AFTER aggregation SELECT genre, COUNT(*) AS num_books, AVG(price) AS avg_price FROM books WHERE stock > 50 -- filter rows first GROUP BY genre HAVING AVG(price) > 10 -- then filter groups ORDER BY avg_price DESC;
| genre | num_books | avg_price |
|---|---|---|
| Fantasy | 1 | 14.990000 |
| Magical Realism | 1 | 13.990000 |
| Dystopian | 1 | 12.990000 |
| Classic | 1 | 11.990000 |
- WHERE — filters individual rows before grouping. Cannot use aggregate functions.
- HAVING — filters groups after
GROUP BY. Must use aggregate functions. - SQL execution order:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Build a simple sales table and query it:
CREATE TABLE sales (
sale_id INT AUTO_INCREMENT PRIMARY KEY,
product VARCHAR(60), category VARCHAR(40),
quantity INT, unit_price DECIMAL(8,2), sale_date DATE
);
INSERT INTO sales (product, category, quantity, unit_price, sale_date) VALUES
('Laptop','Electronics',3,899.99,'2024-01-15'),
('Phone','Electronics',10,499.99,'2024-01-20'),
('Desk','Furniture',2,299.99,'2024-02-01'),
('Chair','Furniture',5,149.99,'2024-02-10'),
('Tablet','Electronics',7,349.99,'2024-02-15'),
('Lamp','Furniture',8,49.99,'2024-03-01'),
('Headphones','Electronics',15,79.99,'2024-03-10'),
('Monitor','Electronics',4,399.99,'2024-03-20');
- Total revenue per category (quantity × unit_price)
- Number of sales and avg unit price per category
- Find categories where total revenue exceeds $2,000
- Find the most expensive product per category
- Count sales per month (use MONTH() function)
-- 1. Total revenue per category SELECT category, SUM(quantity * unit_price) AS total_revenue FROM sales GROUP BY category ORDER BY total_revenue DESC; -- Electronics: 9,099.76 | Furniture: 1,549.87 -- 2. Count & avg price per category SELECT category, COUNT(*) AS num_sales, ROUND(AVG(unit_price), 2) AS avg_price FROM sales GROUP BY category; -- 3. Categories with revenue > $2,000 SELECT category, SUM(quantity * unit_price) AS revenue FROM sales GROUP BY category HAVING revenue > 2000; -- → Electronics only -- 4. Most expensive product per category SELECT category, MAX(unit_price) AS max_price FROM sales GROUP BY category; -- 5. Sales count per month SELECT MONTH(sale_date) AS month_num, MONTHNAME(sale_date) AS month_name, COUNT(*) AS num_sales FROM sales GROUP BY MONTH(sale_date), MONTHNAME(sale_date) ORDER BY month_num;
JOINs — Combining Tables
JOINs combine rows from two or more tables based on a related column. This is the heart of relational databases — normalised data spread across tables reunited by JOIN.
7.1 INNER JOIN — Only Matching Rows
-- Get books with their author names SELECT b.title, CONCAT(a.first_name, ' ', a.last_name) AS author, b.genre, b.price FROM books AS b INNER JOIN authors AS a ON b.author_id = a.author_id ORDER BY b.price DESC;
| title | author | genre | price |
|---|---|---|---|
| Harry Potter and the PS | J.K. Rowling | Fantasy | 14.99 |
| One Hundred Years | Gabriel Garcia | Magical Realism | 13.99 |
| 1984 | George Orwell | Dystopian | 12.99 |
| To Kill a Mockingbird | Harper Lee | Classic | 11.99 |
| Murder on the Orient | Agatha Christie | Mystery | 10.99 |
| And Then There Were None | Agatha Christie | Mystery | 9.99 |
| Animal Farm | George Orwell | Satire | 8.99 |
7.2 LEFT JOIN — All Left Rows + Matching Right
-- All authors, including those with no books SELECT CONCAT(a.first_name, ' ', a.last_name) AS author, COUNT(b.book_id) AS book_count, IFNULL(SUM(b.price), 0) AS total_value FROM authors AS a LEFT JOIN books AS b ON a.author_id = b.author_id GROUP BY a.author_id, a.first_name, a.last_name ORDER BY book_count DESC;
| author | book_count | total_value |
|---|---|---|
| George Orwell | 2 | 21.98 |
| Agatha Christie | 2 | 20.98 |
| J.K. Rowling | 1 | 14.99 |
| Gabriel Garcia | 1 | 13.99 |
| Harper Lee | 1 | 11.99 |
7.3 JOIN Types Visual Reference
| JOIN Type | Returns | Use When |
|---|---|---|
| INNER JOIN | Only rows with matches in BOTH tables | You only want complete records |
| LEFT JOIN | All left rows + matching right (NULL if no match) | Keep all records from the left table |
| RIGHT JOIN | All right rows + matching left (NULL if no match) | Keep all records from the right table |
| CROSS JOIN | Every combination of rows (cartesian product) | Generate all combinations |
| SELF JOIN | Table joined to itself | Hierarchies, employee-manager |
7.4 Multi-Table JOIN — 3+ Tables
-- Create customers and orders for the bookstore CREATE TABLE customers ( customer_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(150) UNIQUE NOT NULL, city VARCHAR(80) ); INSERT INTO customers (name, email, city) VALUES ('Alice Wang', 'alice@mail.com', 'New York'), ('Bob Martinez', 'bob@mail.com', 'Chicago'), ('Carol Smith', 'carol@mail.com', 'London'); CREATE TABLE orders ( order_id INT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, book_id INT NOT NULL, quantity INT DEFAULT 1, order_date DATE, FOREIGN KEY (customer_id) REFERENCES customers(customer_id), FOREIGN KEY (book_id) REFERENCES books(book_id) ); INSERT INTO orders (customer_id, book_id, quantity, order_date) VALUES (1, 1, 2, '2024-03-01'), -- Alice ordered 1984 x2 (1, 3, 1, '2024-03-02'), -- Alice ordered Harry Potter (2, 4, 3, '2024-03-05'), -- Bob ordered To Kill x3 (3, 6, 1, '2024-03-08'); -- Carol ordered Christie -- 3-table JOIN: Customer + Order + Book + Author SELECT c.name AS customer, b.title AS book, CONCAT(a.first_name, ' ', a.last_name) AS author, o.quantity, b.price, o.quantity * b.price AS total FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c.customer_id INNER JOIN books AS b ON o.book_id = b.book_id INNER JOIN authors AS a ON b.author_id = a.author_id ORDER BY c.name;
| customer | book | author | quantity | price | total |
|---|---|---|---|---|---|
| Alice Wang | 1984 | George Orwell | 2 | 12.99 | 25.98 |
| Alice Wang | Harry Potter and the PS | J.K. Rowling | 1 | 14.99 | 14.99 |
| Bob Martinez | To Kill a Mockingbird | Harper Lee | 3 | 11.99 | 35.97 |
| Carol Smith | And Then There Were None | Agatha Christie | 1 | 9.99 | 9.99 |
7.5 SELF JOIN — Table Joined to Itself
CREATE TABLE employees ( emp_id INT PRIMARY KEY, name VARCHAR(60), manager_id INT, -- references emp_id of this same table department VARCHAR(50) ); INSERT INTO employees VALUES (1, 'Sarah CEO', NULL,'Executive'), (2, 'Alice', 1, 'Engineering'), (3, 'Bob', 1, 'Marketing'), (4, 'Carol', 2, 'Engineering'), (5, 'David', 2, 'Engineering'); -- Show each employee with their manager's name SELECT e.name AS employee, IFNULL(m.name, 'No Manager') AS manager FROM employees AS e LEFT JOIN employees AS m -- same table aliased twice! ON e.manager_id = m.emp_id;
| employee | manager |
|---|---|
| Sarah CEO | No Manager |
| Alice | Sarah CEO |
| Bob | Sarah CEO |
| Carol | Alice |
| David | Alice |
Subqueries & CTEs
8.1 Subqueries in WHERE
-- Books priced above the average SELECT title, price FROM books WHERE price > (SELECT AVG(price) FROM books) ORDER BY price DESC; -- Books by authors from Great Britain SELECT title FROM books WHERE author_id IN ( SELECT author_id FROM authors WHERE country = 'GB' );
| title | price |
|---|---|
| Harry Potter and the PS | 14.99 |
| One Hundred Years | 13.99 |
| 1984 | 12.99 |
8.2 Subqueries in FROM (Derived Tables)
-- Authors with more than 1 book (using derived table) SELECT author, book_count FROM ( SELECT CONCAT(a.first_name, ' ', a.last_name) AS author, COUNT(b.book_id) AS book_count FROM authors AS a LEFT JOIN books AS b ON a.author_id = b.author_id GROUP BY a.author_id ) AS author_counts -- derived table MUST have alias WHERE book_count > 1;
| author | book_count |
|---|---|
| George Orwell | 2 |
| Agatha Christie | 2 |
8.3 CTE — Common Table Expression (WITH)
-- Same query as above using CTE — much more readable! WITH author_counts AS ( SELECT a.author_id, CONCAT(a.first_name, ' ', a.last_name) AS author, COUNT(b.book_id) AS book_count, SUM(b.price) AS total_value FROM authors AS a LEFT JOIN books AS b ON a.author_id = b.author_id GROUP BY a.author_id ) SELECT author, book_count, total_value FROM author_counts WHERE book_count > 1 ORDER BY total_value DESC;
| author | book_count | total_value |
|---|---|---|
| George Orwell | 2 | 21.98 |
| Agatha Christie | 2 | 20.98 |
8.4 Window Functions — Advanced Analytics
-- ROW_NUMBER, RANK, DENSE_RANK SELECT title, genre, price, ROW_NUMBER() OVER (PARTITION BY genre ORDER BY price DESC) AS row_n, RANK() OVER (ORDER BY price DESC) AS price_rank, AVG(price) OVER (PARTITION BY genre) AS genre_avg, SUM(price) OVER () AS grand_total FROM books ORDER BY genre, price DESC;
| title | genre | price | row_n | price_rank | genre_avg | grand_total |
|---|---|---|---|---|---|---|
| To Kill a Mockingbird | Classic | 11.99 | 1 | 4 | 11.99 | 83.93 |
| 1984 | Dystopian | 12.99 | 1 | 3 | 12.99 | 83.93 |
| Harry Potter and the PS | Fantasy | 14.99 | 1 | 1 | 14.99 | 83.93 |
| One Hundred Years | Magical Realism | 13.99 | 1 | 2 | 13.99 | 83.93 |
| Murder on the Orient | Mystery | 10.99 | 1 | 5 | 10.49 | 83.93 |
| And Then There Were None | Mystery | 9.99 | 2 | 6 | 10.49 | 83.93 |
| Animal Farm | Satire | 8.99 | 1 | 7 | 8.99 | 83.93 |
Built-in Functions
9.1 String Functions
SELECT CONCAT('Hello', ' ', 'World') AS concat_ex, UPPER('hello') AS upper_ex, LOWER('WORLD') AS lower_ex, LENGTH('Python') AS len_ex, TRIM(' hello ') AS trim_ex, SUBSTRING('MySQL Mastery', 1, 5) AS substr_ex, REPLACE('Hello World', 'World', 'SQL') AS replace_ex, LEFT('MySQL', 3) AS left_ex, RIGHT('MySQL', 3) AS right_ex, INSTR('Hello World', 'World') AS pos_ex, LPAD('42', 6, '0') AS lpad_ex, REPEAT('AB', 3) AS repeat_ex;
| concat_ex | upper_ex | lower_ex | len_ex | trim_ex | substr_ex | replace_ex | left_ex | right_ex | pos_ex | lpad_ex | repeat_ex |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Hello World | HELLO | world | 6 | hello | MySQL | Hello SQL | MyS | SQL | 7 | 000042 | ABABAB |
9.2 Date & Time Functions
SELECT NOW() AS current_datetime, CURDATE() AS current_date, CURTIME() AS current_time, YEAR('2024-03-15') AS yr, MONTH('2024-03-15') AS mo, DAY('2024-03-15') AS dy, DAYNAME('2024-03-15') AS day_name, MONTHNAME('2024-03-15') AS month_name, DATEDIFF('2024-12-31', CURDATE()) AS days_left, DATE_ADD('2024-01-01', INTERVAL 30 DAY) AS plus_30, DATE_FORMAT('2024-03-15', '%d/%m/%Y') AS formatted;
| yr | mo | dy | day_name | month_name | plus_30 | formatted |
|---|---|---|---|---|---|---|
| 2024 | 3 | 15 | Friday | March | 2024-01-31 | 15/03/2024 |
9.3 Math Functions
SELECT ROUND(3.14159, 2) AS rounded, -- 3.14 CEIL(4.1) AS ceiling, -- 5 FLOOR(4.9) AS floored, -- 4 ABS(-42) AS absolute, -- 42 POWER(2, 10) AS two_to_ten, -- 1024 SQRT(144) AS sq_root, -- 12 MOD(17, 5) AS modulus, -- 2 RAND() AS random_num; -- 0 to 1
| rounded | ceiling | floored | absolute | two_to_ten | sq_root | modulus |
|---|---|---|---|---|---|---|
| 3.14 | 5 | 4 | 42 | 1024 | 12 | 2 |
9.4 CASE WHEN — Conditional Logic
SELECT title, price, stock, CASE WHEN price < 10 THEN 'Budget' WHEN price < 13 THEN 'Mid-range' ELSE 'Premium' END AS price_tier, CASE WHEN stock = 0 THEN 'Out of Stock' WHEN stock < 100 THEN 'Low Stock' WHEN stock < 200 THEN 'In Stock' ELSE 'Well Stocked' END AS stock_status FROM books ORDER BY price;
| title | price | stock | price_tier | stock_status |
|---|---|---|---|---|
| Animal Farm | 8.99 | 200 | Budget | Well Stocked |
| And Then There Were None | 9.99 | 175 | Budget | In Stock |
| Murder on the Orient | 10.99 | 160 | Mid-range | In Stock |
| To Kill a Mockingbird | 11.99 | 120 | Mid-range | In Stock |
| 1984 | 12.99 | 150 | Mid-range | In Stock |
| One Hundred Years | 13.99 | 80 | Premium | Low Stock |
| Harry Potter and the PS | 14.99 | 300 | Premium | Well Stocked |
UPDATE & DELETE
Always run a SELECT with the same WHERE clause FIRST to verify which rows will be affected before running UPDATE or DELETE. And always work with transactions when modifying data.
10.1 UPDATE
-- ALWAYS check what you're about to update first! SELECT * FROM books WHERE book_id = 1; -- Update a single row UPDATE books SET price = 13.99, stock = stock + 50 -- increment existing value WHERE book_id = 1; -- Update multiple rows — 10% price increase for all Mystery books UPDATE books SET price = ROUND(price * 1.10, 2) WHERE genre = 'Mystery'; -- Conditional update with CASE UPDATE books SET price = CASE WHEN genre = 'Fantasy' THEN price * 1.05 WHEN genre = 'Classic' THEN price * 0.95 -- discount classics ELSE price END;
10.2 DELETE
-- Delete a specific row DELETE FROM books WHERE book_id = 99; -- Delete with condition DELETE FROM books WHERE stock = 0 AND publish_date < '1950-01-01'; -- TRUNCATE — delete ALL rows (very fast, cannot roll back) TRUNCATE TABLE temp_table; -- Soft delete pattern (industry best practice) -- Instead of deleting, mark as deleted: ALTER TABLE books ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL; UPDATE books SET deleted_at = NOW() WHERE book_id = 5; -- "soft deleted" — still in DB, hidden from queries -- Query only active records SELECT * FROM books WHERE deleted_at IS NULL;
Indexes & Performance
11.1 What is an Index?
An index is a data structure that speeds up data retrieval at the cost of additional storage and slightly slower writes. Think of it as a book's index — instead of reading every page, you jump directly to the right page.
11.2 Creating Indexes
-- Single-column index CREATE INDEX idx_genre ON books(genre); CREATE INDEX idx_price ON books(price); -- Unique index (enforces uniqueness) CREATE UNIQUE INDEX idx_email ON authors(email); -- Composite index (columns used together in WHERE) CREATE INDEX idx_genre_price ON books(genre, price); -- Full-text index (for LIKE '%...%' type searches) CREATE FULLTEXT INDEX idx_title_ft ON books(title); -- Show all indexes on a table SHOW INDEX FROM books; -- Drop an index DROP INDEX idx_genre ON books;
| Table | Key_name | Column_name | Non_unique | Index_type |
|---|---|---|---|---|
| books | PRIMARY | book_id | 0 | BTREE |
| books | idx_genre | genre | 1 | BTREE |
| books | idx_price | price | 1 | BTREE |
| books | idx_title_ft | title | 1 | FULLTEXT |
11.3 EXPLAIN — Analyse Query Performance
-- Prepend EXPLAIN to see MySQL's query plan EXPLAIN SELECT title, price FROM books WHERE genre = 'Mystery' ORDER BY price;
| type | key | key_len | rows | Extra |
|---|---|---|---|---|
| ref | idx_genre | 203 | 2 | Using index condition; Using filesort |
| EXPLAIN type | Meaning | Performance |
|---|---|---|
| system / const | Single row match (PK/unique lookup) | ⚡ Best |
| ref | Non-unique index used | ✅ Good |
| range | Index used for BETWEEN / > / < | ✅ Good |
| index | Full index scan | ⚠️ OK |
| ALL | Full table scan — no index used! | 🔴 Fix this |
- Index columns used in
WHERE,JOIN ON, andORDER BY - Don't index every column — each index slows down INSERT/UPDATE/DELETE
- Composite indexes: put the most selective column first
LIKE '%term'(leading wildcard) cannot use a B-tree index — use FULLTEXT instead- Foreign key columns should almost always be indexed
Views & Stored Procedures
12.1 Views — Saved Queries
-- Create a view = saved SELECT query CREATE VIEW v_book_details AS SELECT b.book_id, b.title, CONCAT(a.first_name, ' ', a.last_name) AS author, a.country, b.genre, b.price, b.stock FROM books b JOIN authors a ON b.author_id = a.author_id; -- Query the view just like a table! SELECT * FROM v_book_details WHERE genre = 'Mystery'; -- List all views SHOW FULL TABLES WHERE Table_type = 'VIEW'; -- Drop a view DROP VIEW v_book_details;
| book_id | title | author | country | genre | price | stock |
|---|---|---|---|---|---|---|
| 6 | And Then There Were None | Agatha Christie | GB | Mystery | 10.99 | 175 |
| 7 | Murder on the Orient | Agatha Christie | GB | Mystery | 12.09 | 160 |
12.2 Stored Procedures
DELIMITER // -- change delimiter so ; inside proc doesn't end it CREATE PROCEDURE get_books_by_genre( IN p_genre VARCHAR(50), IN p_max_price DECIMAL(8,2), OUT p_count INT ) BEGIN SELECT title, price, stock FROM books WHERE genre = p_genre AND price <= p_max_price ORDER BY price; SELECT COUNT(*) INTO p_count FROM books WHERE genre = p_genre AND price <= p_max_price; END// DELIMITER ; -- restore delimiter -- Call the procedure CALL get_books_by_genre('Mystery', 15.00, @count); SELECT @count AS books_found;
| title | price | stock |
|---|---|---|
| And Then There Were None | 10.99 | 175 |
| Murder on the Orient | 12.09 | 160 |
12.3 Triggers
DELIMITER // CREATE TRIGGER trg_reduce_stock AFTER INSERT ON orders FOR EACH ROW BEGIN -- Automatically reduce book stock when an order is placed UPDATE books SET stock = stock - NEW.quantity WHERE book_id = NEW.book_id; END// DELIMITER ; -- Show all triggers SHOW TRIGGERS;
Transactions & ACID
13.1 ACID Properties
| Property | Meaning | Example |
|---|---|---|
| Atomicity | All operations succeed or all fail — never partial | Bank transfer: debit AND credit both happen, or neither |
| Consistency | Data always moves from one valid state to another | Account balance never goes negative if business rule forbids it |
| Isolation | Concurrent transactions don't interfere with each other | Two users booking the last seat can't both succeed |
| Durability | Committed changes survive crashes | Confirmed order stays after server restart |
13.2 Transactions — COMMIT & ROLLBACK
-- Setup CREATE TABLE accounts ( account_id INT PRIMARY KEY, name VARCHAR(60), balance DECIMAL(12,2) NOT NULL CHECK (balance >= 0) ); INSERT INTO accounts VALUES (1, 'Alice', 1000.00), (2, 'Bob', 500.00); -- ── Successful transfer ───────────────────────────── START TRANSACTION; UPDATE accounts SET balance = balance - 200 WHERE account_id = 1; UPDATE accounts SET balance = balance + 200 WHERE account_id = 2; COMMIT; -- permanently saves both changes SELECT * FROM accounts;
| account_id | name | balance |
|---|---|---|
| 1 | Alice | 800.00 |
| 2 | Bob | 700.00 |
START TRANSACTION; UPDATE accounts SET balance = balance - 2000 WHERE account_id = 1; -- ↑ This would make Alice's balance = -1200 → violates CHECK constraint! ROLLBACK; -- undo everything since START TRANSACTION -- Verify: Alice's balance is unchanged SELECT * FROM accounts;
| account_id | name | balance |
|---|---|---|
| 1 | Alice | 800.00 |
| 2 | Bob | 700.00 |
13.3 SAVEPOINTs
START TRANSACTION; INSERT INTO orders (customer_id, book_id, quantity, order_date) VALUES (1, 2, 1, CURDATE()); SAVEPOINT after_order; -- bookmark this point UPDATE books SET stock = stock - 99999 WHERE book_id = 2; -- Oops! Too large a deduction ROLLBACK TO SAVEPOINT after_order; -- go back to savepoint, keep order insert UPDATE books SET stock = stock - 1 WHERE book_id = 2; -- Correct deduction COMMIT;
Capstone Projects
- Tables: hotels, rooms, guests, bookings, payments
- Queries: Available rooms for a date range, revenue per hotel per month
- View: v_booking_summary with guest + room + hotel details
- Stored Procedure: book_room(guest_id, room_id, check_in, check_out)
- Trigger: Auto-update room availability status on booking insert/delete
- Transaction: Payment processing with rollback on failure
- Tables: users, products, categories, orders, order_items, reviews
- Window functions: Product ranking by revenue within each category
- CTE: Monthly revenue trend with month-over-month growth %
- Complex JOIN: Customer lifetime value calculation
- Subquery: Find customers who ordered but never reviewed
- Index optimisation: Use EXPLAIN to speed up slow queries
- Tables: doctors, patients, appointments, prescriptions, wards, staff
- Self JOIN: Doctor-to-supervisor hierarchy
- CASE WHEN: Patient risk scoring based on age and diagnosis
- Pivot: Appointments per doctor per weekday (using conditional aggregation)
- Stored Procedure: schedule_appointment with conflict checking
- Transaction: Transfer patient between wards atomically
Quick Reference
A.1 SQL Execution Order
-- Writing order (what you type): SELECT columns FROM table JOIN other_table ON condition WHERE row_filter GROUP BY grouping_column HAVING group_filter ORDER BY sort_column LIMIT n; -- Execution order (what MySQL does): -- 1. FROM + JOIN → determine all rows -- 2. WHERE → filter rows -- 3. GROUP BY → create groups -- 4. HAVING → filter groups -- 5. SELECT → choose columns + aliases -- 6. DISTINCT → remove duplicates -- 7. ORDER BY → sort -- 8. LIMIT/OFFSET → paginate
A.2 Essential SQL Commands
| Command | Purpose |
|---|---|
| CREATE DATABASE / TABLE | Create new database or table |
| USE db_name | Switch to a database |
| SHOW DATABASES / TABLES | List all databases or tables |
| DESCRIBE table | Show table structure |
| INSERT INTO … VALUES | Add new rows |
| SELECT … FROM … WHERE | Query data |
| UPDATE … SET … WHERE | Modify existing rows |
| DELETE FROM … WHERE | Remove rows |
| ALTER TABLE … ADD/MODIFY/DROP | Change table structure |
| DROP TABLE / DATABASE | Delete table or database (permanent!) |
| CREATE INDEX | Add an index for performance |
| EXPLAIN SELECT … | Analyse query execution plan |
| START TRANSACTION / COMMIT / ROLLBACK | Transaction control |
| CREATE VIEW | Save a query as a virtual table |
| CREATE PROCEDURE / CALL | Create and execute stored procedures |
A.3 String Functions Reference
| Function | Example → Result |
|---|---|
| CONCAT(a, b) | CONCAT('Hello',' World') → Hello World |
| CONCAT_WS(sep, a, b) | CONCAT_WS('-','2024','03','15') → 2024-03-15 |
| LENGTH(s) | LENGTH('Hello') → 5 |
| UPPER(s) / LOWER(s) | UPPER('hello') → HELLO |
| TRIM(s) | TRIM(' hi ') → hi |
| SUBSTRING(s,pos,len) | SUBSTRING('MySQL',1,2) → My |
| REPLACE(s,old,new) | REPLACE('aababc','ab','X') → aXXc |
| INSTR(s, sub) | INSTR('Hello','ll') → 3 |
| LEFT(s,n) / RIGHT(s,n) | LEFT('MySQL',2) → My |
| LPAD(s,n,pad) / RPAD | LPAD('42',6,'0') → 000042 |
| FORMAT(n, decimals) | FORMAT(1234567.89,2) → 1,234,567.89 |
A.4 Date Functions Reference
| Function | Example → Result |
|---|---|
| NOW() | 2024-03-15 14:30:00 |
| CURDATE() / CURTIME() | 2024-03-15 / 14:30:00 |
| YEAR(d) / MONTH(d) / DAY(d) | 2024 / 3 / 15 |
| DAYNAME(d) / MONTHNAME(d) | Friday / March |
| DATEDIFF(a, b) | DATEDIFF('2024-12-31','2024-01-01') → 365 |
| DATE_ADD(d, INTERVAL n UNIT) | DATE_ADD('2024-01-01', INTERVAL 1 MONTH) |
| DATE_SUB(d, INTERVAL n UNIT) | DATE_SUB(NOW(), INTERVAL 7 DAY) |
| DATE_FORMAT(d, fmt) | DATE_FORMAT(NOW(),'%d/%m/%Y') → 15/03/2024 |
| STR_TO_DATE(s, fmt) | STR_TO_DATE('15/03/2024','%d/%m/%Y') |
| TIMESTAMPDIFF(unit, a, b) | TIMESTAMPDIFF(YEAR, dob, NOW()) |
A.5 Common Interview Questions
-- 1. Nth highest salary / price SELECT MAX(price) FROM books WHERE price < (SELECT MAX(price) FROM books); -- 2nd highest -- 2. Find duplicates SELECT email, COUNT(*) AS cnt FROM authors GROUP BY email HAVING cnt > 1; -- 3. Delete duplicates, keep lowest ID DELETE FROM authors WHERE author_id NOT IN ( SELECT MIN(author_id) FROM authors GROUP BY email ); -- 4. Running total SELECT title, price, SUM(price) OVER (ORDER BY price) AS running_total FROM books; -- 5. Rows where value > group average (classic!) SELECT title, genre, price FROM books b WHERE price > ( SELECT AVG(price) FROM books WHERE genre = b.genre -- correlated subquery ); -- 6. Pivot (conditional aggregation) SELECT SUM(CASE WHEN genre = 'Mystery' THEN 1 ELSE 0 END) AS mystery_count, SUM(CASE WHEN genre = 'Fantasy' THEN 1 ELSE 0 END) AS fantasy_count, SUM(CASE WHEN genre = 'Classic' THEN 1 ELSE 0 END) AS classic_count FROM books;
You're now a SQL Developer!
Practice every day. Write queries for real datasets. Build something.