SQL & queries
SQL from your first SELECT to query-plan optimization — a full beginner→expert climb, every query runnable on Python's built-in sqlite3.
sqlite3.
A relational database stores data in tables (rows and columns), and SQL is the language you use to ask it questions. You don't install anything: Python ships with sqlite3, a complete SQL database in a file or in memory. This chapter climbs from your very first SELECT all the way to CTEs, query plans, and optimization — each step a runnable block.
The words you'll hear (in plain terms):
| Term | What it actually means |
|---|---|
| table | a grid of data: columns (fields) and rows (records), like a spreadsheet tab. |
| row / record | one entry in a table (one customer, one order). |
| primary key | a column that uniquely identifies each row (e.g. id). |
| query | a SQL statement that reads or changes data (SELECT, INSERT, …). |
| JOIN | combining rows from two tables by a matching column. |
What you need before starting:
- Python basics (the Python track is enough).
- Nothing to install —
sqlite3is in the standard library. - Curiosity; SQL is one of the highest-ROI skills you can learn.
New to the topic? Read this box, then take the chapters in order — each section is tagged essential → expert so you always know the depth you're at.
Learning objectives
- Run your first SELECT/WHERE/ORDER BY and read the results.
- Combine tables with every JOIN type and summarize with GROUP BY.
- Write subqueries, CTEs, and window functions for real analytics.
- Read a query plan and optimize a slow query with an index.
code/sd1-sql-queries/ — pure Python / sqlite3, runs with no setup.1 · Your first database — in Python essential
We use sqlite3 with an in-memory database so every example is self-contained. Our model: a tiny shop with customers and orders. Run this first — the later labs build on this cur.
setup_db.pyimport sqlite3
conn = sqlite3.connect(":memory:") # a real SQL database, entirely in RAM
cur = conn.cursor()
cur.executescript("""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
amount REAL NOT NULL,
created TEXT NOT NULL
);
""")
cur.executemany("INSERT INTO customers VALUES (?,?,?)",
[(1,"Ava","Seattle"),(2,"Ben","Austin"),(3,"Cy","Seattle"),(4,"Dee","Austin")])
cur.executemany("INSERT INTO orders VALUES (?,?,?,?)",
[(1,1,40.0,"2026-01-05"),(2,1,60.0,"2026-02-01"),(3,2,25.0,"2026-01-20"),
(4,3,90.0,"2026-02-10"),(5,1,20.0,"2026-03-02"),(6,2,55.0,"2026-03-05")])
conn.commit()
print("customers:", cur.execute("SELECT COUNT(*) FROM customers").fetchone()[0],
"| orders:", cur.execute("SELECT COUNT(*) FROM orders").fetchone()[0])
customers: 4 | orders: 6
Before you can query anything you need a database with some data in it. This first block builds a tiny shop entirely in memory: two tables (customers and orders) and a handful of rows. Every later lab reuses the cur object created here, so run this one first.
sqlite3.connect(":memory:")opens a brand-new SQL database that lives in RAM (nothing is written to disk).conn.cursor()gives youcur, the handle you send SQL through.CREATE TABLEdefines the shape of a table.customersgets anidmarkedPRIMARY KEY(a unique row identifier), a requiredname(NOT NULL= can't be empty), and an optionalcity. Inorders,customer_id REFERENCES customers(id)is a foreign key — it says each order belongs to a customer.executemany("INSERT INTO ... VALUES (?,?,?)", [...])adds many rows at once. Each?is a placeholder filled in from the tuples, so 4 customers and 6 orders get inserted safely.conn.commit()saves the changes. The finalprintruns twoSELECT COUNT(*)queries to count the rows in each table and confirm the seed worked.
What the output means: It prints customers: 4 | orders: 6 — proof the two tables exist and hold the rows you inserted.
Try this: Add a fifth customer to the first executemany list and re-run — the count becomes customers: 5. This seeded data is the shop every other lab queries.
2 · SELECT, WHERE, ORDER BY — reading rows essential
A query reads like a sentence: select these columns, from this table, where a condition holds, ordered by something. SQLite runs them in the order FROM → WHERE → SELECT → ORDER BY.
select.py# (assumes `cur` from Step 1)
for row in cur.execute(
"SELECT name, city FROM customers WHERE city = ? ORDER BY name", ("Seattle",)):
print(row)
print("big orders:", cur.execute("SELECT id, amount FROM orders WHERE amount >= 50").fetchall())
('Ava', 'Seattle')
('Cy', 'Seattle')
big orders: [(2, 60.0), (4, 90.0), (6, 55.0)]
This is the most common thing you'll ever do in SQL: read rows back out. A SELECT reads like a sentence — pick these columns, from this table, keep only rows where a condition holds, and sort the result.
SELECT name, city FROM customersasks for just two columns out of thecustomerstable (not the whole row).WHERE city = ?keeps only the rows whosecitymatches — the?is filled by the tuple("Seattle",), so it means where city = 'Seattle'.ORDER BY namethen sorts those rows alphabetically.- The
for row in cur.execute(...)loop walks the matching rows one at a time; eachrowis a tuple like('Ava', 'Seattle'). - The second query,
WHERE amount >= 50, keeps orders of 50 or more..fetchall()grabs all matching rows at once into a list instead of looping.
What the output means: First the two Seattle customers print (Ava, then Cy, sorted by name), then the big orders: the three orders whose amount is at least 50.
Try this: Change the city to "Austin" in the tuple, or lower the threshold to amount >= 20, and predict how many rows come back before running.
? parametersNever build SQL with f-strings — that's SQL injection (xt1). Pass values as the tuple argument; the driver escapes them safely.3 · JOINs — combining tables intermediate
Data is split across tables to avoid duplication (SD2). A JOIN stitches them back on a matching column. The type decides which non-matching rows survive.
This diagram shows what a JOIN physically does: it links two separate tables through one shared column so you can read them as if they were a single wider table.
- The left box (
customers) is one table — the who. The right box (orders) is the other — the what they bought. - The middle box is the join condition,
JOIN on id=customer_id— the rule that decides which customer row belongs with which order row. It matches a customer'sidto the order'scustomer_id. - The arrows show the flow: read across from a customer, through the matching rule, to that customer's orders. Every arrow you follow is one matched pair in the result.
- Read it left-to-right as a sentence: this customer, linked by the shared key, bought these orders.
In short: A JOIN is just "match rows from two tables where a column is equal." The shared column (here customers.id = orders.customer_id) is the hinge everything turns on.
join.py# INNER: only customers who have orders
inner = cur.execute("""
SELECT c.name, o.amount FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.amount DESC LIMIT 3
""").fetchall()
print("top 3 orders:", inner)
# LEFT: every customer, even those with NO orders (NULL amount)
left = cur.execute("""
SELECT c.name, COUNT(o.id) AS n
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id ORDER BY n
""").fetchall()
print("orders per customer:", left) # Dee has 0
top 3 orders: [('Cy', 90.0), ('Ava', 60.0), ('Ben', 55.0)]
orders per customer: [('Dee', 0), ('Cy', 1), ('Ben', 2), ('Ava', 3)]
Real data is split across tables — customers in one, their orders in another — to avoid repeating information. A JOIN stitches them back together by matching a shared column. This lab shows the two joins you'll use 95% of the time: INNER and LEFT.
- In the first query,
FROM orders o JOIN customers c ON c.id = o.customer_idpairs each order with its customer. The lettersoandcare short aliases so you can writeo.amountandc.name. A plainJOINis an INNER join: rows survive only if a match exists on both sides. ORDER BY o.amount DESC LIMIT 3sorts orders biggest-first (DESC= descending) and keeps just the top 3.- The second query uses
LEFT JOIN: it keeps every customer even if they have no orders.COUNT(o.id) AS ncounts each customer's orders (0 for someone with none), andGROUP BY c.idmakes that count per-customer. AS njust renames the counted column tonso the output is tidy.
What the output means: First the top 3 orders by amount with the buyer's name. Then orders-per-customer — note ('Dee', 0): Dee bought nothing, so only the LEFT JOIN reveals her. An INNER join would have dropped her entirely.
Try this: Change LEFT JOIN back to plain JOIN and re-run — Dee disappears from the results. That difference is the whole point of LEFT vs INNER.
| JOIN | Keeps | Use for |
|---|---|---|
| INNER | only matching rows | "orders with a customer" |
| LEFT | all left rows + matches | "customers incl. those with 0 orders" |
| CROSS | every combination | generating pairs/grids (rare) |
4 · GROUP BY & aggregation intermediate
To answer how much did each city spend? group rows and apply an aggregate (SUM, COUNT, AVG). WHERE filters rows before grouping; HAVING filters the groups after.
groupby.pyrows = cur.execute("""
SELECT c.city, COUNT(*) AS n_orders, ROUND(SUM(o.amount),2) AS revenue
FROM orders o JOIN customers c ON c.id = o.customer_id
GROUP BY c.city
HAVING revenue > 50
ORDER BY revenue DESC
""").fetchall()
for r in rows: print(r)
('Seattle', 4, 210.0)
('Austin', 2, 80.0)
GROUP BY answers questions like how much did each city spend? It folds many rows into one row per group and lets you summarize each group with an aggregate function like COUNT, SUM, or AVG.
- The query joins
orderstocustomers(so every order knows its city), thenGROUP BY c.citycollapses all rows into one row per city. - For each city group,
COUNT(*) AS n_orderscounts its orders andROUND(SUM(o.amount),2) AS revenueadds up the amounts (rounded to 2 decimals). These aggregates only make sense because the rows are grouped. HAVING revenue > 50filters the groups after aggregating. This is the key distinction:WHEREfilters individual rows before grouping,HAVINGfilters the summarized groups after.ORDER BY revenue DESCthen lists the highest-revenue city first.
What the output means: Two rows, one per city: ('Seattle', 4, 210.0) and ('Austin', 2, 80.0) — city, its order count, and its total revenue. Both clear the HAVING bar of 50.
Try this: Change HAVING revenue > 50 to > 100 and Austin drops out. Then try grouping by c.name instead of c.city to get per-customer totals.
5 · Subqueries & CTEs advanced
A subquery is a query inside another. A CTE (WITH … AS) names a subquery so complex logic reads top-to-bottom instead of nesting inward — the professional way to structure big queries.
cte.py# Customers who spent above the average customer total.
# First as a nested subquery:
q1 = cur.execute("""
SELECT name FROM customers WHERE id IN (
SELECT customer_id FROM orders GROUP BY customer_id
HAVING SUM(amount) > (SELECT AVG(t) FROM (
SELECT SUM(amount) AS t FROM orders GROUP BY customer_id))
)
""").fetchall()
# Same logic as CTEs — far more readable:
q2 = cur.execute("""
WITH per_customer AS (
SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id
),
avg_total AS (SELECT AVG(total) AS a FROM per_customer)
SELECT c.name FROM per_customer p
JOIN customers c ON c.id = p.customer_id, avg_total
WHERE p.total > avg_total.a
""").fetchall()
print("subquery:", [r[0] for r in q1], "| CTE:", [r[0] for r in q2])
subquery: ['Ava'] | CTE: ['Ava']
This lab answers one question — which customers spent more than the average customer? — two ways: first as nested subqueries, then as CTEs. Compare them to see why CTEs are the readable, professional style. A subquery is just a query used inside another query.
- The nested version reads inside-out. The deepest part,
SELECT SUM(amount) ... GROUP BY customer_id, gets each customer's total; wrapping it inSELECT AVG(t) FROM (...)gets the average of those totals. - The middle layer keeps customer_ids whose
SUM(amount)beats that average (HAVING SUM(amount) > (...)), and the outerWHERE id IN (...)turns those ids into names. It works, but you must read it from the innermost parentheses out. - The second version uses a CTE —
WITH per_customer AS (...)— which names a subquery so you can refer to it by name later.avg_total AS (...)names a second step that reuses the first. - Now the final
SELECTreads top-to-bottom like plain steps: build per-customer totals, compute the average, then keep customers above it. Same answer, far clearer.
What the output means: subquery: ['Ava'] | CTE: ['Ava'] — both paths return the same customer (Ava), proving the CTE is just a tidier way to write identical logic.
Try this: Read only the CTE version and trace it top-down; then read the subquery version inside-out. The CTE is the same logic but you can follow it like a recipe — that's why pros prefer it.
6 · Window functions — analytics without collapsing rows advanced
Window functions compute across rows related to the current row without collapsing them like GROUP BY. They power running totals, rankings, and per-group comparisons — the single most valuable SQL skill for analytics and interviews.
window.pyrows = cur.execute("""
SELECT c.name, o.amount,
SUM(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS running,
RANK() OVER (ORDER BY o.amount DESC) AS rk,
LAG(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS prev
FROM orders o JOIN customers c ON c.id = o.customer_id
ORDER BY c.name, o.created
""").fetchall()
for r in rows: print(r) # (name, amount, per-customer running total, global rank, prev amount)
('Ava', 40.0, 40.0, 4, None)
('Ava', 60.0, 100.0, 2, 40.0)
('Ava', 20.0, 120.0, 6, 60.0)
('Ben', 25.0, 25.0, 5, None)
('Ben', 55.0, 80.0, 3, 25.0)
('Cy', 90.0, 90.0, 1, None)
Window functions are the single most valuable analytics skill in SQL. Unlike GROUP BY (which collapses rows into one summary row), a window function computes across related rows while keeping every original row — so you get running totals and rankings alongside the raw data.
- Every window function has an
OVER (...)clause that defines its "window" — the set of rows it looks at.PARTITION BY c.idrestarts the calculation for each customer (it's likeGROUP BYbut doesn't merge rows), andORDER BY o.createdsets the order within that window. SUM(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS runninggives a running total: each row shows that customer's spending accumulated up to and including that order.RANK() OVER (ORDER BY o.amount DESC) AS rkranks every order globally by amount, biggest = rank 1.LAG(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS prevreaches back to the previous row in the window — the customer's prior order amount (Noneon their first order, since there's nothing before it).
What the output means: One row per order (rows are kept, not collapsed): name, amount, the customer's running total, the global rank, and the previous amount. E.g. Ava's three orders show a running total climbing 40 → 100 → 120, and prev lagging one step behind.
Try this: Swap RANK() for ROW_NUMBER() and watch how ties are handled differently. Then remove PARTITION BY c.id from the running SUM to make it a single global running total instead of per-customer.
PARTITION BY restarts the calculation per group; ORDER BY inside OVER() sets the running order. RANK, ROW_NUMBER, LAG/LEAD, and running SUM cover most window questions.7 · Read the query plan & optimize expert
Expert SQL is knowing why a query is slow. EXPLAIN QUERY PLAN shows whether SQLite scans the whole table or seeks via an index. A full scan on a big table is the usual culprit; the fix is usually the right index.
explain.py# BEFORE: filtering orders by customer_id with no index -> full table SCAN
plan_before = cur.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1").fetchall()
print("before:", plan_before[0][-1])
cur.execute("CREATE INDEX idx_orders_customer ON orders(customer_id)")
# AFTER: same query now SEARCHes via the index -> O(log n)
plan_after = cur.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1").fetchall()
print("after: ", plan_after[0][-1])
before: SCAN orders
after: SEARCH orders USING INDEX idx_orders_customer (customer_id=?)
Expert SQL is knowing why a query is slow and fixing it. EXPLAIN QUERY PLAN asks the database to describe how it will run a query without actually running it — specifically, whether it reads every row or jumps straight to the ones it needs.
- The first
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1reports the plan before any index exists.plan_before[0][-1]pulls the human-readable description out of the result. CREATE INDEX idx_orders_customer ON orders(customer_id)builds an index — a sorted lookup structure on thecustomer_idcolumn, like the index at the back of a book.- Running the exact same
EXPLAIN QUERY PLANagain shows the plan after the index exists. The database now chooses a faster strategy automatically. - The lesson: a SCAN reads every row (slow, O(n)); a SEARCH USING INDEX jumps straight to matches (fast, O(log n)). Same query, very different speed on big tables.
What the output means: before: SCAN orders then after: SEARCH orders USING INDEX idx_orders_customer (customer_id=?) — visible proof the index turned a full scan into a targeted lookup.
Try this: Add an index on a column you never filter by and re-check the plan — it stays a SCAN, showing an index only helps the columns you actually query. Index the columns in WHERE/JOIN/ORDER BY, not everything.
WHERE/JOIN/ORDER BY, measure with EXPLAIN QUERY PLAN, and stop there. This is the same log-n vs n idea as hashing in DSA.8 · Transactions — correctness under concurrency expert
Multi-step changes must be all-or-nothing. A transaction commits together or rolls back together — the 'A' (atomicity) in ACID. The classic example is moving money between accounts.
transaction.pyimport sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)")
db.executemany("INSERT INTO accounts VALUES (?,?)", [(1, 100.0), (2, 0.0)])
db.commit()
def transfer(db, src, dst, amount):
try:
db.execute("BEGIN")
db.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, src))
db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, dst))
bal = db.execute("SELECT balance FROM accounts WHERE id=?", (src,)).fetchone()[0]
if bal < 0:
raise ValueError("insufficient funds")
db.commit(); return True
except Exception:
db.rollback(); return False # neither update lands
print("transfer 30:", transfer(db, 1, 2, 30)) # True
print("transfer 999:", transfer(db, 1, 2, 999)) # False -> rolled back
print("balances:", db.execute("SELECT id, balance FROM accounts").fetchall())
transfer 30: True
transfer 999: False
balances: [(1, 70.0), (2, 30.0)]
Some changes must happen all together or not at all. Moving money is the classic case: you subtract from one account and add to another — if only half runs, money vanishes. A transaction groups steps so they commit together or roll back together.
- The setup creates an
accountstable with two accounts (account 1 has 100, account 2 has 0) so we have something to transfer between. - Inside
transfer,db.execute("BEGIN")starts a transaction. The twoUPDATEstatements subtract from the source and add to the destination — the two halves that must stay in sync. - The check
if bal < 0: raise ValueError(...)catches an overdraft. If the source would go negative, raising an error jumps to theexceptblock. - On success,
db.commit()makes both updates permanent. On any error,db.rollback()undoes both updates — the account balances snap back as if nothing happened. That's atomicity, the 'A' in ACID.
What the output means: transfer 30: True (the valid move commits), transfer 999: False (overdraft rolls back), and final balances [(1, 70.0), (2, 30.0)] — only the 30 transfer stuck; the failed 999 left the accounts untouched.
Try this: Comment out the db.rollback() line and re-run the 999 transfer — now the first UPDATE lands but the second doesn't, and money is lost. That broken state is exactly what a transaction exists to prevent.
Exercise SD1.1 — Answer real questions
Context: Interview and on-the-job SQL is rarely one clause at a time — it is a stack of windowing, aggregation, plan reading, and transactional safety applied to a real question against a seeded database.
Your task: Against a seeded database, answer four questions: find each customer's most-recent order, compute month-over-month revenue, diagnose and fix a slow query, and wrap a two-row insert in a transaction that rolls back on bad data.
Requirements:
- Use
ROW_NUMBER()partitioned per customer to pick the latest order - Compute a running month-over-month revenue with a windowed
SUM - Locate a slow query with
EXPLAIN QUERY PLAN, add the right index, and show the plan improve - Wrap a two-row insert in an explicit transaction that commits on success and rolls back when one row is invalid
- Prove the rollback left the table unchanged after the bad insert
💡 Hint: Each part reuses a rung technique; for the transaction, catch the integrity error and call rollback() so neither row survives.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every backend engineer needs to be fluent in SQL before designing anything on top of it, and Python ships a full relational engine in the standard library so you can practise with zero setup.
Your task: Open an in-memory sqlite3 database, create a customers table, insert two rows, and read them all back with a SELECT.
Requirements:
- Connect to an in-memory database (
:memory:) so nothing touches disk - Define
customerswith anINTEGER PRIMARY KEYplus text columns - Insert rows through parameterized placeholders (
?), never string formatting, so user data can't inject SQL - Use
executemanyto load more than one row in a single call - Iterate the SELECT result and print each row as a tuple
💡 Hint: An in-memory connection plus executemany for the inserts is the whole setup; keep the values in a list of tuples that lines up with the placeholders.
Show solution
No install needed — an in-memory database in the stdlib:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)")
db.executemany("INSERT INTO customers (name, city) VALUES (?, ?)",
[("Ada", "London"), ("Grace", "New York")])
for row in db.execute("SELECT id, name, city FROM customers"):
print(row)
# (1, 'Ada', 'London')
# (2, 'Grace', 'New York')
Parameterized inserts (?) are the safe default — never string-format user data into SQL.
Context: Real reads almost never want the whole table — they want a filtered, ordered slice. WHERE and ORDER BY are the two clauses you reach for on nearly every query.
Your task: Insert several orders with amounts, then select only the orders above 100, sorted by amount from largest to smallest.
Requirements:
- Filter rows with a
WHERE amount > ?predicate bound by parameter - Sort the survivors with
ORDER BY amount DESC - Pass the threshold as a bound parameter, not an inlined literal
- Collect the result with
fetchall()and confirm the order and cut-off are both correct
💡 Hint: The engine applies WHERE to narrow rows before ORDER BY arranges them — you write both in one statement and let SQLite do the work.
Show solution
Filtering and sorting are the everyday reads:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)")
db.executemany("INSERT INTO orders (customer, amount) VALUES (?, ?)",
[("Ada", 250.0), ("Grace", 80.0), ("Ada", 500.0), ("Grace", 150.0)])
rows = db.execute(
"SELECT customer, amount FROM orders WHERE amount > ? ORDER BY amount DESC",
(100,)).fetchall()
print(rows)
# [('Ada', 500.0), ('Ada', 250.0), ('Grace', 150.0)]
WHERE narrows rows before ORDER BY arranges them — the engine does both for you.
Context: Data worth storing is relational: orders belong to customers, and you need both sides at once. A JOIN follows the foreign key to stitch the two tables into one result set.
Your task: Given customers and orders linked by a customer id, write an INNER JOIN that lists each order alongside its customer's name.
Requirements:
- Model two tables where
orders.cust_idreferencescustomers.id - Join them with an explicit
ON c.id = o.cust_idcondition - Select columns from both tables using table aliases (e.g.
c.name,o.amount) - Order the output deterministically (e.g. by customer name then amount)
- Understand that an INNER JOIN drops rows with no match on either side
💡 Hint: The ON clause is where you express the relationship; alias each table with a short letter so the SELECT list stays readable.
Show solution
A JOIN follows the foreign key to stitch tables together:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, cust_id INTEGER, amount REAL)")
db.executemany("INSERT INTO customers (name) VALUES (?)", [("Ada",), ("Grace",)])
db.executemany("INSERT INTO orders (cust_id, amount) VALUES (?, ?)",
[(1, 250.0), (1, 500.0), (2, 150.0)])
rows = db.execute(
"SELECT c.name, o.amount "
"FROM orders o JOIN customers c ON c.id = o.cust_id "
"ORDER BY c.name, o.amount").fetchall()
print(rows) # [('Ada', 250.0), ('Ada', 500.0), ('Grace', 150.0)]
The ON clause is the relationship; INNER JOIN drops rows with no match on either side.
Context: Dashboards and reports live on aggregation: totals, counts, and averages rolled up per group. GROUP BY collapses many rows into one summary row, and HAVING filters those summaries.
Your task: From the order data, compute total spend and order count per customer, keeping only the customers whose total exceeds 300.
Requirements:
- Aggregate with
COUNT(*)andSUM(amount), giving each an alias - Group the rows with
GROUP BY customer - Filter the resulting groups with
HAVING total > 300, not a WHERE - Sort the surviving groups by total descending
- Be able to explain why
WHEREfilters rows before grouping whileHAVINGfilters groups after aggregation
💡 Hint: Alias the aggregate (SUM(amount) AS total) so you can both order by it and reference it in HAVING; that alias-vs-row distinction is the usual beginner trap.
Show solution
Aggregation collapses many rows into one per group:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)")
db.executemany("INSERT INTO orders (customer, amount) VALUES (?, ?)",
[("Ada", 250.0), ("Ada", 500.0), ("Grace", 150.0)])
rows = db.execute(
"SELECT customer, COUNT(*) AS n, SUM(amount) AS total "
"FROM orders GROUP BY customer HAVING total > 300 "
"ORDER BY total DESC").fetchall()
print(rows) # [('Ada', 2, 750.0)]
WHERE filters rows before grouping; HAVING filters the groups after aggregation — a distinction beginners miss.
Context: Analysts constantly ask "what share of the whole does each group represent?" — a query that needs an intermediate per-group total plus a grand total. A CTE names that intermediate result so the query reads top-to-bottom.
Your task: Compute each customer's percentage share of total revenue using a WITH CTE for per-customer totals, then divide by the grand total in the outer query.
Requirements:
- Define a CTE (
WITH totals AS (...)) that sums revenue per customer - Compute the grand total once with a scalar subquery over the CTE
- Divide each customer's total by the grand total and round the percentage
- Multiply by
100.0(float) so integer division doesn't zero the result - Order the output by percentage share, highest first
💡 Hint: The scalar subquery (SELECT SUM(total) FROM totals) is evaluated once and reused for every row; the CTE is what keeps the two-step logic legible.
Show solution
A CTE (WITH) names an intermediate result so the query reads top-down:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)")
db.executemany("INSERT INTO orders (customer, amount) VALUES (?, ?)",
[("Ada", 750.0), ("Grace", 250.0)])
q = (
"WITH totals AS ("
" SELECT customer, SUM(amount) AS total FROM orders GROUP BY customer) "
"SELECT customer, total, "
" ROUND(total * 100.0 / (SELECT SUM(total) FROM totals), 1) AS pct "
"FROM totals ORDER BY pct DESC")
print(db.execute(q).fetchall())
# [('Ada', 750.0, 75.0), ('Grace', 250.0, 25.0)]
The scalar subquery (SELECT SUM(total) FROM totals) computes the grand total once; the CTE keeps the logic readable.
Context: When a query gets slow in production, the difference between fast and slow is usually the access path the engine chose. Window functions add per-row analytics without collapsing rows, and EXPLAIN QUERY PLAN shows whether an index would help.
Your task: Rank each customer's orders by amount with a window function, then use EXPLAIN QUERY PLAN to prove that adding an index changes a filtered lookup from a full scan to an indexed search.
Requirements:
- Assign ranks with
ROW_NUMBER() OVER (PARTITION BY customer ORDER BY amount DESC) - Confirm the window function ranks within each customer without removing any rows
- Capture the query plan for a filtered lookup before creating an index
- Create an index on the filtered column and capture the plan again
- Show the plan flips from
SCANtoSEARCH ... USING INDEX— concrete evidence the access path changed
💡 Hint: PARTITION BY restarts the ranking per customer; the plan text is your proof, so compare the before/after strings rather than trusting intuition.
Show solution
Window functions add per-row analytics; the plan shows how the engine executes:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)")
db.executemany("INSERT INTO orders (customer, amount) VALUES (?, ?)",
[("Ada", 250.0), ("Ada", 500.0), ("Grace", 150.0)])
ranked = db.execute(
"SELECT customer, amount, "
" ROW_NUMBER() OVER (PARTITION BY customer ORDER BY amount DESC) AS rnk "
"FROM orders").fetchall()
print(ranked)
# [('Ada', 500.0, 1), ('Ada', 250.0, 2), ('Grace', 150.0, 1)]
# Before an index: a full scan for a customer lookup
plan = db.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer = 'Ada'").fetchall()
print(plan) # ... SCAN orders
db.execute("CREATE INDEX idx_cust ON orders(customer)")
plan2 = db.execute(
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer = 'Ada'").fetchall()
print(plan2) # ... SEARCH orders USING INDEX idx_cust
The plan flips from SCAN to SEARCH ... USING INDEX — the concrete evidence that the index changed the access path.
✓ Checkpoint — you can move on when you can…
- Run SELECT/WHERE/ORDER BY and read results.
- Use every JOIN type and GROUP BY/HAVING aggregation.
- Write subqueries, CTEs, and window functions.
- Read a query plan, add the right index, and use transactions.
Knowledge check check yourself
In SQL, what is the difference between the WHERE and HAVING clauses when used with GROUP BY?
Show answer
WHERE filters individual rows before they are grouped, while HAVING filters the aggregated groups after grouping. So a condition on a raw column goes in WHERE, and a condition on an aggregate like SUM or COUNT goes in HAVING.What does EXPLAIN QUERY PLAN reveal about a query, and how does adding an index change the plan for a filtered lookup?
Show answer
SCAN of the table (O(n)) or a SEARCH USING INDEX (O(log n)). Adding an index on the filtered column (e.g. customer_id) turns a SCAN orders into a targeted SEARCH orders USING INDEX, a much faster lookup on large tables.