SQL for analysis
Subqueries, CASE, common table expressions, outer joins and window functions on real order data.
Create a free account Try part of it here
Why it matters
Most of a working developer's or analyst's SQL isn't creating tables: it's answering questions. Which products are top sellers in each category? How is revenue trending month by month? Which items haven't sold since the holidays? The basics from the SQL lesson get you started, but questions like these need a few more tools, the same ones that come up in nearly every data interview. Here you'll use them on the practice shop's real order history, mess included.
What you'll be able to do
- Filter with LIKE, BETWEEN, IN and IS NULL, and page through results
- Sort rows into bands with CASE, and count conditionally inside one query
- Break a question into steps with subqueries and WITH (common table expressions)
- Find the rows that aren't there with outer joins, and avoid the NOT IN trap
- Rank, total and compare across rows with window functions, and pass results to pandas
What it covers
- The shop's data in a database
- Sharper filters
- CASE: sorting rows into bands
- Questions in steps: subqueries and WITH
- Categories that sold well
- The rows that aren't there: outer joins
- Window functions: comparing across rows
- Worked example: SQL or pandas?
The shop's data in a database
The practice API serves the shop's order lines for 2024 and 2025, its 40-product catalog, its product lines and its stores. pandas can read each one and write it into a SQLite table with to_sql, and pd.read_sql_query runs a query and hands back a DataFrame, which prints as a tidy table:
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog", "product-lines", "stores"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t.replace("-", "_"), db, index=False) def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) print(q("SELECT COUNT(*) AS lines, COUNT(DISTINCT order_id) AS orders FROM orders")) print(q("SELECT * FROM orders LIMIT 3"))
Each row of orders is one order line: an order with three different products has three lines with the same order_id. The data also has two known problems, as real exports do: 40 lines appear twice, and six lines are test orders for a pretend product, TEST-000, priced at 9,999. Keep them in mind: they'll matter.
Sharper filters
LIKEmatches a pattern:%stands for any run of characters and_for exactly one. In SQLite it ignores the case of plain letters.BETWEEN a AND bincludes both ends, and works on dates stored asYYYY-MM-DDtext, because those sort in date order.IN (...)matches any value in a list, andIS NULLfinds missing values. (= NULLnever matches anything, even a missing value: NULL means "unknown", and nothing equals unknown.)LIMIT n OFFSET kpages through results: skip k rows, take n. Without anORDER BY, the order is up to the database, so a page could show different rows each time.
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) print(q("SELECT sku, name, price FROM catalog WHERE name LIKE '%tent%' ORDER BY price")) print(q("SELECT store, COUNT(*) AS lines FROM orders WHERE date BETWEEN '2025-12-24' AND '2025-12-26' GROUP BY store")) print(q("SELECT name FROM catalog WHERE rating IS NULL")) print(q("SELECT sku, price FROM catalog ORDER BY price DESC LIMIT 3 OFFSET 3")) # the 4th to 6th priciest
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE p (name TEXT, rating REAL)") db.executemany("INSERT INTO p VALUES (?, ?)", [("tent", 4.5), ("stove", None), ("mug", 3.9)]) print(db.execute("SELECT COUNT(*), COUNT(rating), AVG(rating) FROM p").fetchone())
Show the answer
(3, 2, 4.2)
COUNT(*) counts rows, all three. COUNT(rating) and AVG(rating) skip the NULL, so they see two ratings, whose average is 4.2. An unrated product isn't a zero.
CASE: sorting rows into bands
CASE is SQL's if-elif-else. It works anywhere a value can go, so it can make a new column, a grouping key, or a condition inside an aggregate, which answers several questions in one pass over the data:
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) print(q(""" SELECT CASE WHEN price < 50 THEN 'under $50' WHEN price < 150 THEN '$50 to $149' ELSE '$150 and up' END AS band, COUNT(*) AS products FROM catalog GROUP BY band ORDER BY MIN(price) """)) print(q(""" SELECT store, ROUND(SUM(qty * unit_price)) AS revenue, ROUND(100.0 * SUM(CASE WHEN member = 1 THEN qty * unit_price ELSE 0 END) / SUM(qty * unit_price), 1) AS member_pct FROM orders WHERE sku <> 'TEST-000' GROUP BY store ORDER BY revenue DESC """))
Like Python's elif, CASE takes the first branch that matches, so the order of the WHENs matters. And note the 100.0: in SQL, dividing one whole number by another throws away the remainder, so 1 / 4 is 0.
SQL is written SELECT first but worked out in this order, which is why a WHERE can't use a column alias but ORDER BY can.
Questions in steps: subqueries and WITH
A subquery is a query inside another. In WHERE price > (SELECT AVG(price) FROM catalog) the inner query runs first and gives one number. A subquery can also stand in for a table in FROM, or give a list for IN.
Big questions are easier as named steps. WITH (a common table expression, or CTE) names a query so the next step can use it like a table. The first step here cleans the data, the duplicates and test orders, so nothing after it has to remember to:
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) print(q(""" WITH clean AS ( SELECT DISTINCT * FROM orders WHERE sku <> 'TEST-000' ), by_store AS ( SELECT store, SUM(qty * unit_price) AS revenue FROM clean GROUP BY store ) SELECT store, ROUND(revenue) AS revenue FROM by_store WHERE revenue > (SELECT AVG(revenue) FROM by_store) """)) print(q("SELECT COUNT(*) AS raw_lines FROM orders"), q("WITH clean AS (SELECT DISTINCT * FROM orders WHERE sku <> 'TEST-000') SELECT COUNT(*) AS clean_lines FROM clean"))
The answer looks small, one store, until you check the numbers: the web shop sells more than all three stores together, so only it beats the average. That's a finding worth a second query before anyone acts on it.
Categories that sold well
Using the clean step, list each category whose total units sold (SUM(qty)) is over 1,240, as category and units, biggest first. Join clean to catalog on sku, and filter the groups with HAVING, not WHERE.
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) SQL = """ WITH clean AS (SELECT DISTINCT * FROM orders WHERE sku <> 'TEST-000') SELECT c.category, SUM(o.qty) AS units FROM clean o JOIN catalog c ON c.sku = o.sku GROUP BY c.category ORDER BY units DESC """ result = pd.read_sql_query(SQL, db) print(result)
The rows that aren't there: outer joins
An ordinary (inner) join keeps only rows that match on both sides. A LEFT JOIN keeps every row of the left table, filling the right side's columns with NULL where nothing matches. That makes it the tool for questions about absence: which products haven't sold, which customers never ordered. The pattern, called an anti-join, is a left join followed by WHERE right_side.key IS NULL.
The shop launched a new product yesterday. It's in the catalog but has no orders yet:
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) db.execute("INSERT INTO catalog (sku, name, category, price) VALUES ('LA-131', 'Glimmer lantern', 'lighting', 58)") def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) print(q(""" SELECT c.sku, c.name FROM catalog c LEFT JOIN orders o ON o.sku = c.sku WHERE o.sku IS NULL """)) # The same question with NOT IN works here, but watch what one NULL does: db.execute("CREATE TABLE returns (sku TEXT)") db.executemany("INSERT INTO returns VALUES (?)", [("TE-101",), (None,)]) print(q("SELECT COUNT(*) AS not_returned FROM catalog WHERE sku NOT IN (SELECT sku FROM returns)"))
The last query says no product at all was never returned, which is plainly wrong. x NOT IN (a, NULL) asks "is x different from a, and from an unknown value?", and the honest answer to the second part is "unknown", so the whole condition is never true. Use a left join with IS NULL, or NOT EXISTS, whenever the list could contain a NULL.
WHERE customer_id NOT IN (SELECT customer_id FROM orders), and one order has a NULL customer_id. What does it return?Show the answer
No rows, because comparing with NULL is never true
With a NULL in the list, NOT IN can never be true, so the query silently returns nothing. A LEFT JOIN with IS NULL, or NOT EXISTS, doesn't have this trap.
Window functions: comparing across rows
GROUP BY collapses rows. A window function calculates over a set of rows but keeps every row, adding the result beside it. You write it with OVER (...): PARTITION BY says which rows belong together (like a group), and ORDER BY sets the order within them.
RANK() OVER (PARTITION BY category ORDER BY revenue DESC)numbers products within each category, 1 for the best.SUM(revenue) OVER (ORDER BY month)is a running total: each row adds itself to everything before it.LAG(revenue) OVER (ORDER BY month)is the previous row's value, for month-on-month change.
import sqlite3 import pandas as pd db = sqlite3.connect(":memory:") for t in ["orders", "catalog"]: pd.read_csv(f"https://api.gradient.test/v1/datasets/{t}.csv").to_sql(t, db, index=False) def q(sql, params=()): return pd.read_sql_query(sql, db, params=params) CLEAN = "WITH clean AS (SELECT DISTINCT * FROM orders WHERE sku <> 'TEST-000')" print(q(CLEAN + """, revenue AS ( SELECT c.category, c.name, SUM(o.qty * o.unit_price) AS revenue FROM clean o JOIN catalog c ON c.sku = o.sku GROUP BY c.sku ) SELECT * FROM ( SELECT category, name, ROUND(revenue) AS revenue, RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank_in_category FROM revenue ) WHERE rank_in_category <= 2 """)) monthly = q(CLEAN + """, months AS (SELECT substr(date, 1, 7) AS month, SUM(qty * unit_price) AS revenue FROM clean GROUP BY month) SELECT month, ROUND(revenue) AS revenue, ROUND(SUM(revenue) OVER (ORDER BY month)) AS running_total, ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month)) / LAG(revenue) OVER (ORDER BY month), 1) AS change_pct FROM months """) print(monthly.head(6))
The rank has to be filtered in an outer query, because window functions are worked out at the SELECT step, after WHERE has already run. The first month's change is NaN, pandas' name for a missing number: there's no month before it to compare with.
Worked example: SQL or pandas?
The same question, top store by member revenue in 2025, answered both ways. Both are right; the choice depends on where the data lives and how big it is.
import sqlite3 import pandas as pd orders = pd.read_csv("https://api.gradient.test/v1/datasets/orders.csv") db = sqlite3.connect(":memory:") orders.to_sql("orders", db, index=False) in_sql = pd.read_sql_query(""" SELECT store, ROUND(SUM(qty * unit_price)) AS member_revenue FROM (SELECT DISTINCT * FROM orders) WHERE member = 1 AND sku <> 'TEST-000' AND date >= ? GROUP BY store ORDER BY member_revenue DESC """, db, params=("2025-01-01",)) clean = orders.drop_duplicates() clean = clean[(clean.member == 1) & (clean.sku != "TEST-000") & (clean.date >= "2025-01-01")] in_pandas = (clean.assign(revenue=clean.qty * clean.unit_price) .groupby("store")["revenue"].sum().round().sort_values(ascending=False)) print(in_sql) print(in_pandas)
Use SQL when the data is in a database, especially a big one: the database filters and adds up the rows where they live, and only the small answer travels to your program. It's also the shared language of analysts, dashboards and other teams. Use pandas when the data is already in memory, for steps SQL makes awkward (reshaping, plots, statistics), or to explore. A common pattern is both: SQL to cut millions of rows down to the ones you need, then pandas for the rest. Notice params=(...) and the ?: values go in through placeholders, never by pasting them into the SQL string, for the same injection reasons as in the SQL lesson.
Practice what you just read
- 7 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
Why must a filter on RANK() OVER (...) go in an outer query rather than the same query's WHERE?