Index Ai Academy
Phase 7 · Software engineering · Building software

SQL for analysis

Subqueries, CASE, common table expressions, outer joins and window functions on real order data.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The shop's data in a database
  2. Sharper filters
  3. CASE: sorting rows into bands
  4. Questions in steps: subqueries and WITH
  5. Categories that sold well
  6. The rows that aren't there: outer joins
  7. Window functions: comparing across rows
  8. 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"))
It runs right here in your browser, with nothing to install.

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

  • LIKE matches a pattern: % stands for any run of characters and _ for exactly one. In SQLite it ignores the case of plain letters.
  • BETWEEN a AND b includes both ends, and works on dates stored as YYYY-MM-DD text, because those sort in date order.
  • IN (...) matches any value in a list, and IS NULL finds missing values. (= NULL never matches anything, even a missing value: NULL means "unknown", and nothing equals unknown.)
  • LIMIT n OFFSET k pages through results: skip k rows, take n. Without an ORDER 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
Quick check
What does this print?
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())
  1. (3, 2, 2.8)
  2. (3, 3, 2.8)
  3. (3, 2, 4.2)
  4. (2, 2, 4.2)
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.

Try it now

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.

Drill

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)
In the lesson, your code is checked as you write it.

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.

Quick check
A query uses WHERE customer_id NOT IN (SELECT customer_id FROM orders), and one order has a NULL customer_id. What does it return?
  1. An error about the NULL
  2. Every customer who never ordered
  3. No rows, because comparing with NULL is never true
  4. Every customer
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

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)
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why must a filter on RANK() OVER (...) go in an outer query rather than the same query's WHERE?

  1. Window functions are worked out at the SELECT step, after WHERE has already filtered the rows
  2. SQLite doesn't allow functions in WHERE
  3. RANK only works inside GROUP BY
  4. It's just a style rule

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson