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
Almost everyone's first brush with a database goes badly, and it's not their fault. You want to learn SQL, so you follow a guide — and within ten minutes you're being told to install a database server, create a superuser role, edit a config file, and start a background daemon. An hour later you have a broken login prompt and you still haven't run a single SELECT. The environment beat you before the language even got a turn. That barrier stops far more people than the syntax ever does.
Here's the reframe that changes everything: a database is not a server you have to run — it can just be a file, or even a scratchpad in memory. SQLite is a complete SQL engine that lives inside your program. There's no server, no port, no password; you open it much the way you open a file. And Python already ships it as sqlite3, so "set up a database" collapses into one line of code. Think of the difference between renting a warehouse (a full database server — powerful, but paperwork) and grabbing a notebook off your desk (SQLite — instant, yours, and good enough for learning and for a surprising amount of real work).
The mental model to hold onto is small, and it won't change no matter which database you graduate to later. A table is a grid — columns name the fields, rows hold the records — exactly like one tab of a spreadsheet. Our whole chapter runs on a toy shop with just two tables: customers (who) and orders (what they bought), linked by a shared id. Every query you write from here on is a question aimed at that grid, so it pays to keep the grid picture in your head.
The one common mistake to name up front: a beginner sees :memory: and quietly assumes the data is being saved somewhere. It isn't. An in-memory database vanishes the instant the program ends — which is perfect for a tutorial (every run starts clean, nothing to reset) but is exactly the trap that loses a Saturday's work if you mistake it for storage. When you want data to survive, you pass a filename instead of :memory: and SQLite writes a real file to disk. Same code, same SQL — only where the bytes live changes.
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.
customers and orders — each a spreadsheet-like grid, joined by a shared id, all living in one in-memory database.
Read the two grids at the top left-to-right as spreadsheets, then look down to the single database that holds them both.
- The left grid (
customers) and the right grid (orders) each have a header row naming the columns and data rows below — exactly like a spreadsheet tab. - The dashed line links
customers.idtoorders.customer_id: that shared key is how an order knows which customer it belongs to. - The purple box at the bottom is the one
:memory:database created byconnect(":memory:")— it holds both tables in RAM, so nothing touches disk and every run starts fresh.
In short: Two grids + one shared id, all inside a throwaway in-memory database — that is the whole toy shop the rest of the chapter queries.
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
Picture the moment SQL was invented for. A shelf holds ten million index cards — every customer, every order your shop has ever taken. Someone asks: "who are our customers in Seattle, listed alphabetically?" Without SQL you'd write a loop: open a card, check the city, keep it or toss it, sort the survivors by hand. With SQL you say what you want and the engine figures out how to get it. That shift — from how to what — is the whole reason the language feels like magic the first time it clicks.
The trick to reading any SELECT is to hear it as an ordinary English sentence: pick these columns, from this table, keeping only the rows where a condition holds, and sort the result. Four everyday words map onto four clauses — SELECT (which columns), FROM (which table), WHERE (which rows to keep), ORDER BY (how to sort). Once you can translate a query back into that sentence, you can read almost anything.
Here's the detail that quietly trips up beginners and is worth burning in now: SQL does not run in the order you write it. You write SELECT first, but the engine does it nearly last. It first goes to the FROM table, then narrows with WHERE, then decides which columns to hand back (SELECT), and finally sorts with ORDER BY. Think of a chef reading an order ticket: they don't plate the dish (the part the customer sees) until after they've fetched the ingredients and done the cooking. Knowing this execution order explains a puzzle you'll hit soon — why WHERE can't reference a column alias you invented in SELECT: at the moment WHERE runs, that alias doesn't exist yet.
One more habit that separates a beginner from a professional, and it's a security habit as much as a style one: never glue user input into a query string. The instinct is to build the query with an f-string — f"... WHERE city = '{city}'" — and it works right up until someone types a city name containing a quote and an extra command, and now they're running their SQL against your database. That's SQL injection, and it's one of the oldest and most damaging bugs on the web. The fix is painless: leave a ? placeholder in the query and pass the value separately as a tuple, and the driver quotes it safely for you. Every lab below uses ? for exactly this reason.
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 first, but the engine fetches the table, filters rows, then picks columns, then sorts — which is why a SELECT alias is invisible to WHERE.
Compare the top row (the order you type the clauses) with the bottom row (the order the engine actually performs them), following the blue arrows left-to-right along the bottom.
- The top amber row is the syntax as you write it:
SELECT, thenFROM,WHERE,ORDER BY. - The bottom blue row is the real execution sequence, numbered 1–4: first
FROM(get the table), thenWHERE(keep rows), thenSELECT(pick columns), and finally the greenORDER BY(sort what's left). - The payoff is the gap between the two:
SELECTsits first up top but runs third below — so any column alias you invent inSELECTsimply doesn't exist yet whenWHEREruns.
In short: Write SELECT-first, but reason FROM → WHERE → SELECT → ORDER BY — that execution order explains the alias rule beginners trip on.
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
Imagine you stored everything in one giant table instead. Every order row would repeat the customer's name, city, email, phone — all of it, copied on every single order. Ava places 500 orders and her city is written 500 times. Then she moves to Denver and you have to find and fix 500 rows, and if you miss three, your database now disagrees with itself about where Ava lives. That mess is exactly what splitting data into separate tables prevents: store each fact once, in the table it belongs to (Ava's city lives in customers, her purchases live in orders), and connect them with a shared id. The formal name for this cleanup is normalization, and it's the whole subject of SD2.
But splitting creates a new problem: the answer to a real question — "what did Ava buy?" — now lives in two tables at once. A JOIN is how you put the pieces back together on demand. The everyday analogy is a coat-check: you hold a numbered ticket (the order's customer_id) and the rack holds coats with matching numbers (each customer's id); the JOIN is the attendant walking the ticket to the coat. It matches rows from two tables wherever a shared column agrees, producing one wider row that has columns from both sides.
Now the part that actually decides whether a query is correct, and it's where most JOIN bugs are born: what happens to rows that have no partner? Suppose a customer signed up but never ordered. An INNER JOIN (a plain JOIN) keeps a row only when a match exists on both sides — so that customer silently disappears from your results. A LEFT JOIN keeps every row from the left table whether or not it found a match, filling the missing right-hand columns with NULL. The choice is not cosmetic. Ask "total revenue per customer" with an INNER JOIN and your zero-order customers vanish; the report looks fine and is quietly wrong. Ask it with a LEFT JOIN and they show up with a revenue of zero, which is the honest answer.
The classic mistake, in one sentence: reaching for a plain JOIN out of habit when the question really needs "…including the ones with nothing on the other side." Whenever a requirement contains the words "all" or "even those without," that's your cue to think LEFT JOIN, not INNER. The diagram and table below make the "which rows survive" rule concrete.
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
So far every query has handed you back rows — individual records. But the questions a business actually asks are almost never about one row; they're about summaries. Nobody in a Monday meeting asks "show me order #4,812." They ask "how much did each city spend last quarter?", "how many orders per customer?", "what's our average order value?" Those questions all share a shape: chop the data into piles, then compute one number per pile. That is precisely what GROUP BY plus an aggregate function does, and it's the engine behind every dashboard you've ever seen.
The mental picture that makes it stick: imagine dealing a deck of cards into piles by suit. GROUP BY c.city is the dealing step — it sorts every order into a pile per city. Then an aggregate function is you looking at one pile and reporting a single fact about it: COUNT(*) ("how many cards in this pile?"), SUM(amount) ("add up their values"), AVG(amount) ("the average"). The result has one row per pile, not one row per card — the many collapse into the few. That collapse is the defining feature, and also the source of the classic beginner confusion.
Here's that confusion, and it's worth slowing down for because interviewers love it: the difference between WHERE and HAVING. They both filter, so people use them interchangeably and get mysterious errors. The rule is about timing. WHERE runs before the piles are formed — it decides which individual cards get dealt at all ("only orders from 2026"). HAVING runs after — it decides which finished piles to keep ("only cities whose total exceeds 50"). So a condition on a raw column (a single order's date) belongs in WHERE; a condition on an aggregate (a whole city's total) can only go in HAVING, because that total doesn't even exist until after grouping. Try to filter on SUM(amount) in a WHERE and the database rightly refuses — at that point in execution, there's no sum yet.
A useful discipline that flows from this: reach for WHERE whenever you can, and save HAVING for genuinely post-aggregate conditions. Filtering rows out early (in WHERE) means fewer rows to pile up and add, which is simply less work for the engine. HAVING is not "a second WHERE" — it's the one that speaks the language of groups.
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.
GROUP BY sorts rows into one pile per city and an aggregate collapses each pile to a single row — WHERE filters the raw rows going in, HAVING filters the finished totals coming out.
Follow it left-to-right: raw rows on the left, dealt into piles in the middle, collapsed to one summary row each on the right.
- The left column is every individual order row.
WHEREacts here, before grouping — it can drop raw rows on their own columns. - The middle blue boxes are the piles
GROUP BY citycreates — all Seattle orders in one, all Austin in another. Rows are sorted, not yet merged. - The right boxes are the collapse: each pile becomes one row carrying its
COUNTandSUM.HAVINGacts here, after, on those group totals — the red Austin box is where aHAVINGcut-off would decide whether it survives.
In short: Rows → piles → one number per pile. WHERE filters the rows before the piles form; HAVING filters the piles after — because the total it tests doesn't exist until grouping is done.
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
You will one day inherit a query that looks like this and your heart will sink. Six levels of parentheses, opening brackets marching off the right edge of the screen, a SELECT buried so deep inside other SELECTs that you have to count matching parens with your finger to figure out what feeds what. Someone wrote it under deadline, it worked, and now it's yours to change. Reading it feels like reading a sentence with eight nested "which was the thing that…" clauses — technically grammatical, humanly unparseable. This section is about the tool that untangles exactly that.
First the raw idea: a subquery is simply a query used inside another query. That's it — a SELECT whose result feeds the query around it. It might produce a single value ("the average order total"), a list of ids to match against, or a whole temporary table. Subqueries are genuinely useful; the problem is only what happens when you nest them. Each layer wraps the last, so you have to read the thing inside-out — start at the deepest parenthesis and work outward — which is the opposite of how anyone reads anything.
The fix is a Common Table Expression, or CTE: the WITH name AS (...) syntax. A CTE takes a subquery, gives it a name, and lets you refer to that name later. Think of it as the difference between a run-on sentence and a numbered recipe. Instead of "the customers whose total exceeds the average of the totals of all customers grouped by customer," you write step 1: per_customer = each customer's total; step 2: avg_total = the average of those; step 3: keep customers above it. Same logic, but now it reads top-to-bottom, each step building on the named one before it — the way you'd actually explain it to a colleague at a whiteboard.
The mistake to avoid isn't a syntax error — it's a readability debt. Nested subqueries "work," so it's tempting to keep nesting rather than refactor. But every layer you add is a layer the next person (often future-you) has to unwind. The professional rule of thumb: the moment a query nests more than about twice, stop and reach for CTEs. They don't usually run faster — the engine often treats them the same — but they turn a write-once puzzle into something you can read, test a piece at a time, and safely modify. The lab below writes the identical question both ways so you can feel the difference directly.
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.
Compare the two panels: the left is one query as nested subqueries, the right is the identical query rewritten as named CTE steps.
- The left nested boxes sit inside one another — outer
SELECTwraps aHAVINGthat wrapsAVG(t)that wraps aSUM … GROUP BY. The amber arrow points inward: you must start at the deepest parenthesis and read out. - The right stacked boxes are the same logic as three numbered steps —
per_customer, thenavg_totalbuilt on it, then the final filter. The blue arrows flow downward, each step referring to the named one above. - The payoff is direction: the left forces inside-out reading (hard); the right reads top-to-bottom (easy) — same result, far less mental unwinding.
In short: A CTE doesn't change the answer or usually the speed — it flips a write-once puzzle you read inside-out into named steps you read like a recipe.
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
Here's the wall you hit right after you learn GROUP BY. Your boss asks for a report: each order, with the customer's running total next to it — order 1: $40, running $40; order 2: $60, running $100; and so on. You reach for GROUP BY… and immediately realize it can't do this. Grouping collapses each customer down to one summary row, but the report needs every individual order to survive and carry an extra computed number. You want the summary and the detail, on the same line. Beginners spend hours fighting this with self-joins and correlated subqueries, and the result is slow and unreadable. The right tool is the window function, and once you meet it you'll wonder how you managed without it.
The core idea lives in the word "window." A window function looks at a window of rows around the current row and computes something across them — but crucially it keeps every row. Contrast the two directly: GROUP BY is a blender (many rows in, one smoothie out); a window function is a highlighter (every row stays on the page, you just annotate each one with a fact drawn from its neighbours). Think of a race: GROUP BY tells you "the average finish time was 4:02"; a window function writes each runner's placement (1st, 2nd, 3rd) next to their name while everyone stays on the results board.
Every window function carries an OVER (...) clause, and that clause is where the whole idea lives, so it's worth decoding slowly. PARTITION BY c.id means "restart the calculation for each customer" — it's like GROUP BY's piles, except the rows don't merge, they just get their own private scope. ORDER BY o.created inside the OVER() sets the sequence within that window, which is what makes a "running" total meaningful — running implies an order to run along. Get those two knobs and you can express most analytics questions: SUM(...) OVER(...) for running totals, RANK() / ROW_NUMBER() for placements, LAG() / LEAD() to peek at the previous or next row.
The mistake to watch for is confusing OVER(ORDER BY ...) with the query's own final ORDER BY. They're different jobs: the one inside OVER() only orders the rows for the purpose of the window calculation (which row counts as "previous"), while the query's trailing ORDER BY sorts the rows you actually get back. It's completely normal to have both, set differently. This is genuinely the highest-leverage SQL skill for analytics work and interviews alike — the moment you're fluent with OVER(), a whole class of "impossible in SQL" reports becomes a one-liner.
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.
GROUP BY collapses a customer's orders into one summary row; a window function keeps every order and annotates each with a value (here a running total) computed across its neighbours.
Compare the two panels side by side: same three input rows on each, but a very different number of rows come out.
- On the left (GROUP BY), Ava's three orders go into the "blender" and come out as one summary row (
total 120) — the individual orders are gone. - On the right (window function), all three orders survive; each keeps its own row and gains a new green column — the running total climbing 40 → 100 → 120.
- The bottom line names the control knob: the
OVER (PARTITION BY id ORDER BY created)clause defines each row's window —PARTITION BYrestarts per customer, the innerORDER BYsets the running sequence.
In short: Need one number per group? GROUP BY. Need every row to survive and carry a computed value? A window function with OVER().
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
This is the section that separates "I can write SQL" from "I can run SQL in production." The story plays out the same way everywhere: a query that returned instantly against 500 test rows takes 30 seconds against 5 million real ones, the page times out, and someone pages you. The query didn't change — the data grew. And here's the maddening part: the fix is often a single line, but only if you know why it's slow. Guessing ("maybe add more RAM?", "maybe rewrite it?") wastes days. The professional move is not to guess at all — it's to ask the database what it's doing.
The tool for that is EXPLAIN QUERY PLAN. Put those words in front of any query and SQLite describes how it intends to execute it — without actually running it. It's the difference between wondering why traffic is slow and pulling up the map that shows your route. And there is really only one distinction you must read in that output, but it's the one that matters most: does the plan say SCAN or SEARCH?
The analogy that makes this land is a physical book. A SCAN is reading every page from cover to cover to find each mention of "photosynthesis" — thorough, and hopeless once the book is long. That's O(n): double the rows, double the work. A SEARCH USING INDEX is flipping to the index at the back, finding "photosynthesis → p. 412," and jumping straight there. That's roughly O(log n), and the gap between them widens as the table grows — which is exactly why the query that was fine in testing dies in production. A database index is literally that back-of-book index: a pre-sorted lookup structure on a column, so the engine can seek instead of scan. (It's the same log-n-vs-n idea as hashing and balanced trees over in the DSA track.)
But an index is not free, and the beginner mistake is to over-correct into the opposite error: having been burned by a slow scan, they slap an index on every column "just in case." That backfires. Each index has to be updated on every INSERT, UPDATE, and DELETE, so a table drowning in indexes writes slowly and wastes disk. The discipline is surgical: index the columns you actually filter, join, or sort on — the ones in your WHERE, JOIN, and ORDER BY — then measure with EXPLAIN QUERY PLAN to confirm the plan flipped from SCAN to SEARCH, and stop. Add data-driven, remove speculative. The lab below shows the exact same query going from SCAN to SEARCH the instant the right index appears.
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 QUERY PLAN tells you which you got.
Compare the two panels: left is the query with no index, right is the same query after the right index exists.
- On the left, the arrow sweeps across every row to find the one red match — that is a full
SCAN, O(n): double the rows, double the work. - On the right, the amber bar is the sorted index on
customer_id; the arrow jumps straight to the green matching row — aSEARCH USING INDEX, roughly O(log n), like flipping to a book's back-of-index. - The payoff:
EXPLAIN QUERY PLANreports SCAN or SEARCH without executing the query, and the gap between the two only widens as the table grows — which is exactly why a query that was fine in testing dies in production.
In short: SCAN = read everything (slow at scale); SEARCH USING INDEX = seek to the match (fast). Index the columns you filter/join/sort on, then confirm the plan flipped.
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
Here is the nightmare every database exists to prevent. You transfer $100 from savings to checking. The database subtracts $100 from savings — and right then the server loses power, the network drops, or the process crashes. It never got to add the $100 to checking. Reboot, and the money is simply gone: it left one account and arrived in none. No amount of clever code around the two statements saves you, because the failure struck between them. This isn't a rare edge case — with enough traffic, "between two statements" happens constantly. The mechanism that makes it impossible is the transaction.
A transaction wraps several changes into one indivisible unit with a simple promise: all of them happen, or none of them do. You mark the start with BEGIN, do your work, and then either COMMIT (make every change permanent at once) or ROLLBACK (undo everything back to the start, as if you never began). The analogy is a wedding vow: it isn't "half-married" if the officiant faints mid-sentence — either the whole ceremony completes or it didn't happen. The transfer either fully lands on both accounts or neither budges. There is no in-between state the outside world can ever observe.
That all-or-nothing guarantee is the "A" — Atomicity — in the famous ACID acronym, and it's worth having all four in your head because they're the promises a real database makes that a pile of files never will. Atomic: all-or-nothing, as above. Consistent: rules and constraints hold before and after (a balance can't go negative if you forbid it). Isolated: concurrent transactions don't see each other's half-done work, so two simultaneous transfers can't corrupt one account. Durable: once COMMIT returns, the data survives a crash — it's really on disk, not just in memory.
The mistake beginners make is treating error handling as optional decoration. They write the two UPDATEs inside a try, forget the except, and when something throws, the transaction is left dangling — or worse, they never call rollback() and a half-applied change leaks out. The pattern that keeps the promise is disciplined and always the same: BEGIN, do the steps, commit() on success, and rollback() in an except block on any failure. The rollback is not the sad path you hope to skip — it's the whole point of the machinery, the thing that turns "money vanished" into "nothing happened, try again." In the lab below, deliberately deleting the rollback() is how you watch money disappear.
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.
COMMITs both at once; if anything fails it ROLLBACKs both, so money never leaves one account without arriving in the other.
Follow the flow left-to-right from BEGIN, through the two updates, to the fork where the transaction ends one of two ways.
BEGINopens the transaction; the amber box is the work — the twoUPDATEs (subtract from the source account, add to the destination) that must stay in sync.- The fork is the whole point: the green path (all steps OK) leads to
COMMIT, making both updates permanent at once; the red path (any error or crash) leads toROLLBACK, undoing both as if nothing happened. - The payoff: there is no branch where only one update sticks — that impossible "money vanished" half-state is exactly what atomicity, the A in ACID, forbids.
In short: Wrap the steps in BEGIN … commit() on success, rollback() on any failure — the rollback is the machinery's whole purpose, not an afterthought.
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.