Overview — the big picture
Why SQL exists, how to think in it, and how databases actually work — before you write a single real query.
Why SQL exists
When does Excel break?
Drag to grow your dataset. See what survives.
Why exactly does Excel die?
- No real indexing — every filter scans the whole sheet.
- No safe concurrency — two people editing the same cell can corrupt data.
- No enforced rules — "fees must be > 0" is just a hope.
- Complex questions get painful — try "students who attended >80% but paid late" across 6 months.
- Weak audit and recovery — Excel assumes things go right. Databases assume things go wrong.
Excel isn't bad. It's just not designed for what databases are designed for.
The mental shift: HOW vs WHAT
Same goal, two mindsets
Find all active students.
active = []
for s in students:
if s.is_active:
active.append(s)
return active
SELECT * FROM students WHERE is_active = 1;
No loops. No counters. You describe the result; the engine picks the strategy — which index, what order, whether to parallelize.
When you write a Python loop, you're locked into your strategy. If the data grows 1000×, your code still does the same dumb scan.
In SQL, the same query runs on 100 rows or 100 million rows. The engine picks a different plan each time — index scan, hash join, parallel workers. You didn't write any of that. You just described what you wanted.
That separation between "what" and "how" is the whole point.
The restaurant analogy
The cleanest way to feel the difference.
- Get the pan
- Turn on the gas
- Add oil
- Chop onions, stir
- Watch the heat
You control every step.
- "I want a pepperoni pizza."
You describe the result. The kitchen handles the rest.
SEQUEL → SQL: the name story
Inside a database: pages, indexes, search
The four pieces
🎮 Find Aisha Khan
She's in row 37 of this 50-row table. Pick a search strategy and watch the difference.
Tables, rows, and columns
Anatomy of a table
Hover the row or column highlights to see what each piece is called.
| customer_id | name | signup_date | is_active | |
|---|---|---|---|---|
| 1 | Aisha Khan | aisha@example.com | 2024-01-10 | 1 |
| 2 | Raj Patel | raj.patel@sample.org | 2024-02-15 | 0 |
| 3 | Meera Joshi | meera.j@example.com | 2024-03-01 | 1 |
Relational vs NoSQL
- Tables connected via shared identifiers (keys)
- Strict schema — every row must fit
- Strong ACID guarantees
- Engines: Postgres, MySQL, Oracle, SQL Server
- Best for: banking, accounting, anywhere data must be 100% accurate
- No strict schema — flexible structure
- Related data often stored together (less joining)
- Optimized for fast reads at scale
- Engines: MongoDB, Cassandra, Redis, Neo4j
- Best for: social feeds, real-time chat, caches, big analytics
🎮 Pick the right tool
For each use case — which type fits better? Tap an option, see why.
OLTP vs OLAP
- Many users at once
- Tiny, frequent queries
- Fast response, strict consistency
- Example: mark attendance, book a cab, place an order
- Engines: Postgres, MySQL, Oracle
- Few users (analysts, dashboards)
- Huge scans across millions of rows
- Optimized for aggregation, not consistency
- Example: "average attendance by city over 5 years"
- Engines: Snowflake, BigQuery, Redshift
Side by side
| Aspect | OLTP — operations | OLAP — analysis |
|---|---|---|
| Primary goal | Run the business minute-by-minute | Understand and grow the business |
| Data age | Current, live | Historical (months, years) |
| Query type | Simple insert / select | Complex aggregations & sums |
| User base | Every customer & employee | Analysts & executives |
Transactions — pay fees demo
Run the transaction
One user action, three database steps. Run the happy path, then crash it on purpose.
Before changing real data, the database writes its intent to a log on disk:
LOG: "About to change wallet 10000 → 5000" LOG: "About to insert payment row id=789" LOG: "About to update student status to 'paid'" LOG: COMMIT
On crash + restart, the database scans the log:
- If it sees
COMMIT→ reapply any unwritten changes. - If it doesn't see
COMMIT→ undo the partial changes.
That's the whole magic.
ACID — the four promises
Every serious database is designed around these four guarantees. Click any card for the deep version.
DBMS vs RDBMS
The hierarchy
Click any node to see what it actually means.
The rule to remember
DBMS = the broad category of database engines. RDBMS = the relational kind specifically.
Installation & tools
SQL is learned by doing: write → run → observe → fix → repeat. First, build a setup where practicing is effortless.
Where do you actually run SQL?
The three options
🎮 Feel the latency
Hit each "send" button. The difference is the whole reason.
Set up MySQL with Docker
Why Docker beats native install
- Different installer per OS
- Default paths differ
- Port 3306 conflicts
- Version mismatches
- Permission issues
- Same image for everyone
- Same container internals
- Steps identical on Mac, Win, Linux
- No path issues
- Clean and isolated
Image vs container — click to compare
🎮 Configure your container
Fill the form like you would in Docker Desktop. Hit "Start" — wrong or missing values show what would break.
Connect with Beekeeper Studio
Beekeeper is the UI that talks to your container. Cleaner than MySQL Workbench, beginner-friendly.
CREATE DATABASE tuf_sql_3;, the new database may not appear in Beekeeper's left sidebar immediately. Hit the refresh button on the sidebar panel — it's not a failure, just a UI caching thing.
Database & table basics
Now you actually build things: command families, data types, tables, keys, constraints, and how a query runs under the hood.
The 5 SQL command families
Every SQL command belongs to one of five families. Click a card to see what it does.
🎮 Which family does each command belong to?
Tap the family for each command. Wrong picks turn red.
SELECT = select), but object names like database/table names can be case-sensitive depending on settings and OS.DDL vs DML — the house analogy
The two families you'll use most. One shapes the house; the other lives in it.
- Build a room → CREATE
- Change the plan → ALTER
- Knock it down → DROP
- Add a sofa → INSERT
- Swap the TV → UPDATE
- Throw out trash → DELETE
DELETE is DML — removes rows one by one, can be rolled back in a transaction. TRUNCATE is DDL — wipes the whole table instantly by resetting it, usually can't be undone. DROP removes the table itself, structure and all.Database setup commands
The essentials
-- create a database CREATE DATABASE tuf_sql_2; -- list all databases SHOW DATABASES; -- switch into one (all later queries run inside it) USE tuf_sql; -- delete a database (irreversible!) DROP DATABASE tuf_sql_2; -- safe create: no error if it already exists CREATE DATABASE IF NOT EXISTS tuf_sql;
DROP DATABASE is irreversible in most setups — it removes the whole database. Handle with care.
SHOW DATABASES (plural) but CREATE DATABASE name (singular). Mental model: a database is the whole filing cabinet, a table is a drawer, a row is a file. And there's no recycle bin — DROP DATABASE is gone for good, which is why pros often have DROP permissions removed.Common data types
What each column can hold
A data type defines what kind of value a column stores.
| Type | Stores | Example |
|---|---|---|
| INT | Whole numbers — ids, counts, ages | age INT |
| BIGINT | Very large whole numbers | views BIGINT |
| VARCHAR(n) | Variable text up to n chars | email VARCHAR(50) |
| TEXT | Long text — blogs, reviews | description TEXT |
| DATE | Date only | dob DATE |
| DATETIME | Date + time | created_at DATETIME |
| BOOLEAN | true/false (1/0) | is_active BOOLEAN |
| DECIMAL(p,s) | Exact decimals — money | balance DECIMAL(10,2) |
🎮 Pick the right type
For each thing you need to store, tap the best data type.
FLOAT for money — rounding errors creep in; use DECIMAL. Don't store numbers or dates as VARCHAR (you lose sorting and math). And don't oversize: a 3-letter country code is VARCHAR(3), not VARCHAR(255).Create tables, insert, select
Create & drop a table
CREATE TABLE IF NOT EXISTS users ( user_id INT, name VARCHAR(30), email VARCHAR(50), is_active BOOLEAN, account_balance DECIMAL(10,2), signup_datetime DATETIME ); SHOW TABLES; -- list tables in the current database DROP TABLE IF EXISTS users; -- IF EXISTS avoids an error if missing
Insert rows
-- single row INSERT INTO users (user_id, name, email) VALUES (1, 'Raj', 'raj@gmail.com'); -- multiple rows at once INSERT INTO users (user_id, name, email) VALUES (2, 'Mark', 'mark@gmail.com'), (3, 'Albert', 'albert@gmail.com'), (4, 'Peter', 'peter@gmail.com');
Read rows + alias
SELECT * FROM users; -- everything (avoid on huge tables) SELECT name, email AS mail_id -- AS renames the column in output FROM users;
Avoid SELECT * on big tables in real systems. Ask only for the columns you need.
Build the query
Drag the right chip into each slot. Wrong piece bounces back. 5 puzzles, real SQL.
Keys: primary, foreign, composite
Keys are how rows stay unique and how tables connect. Click a card.
See the relationship
A foreign key in orders points at the primary key of users.
CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), FOREIGN KEY (user_id) REFERENCES users(user_id) );
If a table is referenced by a foreign key, drop the child table first (dropordersbeforeusers).
Primary key vs UNIQUE
Easy to confuse — here's the clean split.
| Feature | Primary key | UNIQUE |
|---|---|---|
| How many per table | Exactly one | Many allowed |
| NULLs | Never allowed | Usually allowed |
| Purpose | Identifies the whole row | Prevents duplicates in a column |
| Typical use | user_id | email, phone_number |
🎮 Will the foreign key let it in?
The users table has user_id 1 and 2. Predict PASS or FAIL for each order insert — the FK rejects "ghost" users.
Keys & relationships
A relationship links two tables via a primary key + foreign key. This is why we call them relational databases. Click a type.
Many-to-Many needs a junction table
A student joins many batches; a batch has many students. A third table in the middle holds the pairs.
SELECT students.name, batches.batch_name FROM enrollments JOIN students ON enrollments.student_id = students.student_id JOIN batches ON enrollments.batch_id = batches.batch_id;
Normalization = splitting data into related tables so nothing repeats. Store Raj's email once in students; his six fee rows just reference his id. Change the email in one place and every fee row stays correct. This avoids "update anomalies".
Self-referencing = a table points at itself. A mentor is also a student, so mentor_id is a foreign key back into the same students table:
SELECT s1.name AS student, s2.name AS mentor FROM students s1 LEFT JOIN students s2 ON s1.mentor_id = s2.student_id;
🎮 What relationship is this?
For each scenario, pick the relationship type.
Constraints — the table's rulebook
Constraints are rules the database enforces for you. Click one.
🎮 Will the insert survive?
Our users table has these rules. Predict PASS or FAIL for each insert.
CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30), email VARCHAR(50) NOT NULL UNIQUE, age INT CHECK (age >= 15 AND age <= 30), is_active BOOLEAN DEFAULT TRUE );
NULL vs 0 vs empty string
🎮 Which one fits?
For each scenario, pick the right representation.
How they behave in queries
| Concept | How to check | In AVG() / SUM() |
|---|---|---|
| NULL | WHERE col IS NULL | Ignored |
| 0 | WHERE col = 0 | Included |
| '' (empty) | WHERE col = '' | Not a number |
Never writeWHERE col = NULL— it never matches. UseIS NULL. AndCOUNT(col)skips NULLs, whileCOUNT(*)counts every row.
Query lifecycle: parse → plan → execute
When you run a query, the database doesn't answer instantly. It goes through three stages.
SELECT name, email FROM customers WHERE city = 'Mumbai';
Indexing — the book at the back
How an index works
You saw the speed difference in the find-Aisha game. Here's what's actually happening.
An index is a separate, automatically-sorted mini-table holding two things: a sorted copy of the column (e.g. all emails A→Z) and a pointer to the real row. Because it's sorted, the database uses binary search — 1,000,000 rows might need only ~20 lookups instead of a million.
-- jump straight to a value instead of scanning CREATE INDEX idx_user_email ON users(email); -- one index covering two columns searched together CREATE INDEX idx_name_city ON users(full_name, city);
Full scan vs index lookup
| Feature | Full table scan | Index lookup |
|---|---|---|
| Search speed | Slow (linear) | Fast (logarithmic) |
| Disk space | Main table only | Extra space for the index |
| Insert speed | Fast | Slower (must update index) |
| Best for | Small tables | Large tables |
🎮 Should you index this column?
Tap yes or no for each. Think about how often it's searched vs how often it changes.
Query fundamentals
Reading data: SELECT, WHERE, operators, ORDER BY, LIMIT, DISTINCT, AS. Every playground below runs on a real countries table.
The table we'll use
A 5-row countries table. Every example below queries this.
| id | country | population | area | region |
|---|---|---|---|---|
| 1 | India | 1,400,000,000 | 3,287,000 | Asia |
| 2 | China | 1,200,000,000 | 4,287,000 | Asia |
| 3 | Brazil | 214,000,000 | 8,516,000 | South America |
| 4 | Australia | 25,700,000 | 7,692,000 | Oceania |
| 5 | USA | 331,000,000 | 9,834,000 | North America |
SELECT & FROM
The order the database really runs it
You write SELECT first, but the engine starts with FROM.
🎮 Pick your columns
Tick the columns you want. The query and result update live.
SELECT country, population FROM countries;
SELECT can do more than fetch
-- math on the fly, named with AS SELECT country, population / area AS density FROM countries; -- a static label column SELECT country, 'In Asia region' AS note FROM countries;
SELECT name, age, FROM… is wrong). Avoid SELECT * in real apps — fetching 50 columns when you need 2 wastes memory. Column order in your SELECT is the order they appear in the output.WHERE — the gatekeeper
WHERE checks every row: condition true → row stays, false → row blocked. It runs before SELECT, so you can filter on a column you don't display.
SELECT * FROM countries WHERE region = 'Asia';
✓ = row kept, ✗ = row blocked by the gatekeeper.
region = 'Asia'), numbers don't. Matching is usually case-sensitive. And you can't use a SELECT alias in WHERE — WHERE runs before the alias exists.Comparison operators
Swap any of these into the WHERE playground above.
| Operator | Meaning | Example |
|---|---|---|
< | Less than | population < 50000000 |
> | Greater than | area > 5000000 |
<= | Less than or equal | area <= 4287000 |
>= | Greater than or equal | population >= 331000000 |
= | Equal to | region = 'Asia' |
!= / <> | Not equal to | region != 'Asia' |
= for comparison (not == like Python). Don't quote numbers — price > '100' forces a slow text conversion. For <= / >= the equals sign comes second.Logical AND / OR / NOT
Two conditions on every row. Flip the connector and watch which countries survive.
SELECT * FROM countries WHERE population > 50000000 AND area < 5000000;
The precedence trap
SQL applies AND before OR. Without parentheses you can get surprises — always group OR conditions.
-- "Accessories or Cables, AND under 20" WHERE (category = 'Accessories' OR category = 'Cables') AND price < 20; -- brackets force OR first
region = 'Asia' OR 'Oceania' is wrong; write region = 'Asia' OR region = 'Oceania'. And region = 'Asia' AND region = 'Oceania' returns zero rows — one row can't be two regions.Arithmetic operators
| Operator | Does | Example |
|---|---|---|
+ - * / | Add, subtract, multiply, divide | SELECT 10 * 5; |
% | Modulus (remainder) | SELECT 10 % 3; → 1 |
DIV | Integer division (MySQL) | SELECT 7 DIV 2; → 3 |
🎮 Compute population density
density = population / area. Hit the button to calculate it for every row on the fly.
NULL plus a warning. The production-safe pattern is population / NULLIF(area, 0): if area is 0, NULLIF turns it into NULL, so you divide by NULL (= NULL) with no warning.ORDER BY & LIMIT
Sort the rows, then keep only the top few. Classic "Top N" pattern.
SELECT country, area FROM countries ORDER BY area ASC LIMIT 5;
ASC (small→large, A→Z). LIMIT goes last in the query. You can sort by multiple columns — ORDER BY region ASC, area DESC uses the second as a tie-breaker. You can even sort by an alias.DISTINCT & AS (aliases)
DISTINCT removes duplicate values. AS renames a column header for the output (the real table is unchanged).
SELECT DISTINCT region AS unique_region FROM countries;
DISTINCT goes right after SELECT. DISTINCT region, country dedupes unique pairs, not each column alone. And you can't use an alias in WHERE (it's created after filtering). Prefer underscores over spaces in alias names.Predict the result
🎮 How many rows comes back?
Read each query against the 5-row countries table and pick the row count.
Filtering essentials
Real data is messy — missing values, ranges, patterns. Meet the operators that handle it: IS NULL, IN, BETWEEN, LIKE (and their NOT forms).
A deliberately messy users table
Look at the city column: real values, a NULL, an empty string, even a single space. That's why we need special operators.
NULL≠ empty string ≠ a space ≠ zero. NULL means "unknown / not recorded" — so it can't be compared with=or!=.
IS NULL / IS NOT NULL
Pick a column and a mode. Toggle normalization to treat empty strings and spaces as "missing" too.
SELECT id, email, city FROM users WHERE city IS NULL;
NULLIF(TRIM(city), '') IS NOT NULL trims spaces, turns the result into NULL if it's empty, and only keeps truly-present values — folding NULL, '', and ' ' all into "missing".IN / NOT IN
IN is a clean shortcut for many ORs. Pick cities to build the list, then flip IN ↔ NOT IN.
SELECT id, email, city FROM users WHERE city IN ('Delhi', 'Mumbai');
NOT IN ('Delhi', NULL) — you get zero rows, because the DB can't prove a value isn't equal to "unknown". Keep NULLs out of NOT IN lists.BETWEEN / NOT BETWEEN
Inclusive range on last_purchase_inr. Both ends count. NULLs (never purchased) never match a range.
SELECT full_name, last_purchase_inr FROM users WHERE last_purchase_inr BETWEEN 400 AND 800;
BETWEEN 1000 AND 500 returns nothing. It's inclusive (both ends included). NULLs never match a range. With dates, '2025-12-31' means midnight, so events later that day get missed unless you include the time.LIKE / NOT LIKE
Type a pattern to match against email. % = any number of characters, _ = exactly one.
SELECT id, email FROM users WHERE email LIKE '%@tuf.com';
_ or %, escape it with a backslash. LIKE 'test\_user%' matches a real underscore, so it finds test_user1 but not testXuser2.Which operator fits?
🎮 Pick the right tool for the job
For each goal, tap the operator you'd reach for.
Aggregation & grouping
Stop reading rows one by one. Bucket them, then summarize: GROUP BY, SUM/AVG/MIN/MAX, COUNT, and HAVING.
GROUP BY & aggregates
The bucket model
Picture a pile of balls. GROUP BY color drops them into buckets by colour. Then an aggregate (count, sum, average…) runs inside each bucket and returns one row per bucket.
The employees table
10 employees across 3 projects. Note Dave's NULL experience and Hank's NULL hours.
🎮 Group & summarize
Choose how to bucket the rows and what to compute in each bucket.
AVG(hours_logged) for Alpha = (100+120+105+0)/4 = 81.25 — Dave's 0 drags it down. If Dave's hours were NULL instead, he'd be skipped and the average would be higher.SELECT employee_name … GROUP BY project fails (which name?). Don't group by a primary key (every bucket holds one row). SUM/AVG need numbers; MIN/MAX also work on text (alphabetical).COUNT — four flavours
The registrations table
10 sign-ups. Some have NULL names or emails; Aisha registers several times.
🎮 Count it different ways
Same table, very different answers depending on what (and how) you count.
COUNT(*) = total volume (counts every row, NULLs included). COUNT(col) = data completeness (skips NULLs). COUNT(DISTINCT col) = true audience size (skips NULLs and duplicates). DISTINCT is slower on huge tables.HAVING — filter the buckets
WHERE filters rows before grouping; HAVING filters buckets after the math. That's why WHERE SUM(...) > 100 fails — the sum doesn't exist yet at the WHERE step.
✓ = bucket kept, ✗ = bucket filtered out by HAVING.
🎮 WHERE or HAVING?
For each filter, decide where it belongs.
Predict the result
🎮 What does each query return?
Read the aggregate query and pick the answer.
Numeric & NULL functions
Reshape values per row: round and absolute, pick the biggest/smallest across columns, and tame missing data with IFNULL, NULLIF, and COALESCE.
The students table
Note the messy phone (NULL vs empty), and NULLs in fees, scores, and tests.
ROUND & ABS
ROUND(n, d) rounds to d decimals — and d can be negative (round to tens, hundreds). ABS(n) drops the sign. NULL stays NULL for both.
ROUND(fee, -2) rounds to the nearest 100 (2804.45 → 2800). ABS is great for "size of change regardless of direction" — WHERE ABS(score_change) > 20 finds the biggest swings up or down. To cut decimals without rounding, use TRUNCATE / FLOOR instead.GREATEST / LEAST
Unlike MAX/MIN (which work down a column), these work across a row. If any value is NULL, the result is NULL — toggle the fix to see why.
IFNULL & NULLIF
IFNULL(col, x) swaps a NULL for x — but it does not catch empty strings. Toggle NULLIF(phone, '') to fold '' into NULL first, then watch Rohan/Imran/Tanya get caught too.
IFNULL only replaces true NULLs. Empty string means "data exists but is blank", so it slips through. The combo IFNULL(NULLIF(phone, ''), 'Missing') handles both. And IFNULL changes the output, not the rows — to hide them you still need WHERE … IS NOT NULL.COALESCE
The coalesce_demo table
Three email columns with NULLs (and one empty string). COALESCE returns the first non-NULL.
Build a priority chain. Toggle the columns and order matters left-to-right. Add a default for when all are missing.
IFNULL takes exactly 2 args (value + one backup). COALESCE takes an unlimited list and returns the first non-NULL — perfect for fallback chains and safe math like COALESCE(hours, 0) + COALESCE(bonus, 0). Like IFNULL, it treats '' as a real value unless you NULLIF it first.IS NULL vs IFNULL vs COALESCE vs NULLIF
Quick reference
| Tool | Does | Used in |
|---|---|---|
IS NULL / IS NOT NULL | Tests if a value is missing / present | WHERE (filter rows) |
IFNULL(col, x) | Replaces NULL with one backup value | SELECT (clean output) |
COALESCE(a, b, …) | Returns the first non-NULL from a list | SELECT (clean output) |
NULLIF(a, b) | Returns NULL if a = b (e.g. turn '' into NULL) | SELECT (pre-clean) |
🎮 Pick the right tool
For each goal, tap the function you'd reach for.
CASE & conditional logic
SQL's if/else. Checked top to bottom, returns the first match (or ELSE, or NULL). From simple labels to the pro "conditional aggregation" pivot trick.
The shape of a CASE
CASE WHEN condition1 THEN value1 -- checked first WHEN condition2 THEN value2 -- then this ELSE fallback_value -- optional; NULL if omitted END AS label -- must close with END
| Type | Matches | Best for |
|---|---|---|
| Searched CASE | Boolean conditions (>, <, BETWEEN, IS NULL) | Thresholds, ranges, validations |
| Simple CASE | Equality against fixed values (= 'P') | Direct code → label mapping |
Classify with CASE
The products table
🎮 Price classifier — drag the thresholds
Move the cut-offs and watch every product re-label live. NULL price → "Missing Price".
END. Omit ELSE and unmatched rows become NULL. All THEN results should be the same data type.🎮 Searched or Simple?
Ranges and IS NULL → Searched. Mapping fixed codes → Simple.
Boolean verdicts & bonus tiers
🎮 PASS / FAIL threshold
The rules table. Slide the pass mark; NULL stays "Missing".
CASE + COALESCE: tiered bonus
NULL score → bonus 0 (via COALESCE), ≥90 → 20%, ≥75 → 10%, else 5%.
CASE WHEN COALESCE(performance_score, -1) = -1 THEN 0 WHEN performance_score >= 90 THEN salary * 0.20 WHEN performance_score >= 75 THEN salary * 0.10 ELSE salary * 0.05 END AS bonus_amount
NULL = NULL is UNKNOWN, not true — so WHEN value = NULL never fires. Use IS NULL, or MySQL's null-safe <=>, or wrap with COALESCE as above. That's why the bonus rule uses COALESCE(score, -1) = -1 to catch missing scores.Conditional aggregation (pivoting)
Put CASE inside SUM/COUNT to turn many rows into one side-by-side summary row — exactly how dashboards show "Revenue India vs USA" together. Pick a pivot:
SUM(CASE …) always add ELSE 0 so no-match doesn't become NULL. COUNT(CASE WHEN … THEN 1 END) works because COUNT ignores the NULLs from unmatched rows. EXISTS-in-CASE (node classification) is powerful but slow on big tables.Predict the CASE output
🎮 What does CASE return?
Read each rule set and the row, then pick the result.
String functions
Real text is messy — mixed case, accents, stray spaces. These functions normalize, slice, search, and join it. Type into any box and watch the output live.
Collation — when are two strings "equal"?
Collation decides comparison rules. Four flags you'll see in names like utf8mb4_0900_ai_ci:
| Flag | Meaning | café vs cafe / Apple vs apple |
|---|---|---|
ai | Accent-insensitive | café = cafe |
as | Accent-sensitive | café ≠ cafe |
ci | Case-insensitive | Apple = apple |
cs | Case-sensitive | Apple ≠ apple |
🎮 Compare under different collations
CONCAT vs CONCAT_WS
The customers table
Neha and Aayush have no middle name (NULL). Watch what each function does.
CONCAT returns NULL if any argument is NULL (the NULL trap). CONCAT_WS(sep, …) sets the separator once and skips NULLs.
String lab
Type anything — include spaces, an accent (é), or an emoji 😀 — and see every function applied at once. Brackets [ ] reveal where spaces are.
CHAR_LENGTH counts characters (what humans see), LENGTH counts bytes. In UTF-8, English = 1 byte, é = 2 bytes, 😀 = 4 bytes. Use CHAR_LENGTH for password/length checks; LENGTH for storage limits. A row has special characters when LENGTH > CHAR_LENGTH.LEFT / RIGHT / SUBSTRING & LOCATE / INSTR
🎮 Slice a string (1-based indexing)
🎮 Find a position
Both return the 1-based position (0 if not found). Only the argument order differs: LOCATE(needle, haystack) vs INSTR(haystack, needle).
0 from LOCATE/INSTR means "not found". RIGHT(filename, 4) is the easy way to grab a .ext. SUBSTRING(code, 5, 4) pulls a year out of MKT-2023-001.REPLACE & LIKE
🎮 REPLACE every occurrence
Swaps all matches (case-sensitive). Try a breadcrumb: replace / with > .
REPLACE('Apple','apple','X') does nothing). It changes every occurrence — replacing cat in category gives dogegory. In a SELECT it only changes the output; use UPDATE to change stored data. NULL in → NULL out. Nest them: REPLACE(REPLACE(url,'tuf.com','takeuforward.org'),'/',' > ').LIKE pattern matching — quick recap
% = any length (0+), _ = exactly one character. A comparison returns 1 (true) or 0 (false).
'plus_user' LIKE 'plus%' -- 1 (starts with plus) 'plus1user' LIKE 'plus_us_r' -- 1 (each _ matches one char) 'USR_8' LIKE 'USR__' -- 1 (one char after USR_) 'USR_99' LIKE 'USR__' -- 0 (two chars after) 'plus1user' LIKE 'plus\_user' -- 0 (\_ = a literal underscore)
There's a full live LIKE playground (type a pattern, watch rows match) in the Filtering essentials module.
Predict the output
🎮 What does each call return?
Editing data & tables
The write side of SQL: INSERT, UPDATE, DELETE, UPSERT, ALTER, TRUNCATE. Every panel below mutates one shared employees sandbox — break it, then hit Reset.
INSERT
Add a row. emp_id is the primary key — a duplicate is rejected. Leave department blank to insert NULL.
'Kevin O''Brian'. Use functions inline: hire_date = CURRENT_DATE. Copy from another table: INSERT INTO employees (…) SELECT … FROM draft_hires;. Always list columns — never rely on table order.UPSERT
"Insert if new, otherwise update." Needs a primary/unique key to detect the collision. Pick how a conflict is handled, then try an existing id (301) vs a new one.
UPDATE — mind the WHERE
Set a column where a condition holds. Pick no WHERE to feel the "global update" danger — it hits every row.
DELETE & TRUNCATE
DELETE vs TRUNCATE vs DROP
| Feature | DELETE | TRUNCATE | DROP |
|---|---|---|---|
| Removes | Selected (or all) rows | All rows | Entire table + structure |
| WHERE allowed? | Yes | No | No |
| Table remains? | Yes | Yes | No |
| Speed | Slower (row-by-row) | Faster (bulk wipe) | Fast (drops object) |
🎮 The AUTO_INCREMENT difference
Same goal — empty the table, then insert "Raj". But the next id depends on how you cleared it. Watch the counter.
DELETE is logged row-by-row, so it's slower but you can wrap it in a transaction and ROLLBACK. TRUNCATE resets the AUTO_INCREMENT counter to 1; DELETE keeps counting. Foreign keys enforce referential integrity — the DB blocks deleting a parent row that children reference, unless you use ON DELETE CASCADE. And deleting a non-existent row isn't an error — just "0 rows affected".ALTER
Change the table's shape, not its rows. Add a column (NULL for existing rows), backfill it, then drop it.
MODIFY salary DECIMAL(12,2) changes the type/constraints, keeps the name. CHANGE salary monthly_salary DECIMAL(10,2) also renames (name written twice, type re-stated). Use MODIFY unless you specifically need to rename — accidental renames with CHANGE cause real production bugs. Also: RENAME TO employees_v2 renames the table.RENAME COLUMN hire_date TO joining_date changes the name only — old queries break. New columns land at the end by default; MySQL's AFTER colname places them. Use DROP COLUMN IF EXISTS in cleanup scripts so they don't crash. On big tables ALTER can lock the table for minutes, so it's run during maintenance windows. Shrinking a type (VARCHAR(100)→VARCHAR(10)) can truncate existing data.Which command?
🎮 Pick the right write operation
Merging query results
Stack the rows of two SELECTs into one result. UNION removes duplicates; UNION ALL keeps them and is faster. There's no built-in intersection in older MySQL — so you build it with logic.
What "merging" really means
You are not appending table B to table A. You are creating one combined result set. SQL stacks the rows vertically — so both queries must line up.
| The 3 rules | What it means | If broken |
|---|---|---|
| Same number of columns | 2 columns can only merge with 2 columns | Query errors out |
| Compatible data types | Column 1 here ↔ column 1 there must match | Error or junk data |
| Matched by position | SQL aligns by order, not by name | Values land in the wrong column |
UNION — the unique master list
The two office tables
Two offices, two staff tables. Neha Sharma works in both.
🎮 Merge lab — watch duplicates appear & vanish
Flip between the two operators and choose which columns to merge. Rows SQL throws away are struck through.
name, role selected, two people named "Neha Sharma" with different roles are not duplicates. Sort the final list with one ORDER BY at the very end — never inside an individual SELECT.UNION ALL — keep every row
Sales come from two channels. To total revenue or count packages, every transaction must survive — even a repeated Mouse. UNION ALL just pastes the rows together: no sort, no compare, no filter.
🎮 Consolidate sales — and why the total breaks under UNION
Same data, two operators. Notice the revenue total change when a real duplicate sale gets silently deleted.
orders_2023 + orders_2024) or meaningful (every sale, every log line). Reach for plain UNION only when you truly need a deduplicated list — its hidden sort is expensive on millions of rows.UNION vs UNION ALL
| Feature | UNION | UNION ALL |
|---|---|---|
| Duplicate rows | Removed | Kept |
| Performance | Slower — sort + distinct | Faster — just append |
| Internal work | Sorts & compares every row | No filtering at all |
| Best for | Unique list (mailing list) | Totals & raw logs |
UNION ALL dumps the second stack on the first — one move. UNION checks each paper against the pile, sorts them, and throws out copies. With millions of papers, that sorting is the "hidden cost".Decision matrix
- Are duplicates even possible? No →
UNION ALL(faster, nothing to remove). - Do you want to keep them? Yes →
UNION ALL(e.g. counting sales). No →UNION(e.g. a contact list).
Intersection & set difference
The set explorer
Two customer lists. Pick a set operation and watch which buckets it pulls from. store_customers = Raj, Neha, Sana · online_customers = Raj, Amit, Sana.
Building INTERSECT by hand
Older MySQL has no INTERSECT. The mindset: if SQL won't hand you a command, express it with logic. Stack both lists with UNION ALL, group, and keep anything that appears more than once.
SELECT customer_name
FROM (
SELECT customer_name FROM store_customers
UNION ALL -- must be ALL, or counts collapse to 1
SELECT customer_name FROM online_customers
) AS combined_list
GROUP BY customer_name
HAVING COUNT(*) > 1; -- appeared in both → intersection
UNION ALL→ [Raj, Neha, Sana, Raj, Amit, Sana]GROUP BY→ Raj (2), Neha (1), Sana (2), Amit (1)HAVING COUNT(*) > 1→ Raj, Sana
UNION ALL. Plain UNION dedupes first, so every count becomes 1 and the intersection silently returns nothing. For set difference ("in A but not B"), a LEFT JOIN ... WHERE b.col IS NULL does the job where EXCEPT / MINUS isn't available.Which operator & what's the output?
🎮 UNION or UNION ALL?
Read each business goal and pick the right tool.
🎮 Predict the merged result
Lists A = [X, Y] and B = [Y, Z] unless noted. How many rows come back?
Joins deep dive
Real data lives in separate tables that connect through shared keys. JOIN stitches their rows back together. Pick the join type, watch which rows survive, and learn the one trap that quietly turns every outer join into an inner one.
What a JOIN actually does
A join walks the rows of two tables and pairs them up wherever the ON condition is true. What you change is which unmatched rows are kept — and that single choice is the whole difference between INNER, LEFT, RIGHT and FULL.
| Join | Keeps unmatched from… | Plain-English ask |
|---|---|---|
| INNER | Neither — matches only | "Only rows that exist in both." |
| LEFT | Left table | "All customers, orders if any." |
| RIGHT | Right table | "All orders, customer if known." |
| FULL OUTER | Both tables | "Everything from either side." |
| CROSS | No ON — every pair | "All combinations (m × n)." |
The two tables
Everything below runs against these two. Note the deliberate gaps: customer 105/999 never existed, order 7 has a NULL customer, and several customers never ordered.
Join visualizer — flip the type, watch the rows
🎮 One query, five join types
Same customers c + orders o, same ON c.customer_id = o.customer_id. Only the join word changes. Rows filled with NULL are the "kept anyway" rows — proof of what each join preserves.
A LEFT JOIN B is identical to B RIGHT JOIN A. Most people pick a side and stick with LEFT JOIN because reading parent → child top-to-bottom feels natural — but you'll meet RIGHT JOIN in other people's code, so know both.INNER JOIN aggregates cleanly
Because INNER drops unmatched rows, there are no stray NULLs to poison a SUM or COUNT. Revenue + order count per customer, computed live:
SELECT c.customer_id, c.name,
COUNT(*) AS orders_count,
SUM(o.total_amount) AS revenue
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;
ON vs WHERE — the soft and hard filter
🎮 Same filter, two places, very different results
We LEFT JOIN customers to orders and want only PAID orders. Put that condition in ON (during the join) versus WHERE (after) and watch the "zero-order" customers vanish.
| Aspect | ON (join condition) | WHERE (filter) |
|---|---|---|
| When | During matching | After the join is built |
| Effect on unmatched rows | Kept, filled with NULL | Thrown away (NULL ≠ value) |
| Use it to… | Control what counts as a match | Trim the final result |
WHERE o.status = 'PAID' on a LEFT JOIN silently deletes every customer with no order — their status is NULL, and NULL = 'PAID' is never true. Your LEFT JOIN just became an INNER JOIN. Want to keep them? Put the condition in ON, or filter for IS NULL on purpose (the "who never ordered?" query).FULL OUTER JOIN & the COALESCE fix
The audit tables
A merger-style audit: keep every student and every payment. Note Raj has a NULL id, and payment for student 4 is an orphan (no such student).
🎮 Raw full join vs COALESCE master-id
MySQL has no FULL OUTER JOIN — you stitch LEFT JOIN + RIGHT JOIN with UNION. The raw version leaves the id NULL for the orphan payment (its student row doesn't exist). COALESCE falls back to the payment's id to build one clean key.
4 and the NULL-id Raj aren't query bugs — they're data-quality findings the join surfaced. A WHERE on the nullable side (e.g. WHERE s.name = 'Aisha') would quietly delete those rows and undo the whole "outer" point.Self join — a table joined to itself
The employees table
Employee and manager live in the same table. Two aliases (e and m) let SQL treat one table as two and walk the hierarchy.
🎮 Build the org chart
Cross, implicit & natural joins
CROSS JOIN — the Cartesian product
No ON. Every customer pairs with every order: 8 × 8 = 64 rows. Useful for generating grids (all customer–coupon pairs, all dates × products), dangerous by accident.
SELECT COUNT(*) FROM customers c CROSS JOIN orders o; -- 64
🎮 Combination generator
The "combo meal" mindset: no key needed, you just want every pairing. Pick two small reference tables and watch the grid build itself.
IMPLICIT JOIN — the old comma style
Comma in FROM makes a Cartesian product; the WHERE condition trims it down to matches. Forget the WHERE and you've written an accidental CROSS JOIN.
SELECT c.customer_id, c.name, o.order_code, o.status FROM customers c, orders o WHERE c.customer_id = o.customer_id; -- == INNER JOIN
NATURAL JOIN — auto-match by column name
Joins on every identically-named column automatically — here customer_id links customers to customer_loyalty. No ON needed.
SELECT c.customer_id, cl.tier FROM customers c NATURAL JOIN customer_loyalty cl ORDER BY c.customer_id;
The multi-column trap
Now both customers and store_locations share two names — customer_id AND city. NATURAL JOIN silently matches on both, so a customer listed under a different city vanishes.
SELECT * FROM customers NATURAL JOIN store_locations;
name), the join silently starts matching on that too — changing results with zero warning. Prefer an explicit ON so the match key is always visible.Pick the join & predict the rows
🎮 Which join answers the question?
Read each business goal and choose the right join.
🎮 How many rows come back?
Using the same customers (8 rows) and orders (8 rows) from this module.
Permissions & access control
A database is only as safe as the keys you hand out. GRANT, REVOKE and ALTER USER decide who can do what, where — from a single table to the whole server. This is DCL: Data Control Language.
What is a privilege?
A privilege is a single permission that controls what a user can do — read rows, insert, update, delete, create tables, drop tables. The admin decides which user gets access to which database or table.
user_login_history or revenue. You grant access only to the people who actually need it — and ideally keep an audit trail. Least privilege is the whole game.Three commands do all the work, and each is the mirror of another:
| Command | Does | Direction word |
|---|---|---|
GRANT | Gives a privilege or role | TO a user/role |
REVOKE | Removes a privilege or role | FROM a user/role |
ALTER USER | Changes the account itself (password, lock) | — |
Users, hosts & roles
Where users live
MySQL stores every account — users and roles — in one system table: mysql.user. List them with SELECT user, host FROM mysql.user;. A user is defined not just by name, but by where they connect from — the host.
Click a host type
'raj'@'localhost' and 'raj'@'%' are different accounts. The host is half the identity.
CREATE USER 'raj'@'%' IDENTIFIED BY '123456'; lets raj connect — but he can't see or touch a single table until you GRANT him something.User or role? Same table, different shape
Roles don't live in a separate table — they sit in mysql.user too. The tell isn't the table, it's the account properties: a user has a password and logs in; a role has no password and just holds privileges.
| Account | Has password? | Type |
|---|---|---|
root | Yes | User |
raj | Yes | User |
engineering | No | Role |
support | No | Role |
Roles are typically locked and password-less, so you can list them with:
SELECT user AS role_name, host FROM mysql.user WHERE account_locked = 'Y' AND (authentication_string IS NULL OR authentication_string = '') ORDER BY user;
GRANT — build the privilege set
Everything below protects this one table. Run all GRANT/REVOKE statements as root/admin — only an admin can create users and hand out privileges.
CREATE TABLE students (
student_id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(120) NOT NULL UNIQUE,
full_name VARCHAR(80) NOT NULL,
city VARCHAR(60),
status ENUM('ACTIVE','BANNED') NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME NOT NULL
) ENGINE=InnoDB;
🎮 Tick privileges, watch what raj can do
Pick what you GRANT to 'raj'@'%' on tuf_sql.students. The statement rebuilds, and every operation raj might try is re-checked live. Notice DROP never appears in the list — it was never granted, so it always fails.
SHOW GRANTS FOR 'raj'@'%'; to see exactly what's been granted. To truly confirm, log in as raj and try the operation — a missing privilege fails with "permission denied".GRANT ALL — it's all about scope
🎮 Move the scope, watch the blast radius
GRANT ALL hands over every privilege — but its real power depends entirely on where you point it. Switch the scope and watch how much of the server lights up as accessible.
GRANT ALL ON *.* is God Mode. It makes the account a superuser — it can read, modify or DROP any database on the machine. Give it only to trusted admins, never to application users or developers.Roles & delegation
Why roles?
Imagine 50 engineers. Granting SELECT to each one is 50 queries — and one typo is a security hole. Instead, grant once to a role, then link users to it. Change the role later and all 50 update instantly.
CREATE ROLE 'engineering'; GRANT SELECT, INSERT, UPDATE ON tuf_sql.students TO 'engineering'; GRANT 'engineering' TO 'raj_ip'@'192.168.65.1';
🎮 The "default role" trap
Granting a role to a user does not make it active at login. A common gotcha — toggle the switches and see whether raj_ip can actually run queries after connecting.
WITH GRANT OPTION — the power to delegate
By default a user can use a privilege but can't pass it on. Add WITH GRANT OPTION and they can re-grant the same privileges to others (but never privileges they don't hold themselves). You can test this toggle back in the GRANT playground.
GRANT SELECT, INSERT ON tuf_sql.students TO 'raj'@'%' WITH GRANT OPTION;
ALTER USER — rotate & lock
GRANT changes what a user can do. ALTER USER changes the account itself — its password or login status. Admin-only.
1 · Password rotation
Change a password periodically, or immediately after a suspected leak. The old password dies instantly; new connections need the new one.
ALTER USER 'tuf_support'@'localhost' IDENTIFIED BY 'Temp#5678';
2 · Emergency lock
Offboarding, suspicious activity, or a compromised device — block login instantly without deleting the account. Roles, grants and history stay intact, so you can restore access later.
ALTER USER 'tuf_support'@'localhost' ACCOUNT LOCK;
3 · Unlock
The reverse. Login works again with the existing password; nothing was lost during the lockdown.
ALTER USER 'tuf_support'@'localhost' ACCOUNT UNLOCK;
DROP USER erases the account's whole setup — if it was a false alarm or the employee returns, you rebuild from scratch. ACCOUNT LOCK just freezes login and preserves everything.REVOKE — the opposite of GRANT
REVOKE removes privileges or roles. Same shape as GRANT, one keyword flips: TO becomes FROM. Run as admin.
Revoke a role from a user
REVOKE 'engineering' FROM 'raj_ip'@'192.168.65.1';
All privileges coming from that role vanish for the user.
Revoke specific privileges
REVOKE INSERT, UPDATE ON tuf_sql.students FROM 'raj'@'%';
Raj keeps anything still granted (e.g. SELECT) but loses the revoked ones — an INSERT now fails with "permission denied".
Lock it in
🎮 Which command?
Pick the right DCL / account command for each scenario.
🎮 Will it succeed?
raj has exactly SELECT, INSERT on tuf_sql.students — nothing else. Logged in as raj, does each statement work?
🎮 User or role?
Same mysql.user table — decide by the account's properties.
Transactions & concurrency
A transaction makes many statements behave as one atomic unit — all succeed, or none do. Below: how connections carry transaction state, what COMMIT / ROLLBACK / SAVEPOINT really do, what InnoDB does under the hood, and how locks turn into timeouts and deadlocks.
Connections & connection pools
What is a connection?
A connection is a single live channel between your app and the MySQL server. Every connection has a connection_id, and it is stateful — session variables and in-progress transaction state live inside that one connection. Kill the connection and that state is gone.
connection_id keep alternating (73, 74, 75…)? Your tool is using a connection pool and handing out connections round-robin — so each query may run on a different connection.🎮 Pool vs single connection
Opening a fresh DB connection every time is slow. A pool keeps several open and reuses them: it hands out a free one, runs the query, returns it. Fire three simultaneous requests each way and watch the difference.
COMMIT & ROLLBACK
Outside a transaction MySQL auto-commits each statement. Inside one, nothing is final until you COMMIT — and ROLLBACK throws all of it away, restoring the last committed state.
START TRANSACTION; UPDATE students SET status = 'ACTIVE' WHERE student_id = 1; UPDATE students SET city = 'Delhi' WHERE student_id = 1; COMMIT; -- or ROLLBACK to discard both
🎮 Run a transaction, then keep or discard it
Start a transaction, apply the two updates, then choose COMMIT or ROLLBACK. The right panel is what another session sees — proof that uncommitted changes stay invisible (MVCC).
SAVEPOINT — undo just part of the work
A SAVEPOINT is a bookmark inside a transaction. ROLLBACK TO SAVEPOINT undoes only the work after the bookmark and keeps everything before it — the transaction stays open.
🎮 Step through a savepoint
We insert an enrollment, bookmark, then attempt a balance transfer — but roll the transfer back to the savepoint while keeping the enrollment. Step through and watch which changes survive the final COMMIT.
Inside InnoDB
What actually happens during a transaction?
Click each piece to see its job. Together they explain how InnoDB can change data, still let others read the old version, undo on rollback, and survive a crash.
Undo log vs Redo log — don't mix them up
| Undo log | Redo log | |
|---|---|---|
| Purpose | Reverse changes | Replay changes |
| Used for | ROLLBACK + MVCC reads | Durability after a crash |
| Holds | The old row version | The new change to re-apply |
START TRANSACTION on one pooled connection and COMMIT on another, the commit does nothing useful — the state isn't there. Always run start → queries → commit/rollback on the same connection handle.Timeout & deadlock
A write takes a lock on its rows until commit/rollback. If another transaction wants the same row it waits — and if it waits too long, it times out and fails instead of hanging forever.
🎮 Build a deadlock
Two transactions grab locks in the opposite order. Step through and watch them lock each other out — then see how MySQL breaks the tie.
Lock it in
🎮 Which keyword?
Pick the right transaction control for each goal.
🎮 What survives?
Reason about the final state after each transaction.
🎮 Concept check
Connections, logs, locks & concurrency.
Subqueries
A subquery is just a SELECT living inside another statement. SQL runs the inner query first, then hands its result to the outer query to finish the job. You'll meet them in the SELECT list, inside IN / EXISTS, and — the powerful kind — correlated, re-running once per outer row.
Inner first, outer second
Read any subquery from the inside out. The inner SELECT produces a value, a list, or a yes/no — then the outer query uses it. The shape of what comes back decides where it can plug in.
| Returns… | Plugs into… | Example |
|---|---|---|
| One value (scalar) | SELECT list or = / > compare | "the name for this order" |
| A list of values | IN / NOT IN | "users who appear in orders" |
| Rows (or none) | EXISTS / NOT EXISTS | "does any order exist?" |
The three tables
A small e-commerce slice. Note the deliberate gaps: users 2, 5, 6 never order, user 7 has an empty name and 8 a NULL one, the Keyboard is never sold, and order 102 is CANCELLED. Orders also carry a status and a placed_at_utc timestamp we'll use later.
Scalar subquery in the SELECT list
🎮 One value per order row — no JOIN needed
When a subquery returns exactly one value, you can drop it straight into the SELECT list. It runs once per outer row, using that row's o.user_id. Pick what each order should look up:
SELECT re-runs for every output row — great for a quick lookup, usually slower than a JOIN on large tables.Subquery with IN
🎮 The inner query builds the filter list
IN checks whether a value matches anything in a list. Instead of hardcoding IN (1, 3, 4), we let the subquery find the IDs. Watch the generated list, then the rows it keeps:
Subqueries vs JOINs — when to reach for each
Subqueries read like "Step 1, then Step 2"; JOINs are often faster for the engine. Reach for a subquery when you're filtering a WHERE against a separate list, or comparing each row to an aggregate (like an average).
| Use a subquery when… | Use a JOIN when… |
|---|---|
| Filtering by a list from another query | You need columns from both tables in the output |
| Comparing a row to an aggregate value | Performance matters on large tables |
| The logic reads cleaner as discrete steps | You're stitching related rows together |
EXISTS & NOT EXISTS
🎮 Does any matching row exist?
EXISTS returns TRUE the instant the correlated subquery finds one row — values don't matter, so SELECT 1 is the idiom. NOT EXISTS is the opposite: true when nothing is found. Flip between them and raise the bar:
IN must build the full list of inner values first; EXISTS stops at the first match. And NOT EXISTS is NULL-safe — NOT IN can return zero rows if the inner list contains a single NULL.EXISTS in the wild
🎮 Four real questions, one pattern
Same presence/absence idea aimed at different tables and conditions:
Common mistakes to avoid
⚠️ The NOT IN + NULL trap — live
"Which products were never sold?" answered two ways. With clean data both agree on Keyboard. Now inject one refund-style order whose product_id is NULL and watch NOT IN collapse to nothing while NOT EXISTS stays correct.
SELECT p.product_id, p.product FROM products p WHERE p.product_id NOT IN ( SELECT o.product_id FROM orders o );
SELECT p.product_id, p.product FROM products p WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.product_id = p.product_id );
| Mistake | Why it bites | Do instead |
|---|---|---|
SELECT * inside EXISTS | Wastes work — the values are never read | Use SELECT 1 |
NOT IN over a NULL-able column | One NULL makes it return zero rows | Use NOT EXISTS |
| Forgetting the correlation link | Inner query gives the same answer for every row | Tie it back: WHERE o.user_id = u.user_id |
Row-by-row comparisons
🎮 The looping logic in action
Three correlated patterns you'll reuse constantly — last-seen, running counts, and beating a group average:
JOIN, but for it to stay fast the inner WHERE column (e.g. user_id) must be indexed.Simple vs correlated
| Feature | Simple subquery | Correlated subquery |
|---|---|---|
| Execution | Runs once | Runs in a loop — once per outer row |
| Dependency | Independent; runs by itself | Depends on the outer row's values |
| Performance | Usually fast | Can be slow on large tables |
Lock it in
🎮 Pick the right tool
Which clause fits each goal?
🎮 Predict the result
Reason about what each subquery returns over the tables above.
CTEs & Temporary Tables
A CTE (Common Table Expression) names the result of a query so the next query can select from it like a table. Defined with WITH … AS (…), it lives only for that one statement — and it turns deeply nested subqueries into clean, sequential steps. The recursive flavour even walks tree-shaped data.
Name a result set, then query it
The high-level idea
1) Run a query that generates a result set. 2) Give that result a name — like a nickname for a table. 3) Write a final query that selects from the named result as if it were real.
WITH cte_name AS ( -- any SELECT that returns a table-like result SELECT ... ) SELECT ... FROM cte_name;
The keyword is WITH (not "width"). The CTE exists only for the duration of that one final query — nothing is stored on disk; the engine computes it, uses it, and throws it away.
Always tabular — even one value
A CTE's output is always rows × columns. "One value" just means 1 row × 1 column; it can also be 0 rows or many.
WITH e_row AS ( SELECT 1 AS n ) SELECT n FROM e_row; -- → one row: n = 1
Why bother?
| Win | Because… |
|---|---|
| Readability | Logic flows as named steps instead of deeply nested subqueries |
| Maintainability | Change one step (one CTE) without untangling the whole query |
| Reusability | Reference the same computed result multiple times in the final query |
sales_by_month, not demo), don't collide with real table names, and optionally list explicit columns for clarity: WITH user_summary (user_id, total_spent) AS (…).The tables
Three teaching tables: restaurant ratings (note Burger Barn's NULL star — aggregates ignore it), a sales_date ledger, and a categories hierarchy where each row points at its parent. OrphanCat's parent (999) doesn't exist — deliberately broken.
Basic CTE — compute once, filter after
🎮 Average ratings, then a clean filter
The CTE does the aggregation; the final query treats it like a table and filters. Pick a variant:
LIMIT inside the step.Multi-step CTE pipeline
🎮 Top salesperson, one step at a time
Chain CTEs: each one uses the previous. Step through the pipeline and watch the intermediate results:
Subquery vs CTE — same answer, different shape
Restaurants above the overall average
SELECT restaurant, AVG(stars) AS avg_stars FROM ratings GROUP BY restaurant HAVING AVG(stars) > ( SELECT AVG(stars) FROM ratings ) ORDER BY avg_stars DESC;
WITH baseline AS ( SELECT AVG(stars) AS overall_avg FROM ratings ), avg_by_restaurant AS ( SELECT restaurant, AVG(stars) AS avg_stars FROM ratings GROUP BY restaurant ) SELECT a.restaurant, a.avg_stars FROM avg_by_restaurant a CROSS JOIN baseline b WHERE a.avg_stars > b.overall_avg ORDER BY a.avg_stars DESC;
Recursive CTE — anchor + loop
A recursive CTE has two parts glued by UNION ALL: the anchor query runs once and seeds the result; the recursive query references the CTE's own name and keeps adding rows until its condition fails. No stop condition → infinite loop.
🎮 The simplest recursion: count to N
Change the stop condition and watch the rows grow:
Walking the category tree
🎮 One pattern, five real questions
The recursive member joins categories back to the CTE — downwards on c.parent_id = ct.cat_id (children), upwards on c.cat_id = cp.parent_id (parents):
UNION ALL (plain UNION dedupes every iteration — slow and error-prone), always have an anchor, index parent_id, and make sure the data has no cycles — A→B→A recurses forever.CTE vs Temporary Table
| Feature | CTE | Temporary table |
|---|---|---|
| Definition | Logical named query block | Physical temp table |
| Storage | Logical / optimizer-dependent | Stored in temp storage |
| Lifetime | Single query only | Whole session / until dropped |
| Reusability | Within the same query only | Across multiple queries |
| Recursion | Yes | No |
Temporary table version of the ratings summary
CREATE TEMPORARY TABLE tmp_avg_ratings AS
SELECT restaurant,
AVG(stars) AS avg_stars,
COUNT(stars) AS ratings_count
FROM ratings
GROUP BY restaurant;
COUNT(stars) counts only non-NULL values: Burger Barn's count is 1.Common mistakes to avoid
| Mistake | Why it bites | Do instead |
|---|---|---|
| Semicolon after the CTE | WITH … ; ends the statement — the final query loses its CTE | One statement, one semicolon at the very end |
SELECT before WITH | The WITH clause must open the query | Define all CTEs first, final query last |
| Comma after the last CTE | Syntax error — commas go between CTEs only | WITH a AS (…), b AS (…) SELECT … |
| Reusing a CTE in the next statement | Scope is one query — it's gone after that | Redefine it, or use a temporary table |
| Treating a CTE as a variable | It's always a table shape, even 1 row × 1 column | Select from it; join or CROSS JOIN a one-row CTE |
| 50-line mega-CTE | Defeats the whole readability purpose | One logical step per CTE |
Lock it in
🎮 Pick the right tool
CTE, recursive CTE, or temp table?
🎮 Predict the result
Reason over the tables above.
Dates & Times
Dates power every real report: who joined when, what sold this month, how fast did we deliver? You'll extract parts with YEAR() / MONTH() / EXTRACT(), filter ranges the index-friendly way, find first/latest events with MIN/MAX, and measure gaps with DATEDIFF and TIMESTAMPDIFF — plus the classic BETWEEN-on-DATETIME interview trap, live.
Extracting date & time parts
🎮 Six functions, two spellings
Given @date_temp = '2029-08-19' and @datetime_temp = '2029-05-18 12:50:45', pull out any component. EXTRACT(part FROM value) is the SQL-standard spelling of the same thing:
MONTH() starts at 1 for January (not 0). HOUR() on a date-only value gives 0 — there's no time to read. And EXTRACT needs its FROM: EXTRACT(YEAR joining_date) is a syntax error.Joining dates in practice
The employees table — note Arjun's NULL joining date:
🎮 Break the date apart, then report on it
The BIG performance rule
Never wrap the column in a function to filter it
WHERE YEAR(joining_date) = 2029 AND MONTH(joining_date) = 2;
WHERE joining_date >= '2029-02-01' AND joining_date < '2029-03-01';
This is called being SARGable (Search-ARGument-able). Wrapping the column in YEAR() forces the engine to compute the year for every row before checking; a plain range lets it jump straight to the right slice of the index. Same results, wildly different speed on 10 million rows.
Filtering with date ranges
The orders table (a DATE column — no time part):
🎮 Pick a window
BETWEEN is inclusive on both ends — fine on pure DATE columns. The half-open pattern (>= start AND < next-period-start) works everywhere and needs no month-length math:
The BETWEEN + DATETIME trap
⚠️ 🎮 Where did January 31st go?
On a DATETIME column, '2029-01-31' silently becomes '2029-01-31 00:00:00' — so BETWEEN keeps only the first second of the 31st. Try all three filters on the January logins and watch who survives:
login_time >= '2029-01-01' AND login_time < '2029-02-01' — a half-open interval [start, next_start). It catches every timestamp including fractional seconds (23:59:59.500 slips past a <= 23:59:59 filter), never needs 28-vs-31-day math, and stays index-friendly.First & latest events with MIN / MAX
SQL sees dates as numbers on a timeline: MIN = oldest, MAX = newest. The logins table:
🎮 Five timeline questions
device next to MIN(login_time) without grouping by it can return a device that doesn't match that minimum row. Dates stored outside 'YYYY-MM-DD' sort alphabetically, not chronologically. And both functions ignore NULLs — a user whose every login_time is NULL gets NULL, not an error.DATEDIFF & TIMESTAMPDIFF
🎮 Two rulers
DATEDIFF(end, start) always answers in whole days, ignoring time. TIMESTAMPDIFF(unit, start, end) counts complete units of your choice — note the argument order flips!
CURRENT_DATE / CURRENT_TIMESTAMP and the same query stays correct forever — that's what powers daily dashboards and alerts. Watch out for: server timezone ("today" in the US ≠ "today" in India — prefer UTC), portability (GETDATE() in SQL Server), and comparing CURRENT_DATE to a DATETIME column — SQL pads it to midnight, so later-in-the-day rows can slip out.Instant food delivery latency
Minutes to deliver + a CASE verdict
SELECT order_id, order_placed_at, delivered_at,
TIMESTAMPDIFF(MINUTE, order_placed_at, delivered_at) AS delivery_minutes,
CASE
WHEN delivered_at IS NULL THEN 'Pending'
WHEN delivered_at < order_placed_at THEN 'Bad Data'
WHEN TIMESTAMPDIFF(SECOND, order_placed_at, delivered_at) <= 60 * 30 THEN 'On Time'
ELSE 'Slow'
END AS delivery_status
FROM deliveries;
CASE guards fire top-down: NULL first (still pending), then reversed timestamps (bad data — order 9004 was "delivered" before it was placed), then the 30-minute promise. Orders 9007 and 9008 cross midnight and still compute correctly — timestamps don't care about day boundaries.Lock it in
🎮 Pick the right tool
Function, pattern, or trap?
🎮 Predict the result
Reason over the tables above.