AI EngineeringZero to ProductionHome·About·Contact
System Design · Chapter SD1

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.

⏱️ ~3 hours🧪 8 labs🎯 Beginner→Expert
🌱 Start here — from zero SQL, from scratch — no database experience needed — every query here runs on Python's built-in 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):

TermWhat it actually means
tablea grid of data: columns (fields) and rows (records), like a spreadsheet tab.
row / recordone entry in a table (one customer, one order).
primary keya column that uniquely identifies each row (e.g. id).
querya SQL statement that reads or changes data (SELECT, INSERT, …).
JOINcombining rows from two tables by a matching column.

What you need before starting:

  • Python basics (the Python track is enough).
  • Nothing to install — sqlite3 is 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 essentialexpert 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.
▶ Runnable companionEvery code block here is also saved under 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · Create & seed
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
▶ How this works

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.

  1. sqlite3.connect(":memory:") opens a brand-new SQL database that lives in RAM (nothing is written to disk). conn.cursor() gives you cur, the handle you send SQL through.
  2. CREATE TABLE defines the shape of a table. customers gets an id marked PRIMARY KEY (a unique row identifier), a required name (NOT NULL = can't be empty), and an optional city. In orders, customer_id REFERENCES customers(id) is a foreign key — it says each order belongs to a customer.
  3. 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.
  4. conn.commit() saves the changes. The final print runs two SELECT 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.

Step 2 · Filter & sort (continues Step 1)
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)]
▶ How this works

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.

  1. SELECT name, city FROM customers asks for just two columns out of the customers table (not the whole row).
  2. WHERE city = ? keeps only the rows whose city matches — the ? is filled by the tuple ("Seattle",), so it means where city = 'Seattle'. ORDER BY name then sorts those rows alphabetically.
  3. The for row in cur.execute(...) loop walks the matching rows one at a time; each row is a tuple like ('Ava', 'Seattle').
  4. 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.

Always use ? 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.

customers who JOIN on id=customer_id the link orders what they bought
🗺️ How to read this diagram

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's id to the order's customer_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.

Step 3 · INNER vs LEFT JOIN (continues Step 1)
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)]
▶ How this works

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.

  1. In the first query, FROM orders o JOIN customers c ON c.id = o.customer_id pairs each order with its customer. The letters o and c are short aliases so you can write o.amount and c.name. A plain JOIN is an INNER join: rows survive only if a match exists on both sides.
  2. ORDER BY o.amount DESC LIMIT 3 sorts orders biggest-first (DESC = descending) and keeps just the top 3.
  3. The second query uses LEFT JOIN: it keeps every customer even if they have no orders. COUNT(o.id) AS n counts each customer's orders (0 for someone with none), and GROUP BY c.id makes that count per-customer.
  4. AS n just renames the counted column to n so 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.

JOINKeepsUse for
INNERonly matching rows"orders with a customer"
LEFTall left rows + matches"customers incl. those with 0 orders"
CROSSevery combinationgenerating 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.

Step 4 · Group, aggregate, HAVING (continues Step 1)
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)
▶ How this works

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.

  1. The query joins orders to customers (so every order knows its city), then GROUP BY c.city collapses all rows into one row per city.
  2. For each city group, COUNT(*) AS n_orders counts its orders and ROUND(SUM(o.amount),2) AS revenue adds up the amounts (rounded to 2 decimals). These aggregates only make sense because the rows are grouped.
  3. HAVING revenue > 50 filters the groups after aggregating. This is the key distinction: WHERE filters individual rows before grouping, HAVING filters the summarized groups after.
  4. ORDER BY revenue DESC then 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.

Step 5 · Subquery then the same as a CTE (continues Step 1)
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']
▶ How this works

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.

  1. The nested version reads inside-out. The deepest part, SELECT SUM(amount) ... GROUP BY customer_id, gets each customer's total; wrapping it in SELECT AVG(t) FROM (...) gets the average of those totals.
  2. The middle layer keeps customer_ids whose SUM(amount) beats that average (HAVING SUM(amount) > (...)), and the outer WHERE id IN (...) turns those ids into names. It works, but you must read it from the innermost parentheses out.
  3. The second version uses a CTEWITH 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.
  4. Now the final SELECT reads 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.

Reach for CTEs once a query nests twiceNested subqueries get unreadable fast. A CTE names each step, so the query reads like a sequence of clear definitions. Most 'hard' SQL is just several CTEs stacked.

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.

Step 6 · Running total, rank, LAG (continues Step 1)
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)
▶ How this works

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.

  1. Every window function has an OVER (...) clause that defines its "window" — the set of rows it looks at. PARTITION BY c.id restarts the calculation for each customer (it's like GROUP BY but doesn't merge rows), and ORDER BY o.created sets the order within that window.
  2. SUM(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS running gives a running total: each row shows that customer's spending accumulated up to and including that order.
  3. RANK() OVER (ORDER BY o.amount DESC) AS rk ranks every order globally by amount, biggest = rank 1.
  4. LAG(o.amount) OVER (PARTITION BY c.id ORDER BY o.created) AS prev reaches back to the previous row in the window — the customer's prior order amount (None on 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 = "GROUP BY for windows"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.

Step 7 · Before/after an index (continues Step 1)
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=?)
▶ How this works

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.

  1. The first EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1 reports the plan before any index exists. plan_before[0][-1] pulls the human-readable description out of the result.
  2. CREATE INDEX idx_orders_customer ON orders(customer_id) builds an index — a sorted lookup structure on the customer_id column, like the index at the back of a book.
  3. Running the exact same EXPLAIN QUERY PLAN again shows the plan after the index exists. The database now chooses a faster strategy automatically.
  4. 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.

Index the columns you filter/join on — not everythingIndexes speed reads but slow writes and cost space. Index the columns in your 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.

Step 8 · An atomic transfer (runs standalone)
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)]
▶ How this works

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.

  1. The setup creates an accounts table with two accounts (account 1 has 100, account 2 has 0) so we have something to transfer between.
  2. Inside transfer, db.execute("BEGIN") starts a transaction. The two UPDATE statements subtract from the source and add to the destination — the two halves that must stay in sync.
  3. The check if bal < 0: raise ValueError(...) catches an overdraft. If the source would go negative, raising an error jumps to the except block.
  4. 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.

ACID in one line eachAtomic (all-or-nothing), Consistent (rules always hold), Isolated (concurrent txns don't corrupt each other), Durable (committed survives a crash). SQL gives you these; many NoSQL stores relax some (SD7).

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.

Exercise 1 · Create a table and SELECT itBeginner

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 customers with an INTEGER PRIMARY KEY plus text columns
  • Insert rows through parameterized placeholders (?), never string formatting, so user data can't inject SQL
  • Use executemany to 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.

Exercise 2 · WHERE + ORDER BYIntermediate

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.

Exercise 3 · JOIN two tablesAdvanced

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_id references customers.id
  • Join them with an explicit ON c.id = o.cust_id condition
  • 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.

Exercise 4 · GROUP BY + aggregationExpert

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(*) and SUM(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 WHERE filters rows before grouping while HAVING filters 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.

Exercise 5 · Subquery / CTE for a derived metricProfessional

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.

Exercise 6 · Window function + read the query planIndustry scenario

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 SCAN to SEARCH ... 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

✓ Knowledge check

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.
✓ Knowledge check

What does EXPLAIN QUERY PLAN reveal about a query, and how does adding an index change the plan for a filtered lookup?

Show answer
It describes how SQLite will run a query without executing it — specifically whether it does a full 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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in