Part 9 · Data Engineering · Module 61

Analytical SQL & DuckDB

SQL is the lingua franca of data. Basic SELECT … GROUP BY gets you started; analytical SQL (CTEs, recursive queries, window functions, conditional aggregation, upserts) is what lets you answer real business questions in one readable query. You will practice it all with Python's built-in sqlite3, right here in the browser, then meet DuckDB, an in-process analytical engine that runs the same SQL over Parquet files at remarkable speed.

How a query is evaluated

You write SQL in the order SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but the database evaluates it (logically) in a different order. Knowing that order explains most "why can't I use that alias here?" errors:

  1. FROM and JOIN: build the input rows.
  2. WHERE: filter individual rows (no aggregates yet, no SELECT aliases).
  3. GROUP BY: collapse rows into groups.
  4. HAVING: filter groups (aggregates allowed).
  5. Window functions are computed here, over the rows that survived.
  6. SELECT: compute output columns and aliases.
  7. DISTINCT, then ORDER BY (aliases allowed), then LIMIT/OFFSET.

Throughout this module we use a small shop dataset. Run the cell to create it and look at it.

Explanation

ORDER BY runs after SELECT, so it can use the alias. WHERE runs before grouping, so an aggregate there is an error ("misuse of aggregate"); that filter belongs in HAVING. (SQLite is lenient and also lets WHERE use a SELECT alias, but standard SQL and most engines do not, so don't rely on it.)

Remember: FROM → WHERE → GROUP BY → HAVING → windows → SELECT → ORDER BY → LIMIT.

Common table expressions

A CTE (WITH name AS (…)) names a subquery so later parts of the query can use it like a table. CTEs turn a nested, inside-out query into a top-to-bottom story of small steps, each with a name that says what it is. That is their real value: readability and testability, not speed.

Recursive CTEs: walking a hierarchy

A recursive CTE refers to itself. It has an anchor member (the starting rows) and a recursive member joined to the CTE's previous output, combined with UNION ALL. The database repeats the recursive step until it produces no new rows. Classic uses: org charts, bill-of-materials, category trees, and generating series of dates.

flowchart TD
  A[1 Grace, CEO] --> B[2 Alan, CTO]
  A --> C[3 Ada, CFO]
  B --> D[4 Linus, Eng Manager]
  B --> E[5 Barbara, Architect]
  D --> F[6 Guido, Engineer]
  D --> G[7 Margaret, Engineer]
  C --> H[8 Katherine, Analyst]
    

Remember: CTEs name steps; recursive CTEs = anchor UNION ALL recursive step, with a termination condition.

Window functions

An aggregate with GROUP BY collapses rows: ten orders become two region totals. A window function computes across a set of related rows without collapsing them: every order keeps its row and gains a column such as "rank within its region" or "running total so far". The syntax is function(...) OVER (PARTITION BY … ORDER BY … frame):

Ranking

Explanation

ROW_NUMBER always counts 1, 2, 3, 4 (ties broken by the extra player sort). RANK gives ties the same rank and then skips: 1, 1, 3, 4. DENSE_RANK gives ties the same rank without gaps: 1, 1, 2, 3.

The most common pattern in analytics is "top N per group": rank inside a partition, then filter in an outer query (you cannot filter on a window function in WHERE, because windows are computed after WHERE).

LAG and LEAD: comparing with neighbours

LAG(x, n, default) reads x from n rows earlier in the window order; LEAD reads later rows. They make period-over-period comparisons a one-liner.

The WINDOW w AS (…) clause names a window definition so you can reuse it.

Frames: running totals and moving averages

With ORDER BY inside OVER, an aggregate's default frame is "from the start of the partition up to the current row (and its ties)", which gives a running total. An explicit ROWS BETWEEN frame gives a moving window.

Remember: windows keep rows; PARTITION BY groups, ORDER BY orders, the frame chooses neighbours; filter on window results in an outer query.

CASE and conditional aggregation

CASE WHEN … THEN … ELSE … END is SQL's if/else expression. Inside an aggregate it becomes conditional aggregation, the standard way to pivot rows into columns: one pass over the data computes many filtered totals at once.

COUNT(*) FILTER (WHERE …) is the standard SQL shorthand for conditional aggregation (SQLite 3.30+, PostgreSQL, DuckDB). CASE works everywhere.

Remember: one GROUP BY plus several SUM(CASE …) columns is a pivot.

Upserts and idempotent loads

Pipelines re-run: after a crash, during a backfill, or because a file arrived twice. A load that uses plain INSERT duplicates rows on every re-run. An upsert ("update or insert") makes the load idempotent: running it twice leaves the same table as running it once. SQLite and PostgreSQL spell it INSERT … ON CONFLICT (key) DO UPDATE SET …; the special table excluded holds the row you tried to insert.

The conflict target must be backed by a PRIMARY KEY or UNIQUE constraint. Other engines use MERGE INTO … WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT (standard SQL, supported by PostgreSQL 15+, Snowflake, BigQuery, DuckDB 1.4+, Delta, Iceberg) for the same job.

Remember: idempotent loads = a natural key + upsert. You will rely on this in M62 and Project 9.

Working with dates

SQLite has no date type. Store timestamps as ISO-8601 text in UTC ('2026-03-05T13:00:00+00:00' or '2026-03-05 13:00:00'): ISO strings sort correctly as text, and the date functions understand them. The core functions are date(), time(), datetime(), julianday(), unixepoch() (3.38+), and strftime(format, value, modifiers…). Modifiers shift values: '+1 day', '-7 days', 'start of month', 'weekday 1' (next Monday).

Remember: store UTC ISO-8601 text, bucket with strftime/date modifiers, and keep time-zone logic in one well-tested place.

Query plans and indexes

SQL is declarative: you say what, the query planner decides how. To see its decision, prefix a query with EXPLAIN QUERY PLAN. The two words to look for in SQLite are SCAN (read every row of the table, O(n)) and SEARCH … USING INDEX (jump to matching rows via a B-tree, O(log n) per lookup).

Index guidelines for analytical work:

Remember: read the plan before guessing; SCAN on a big table behind a selective filter means an index is missing.

DuckDB: in-process OLAP

DuckDB is "SQLite for analytics": an embedded database that runs inside your Python process (no server), but with a columnar, vectorized, multi-threaded engine built for scans and aggregations. It reads CSV, JSON, and Parquet files directly (locally or from S3/HTTP), speaks a rich PostgreSQL-like SQL dialect, and exchanges data with pandas, Polars, and Arrow with little or no copying. For data up to hundreds of gigabytes on one machine, it often replaces a cluster.

DuckDB's SQL has friendly extensions worth knowing: GROUP BY ALL, SELECT * EXCLUDE (col), FROM tbl without SELECT, QUALIFY to filter on window functions directly (QUALIFY row_number() OVER (…) = 1), PIVOT/UNPIVOT, and list/struct types. Every window-function query you wrote for SQLite runs unchanged.

Remember: DuckDB = embedded, columnar, vectorized; query files in place; hand results to pandas or Polars with .df() / .pl().

SQL style and testing

Analytical SQL lives for years and is read far more than written. Professional conventions:

Test SQL like code: build a tiny dataset where you know the right answer by hand, run the query, assert the result. Keep queries in .sql files or module constants so tests and production run the same text.

# test_reports.py  (run with: pixi run pytest)
import sqlite3
import pytest
from reports import TOP_CUSTOMER_PER_REGION   # the production query string

@pytest.fixture
def conn():
    c = sqlite3.connect(":memory:")
    c.executescript("""
        CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, region TEXT);
        CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL, status TEXT);
        INSERT INTO customers VALUES (1, 'Ada', 'north'), (2, 'Cy', 'north'), (3, 'Ben', 'south');
        INSERT INTO orders VALUES (1, 1, 50, 'paid'), (2, 2, 70, 'paid'), (3, 2, 500, 'refunded'), (4, 3, 10, 'paid');
    """)
    yield c
    c.close()

def test_refunds_do_not_count(conn):
    rows = conn.execute(TOP_CUSTOMER_PER_REGION).fetchall()
    assert rows == [("north", "Cy", 70.0), ("south", "Ben", 10.0)]

def test_empty_tables_return_nothing(conn):
    conn.execute("DELETE FROM orders")
    assert conn.execute(TOP_CUSTOMER_PER_REGION).fetchall() == []

Include edge cases in the fixture on purpose: ties, NULLs, refunds, a region with no orders, a day with no sales. In dbt the same idea appears as data tests (unique, not_null, relationships) and unit tests with mocked inputs.

Remember: readable CTEs, named columns, bound parameters, and a hand-checked fixture for every query.

Practice

Each exercise asks you to assign a SQL string to a variable. The tests build the dataset shown in the prompt in an in-memory SQLite database, run your query, and compare the rows. Column names don't matter; column order and row order do.

Table orders(id, order_date TEXT, amount REAL, status TEXT) where status is 'paid' or 'refunded'. Write QUERY returning one row per order_date (ascending) with: the date, the number of paid orders, paid revenue, and refunded revenue (use 0, not NULL, when there is none).

Hint

SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) counts; SUM(CASE WHEN … THEN amount ELSE 0 END) totals. Integer 0 vs 0.0: SQLite sums a REAL column to a float.

Show solution
QUERY = """
SELECT order_date,
       SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)          AS paid_orders,
       SUM(CASE WHEN status = 'paid' THEN amount ELSE 0.0 END)     AS paid_revenue,
       SUM(CASE WHEN status = 'refunded' THEN amount ELSE 0.0 END) AS refunded_revenue
FROM orders
GROUP BY order_date
ORDER BY order_date
"""

Table orders(id, customer_id, order_date TEXT, amount REAL). Write QUERY returning each customer's most recent order as (customer_id, id, order_date, amount), ordered by customer_id. If a customer has two orders on the same latest date, pick the one with the larger id. Use ROW_NUMBER().

Hint

ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, id DESC), then keep rn = 1 in the outer query.

Show solution
QUERY = """
WITH ranked AS (
    SELECT customer_id, id, order_date, amount,
           ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, id DESC) AS rn
    FROM orders
)
SELECT customer_id, id, order_date, amount
FROM ranked
WHERE rn = 1
ORDER BY customer_id
"""

Table daily(day TEXT, revenue REAL), one row per day. Write QUERY returning (day, revenue, running_total, moving_avg) ordered by day, where moving_avg is the average of the current and up to two previous rows, rounded to 2 decimals with ROUND.

Hint

SUM(revenue) OVER (ORDER BY day) and AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW).

Show solution
QUERY = """
SELECT day,
       revenue,
       SUM(revenue) OVER (ORDER BY day) AS running_total,
       ROUND(AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg
FROM daily
ORDER BY day
"""

Table sales(region TEXT, month TEXT, revenue REAL). Write QUERY returning (region, month, revenue, prev_revenue, growth_pct) ordered by region then month. prev_revenue is the previous month's revenue in the same region (NULL for the first month), and growth_pct is ROUND(100.0 * (revenue - prev) / prev, 1) (NULL when there is no previous month).

Hint

Compute LAG(revenue) OVER (PARTITION BY region ORDER BY month) in a CTE, then do the arithmetic in the outer query. Arithmetic with NULL gives NULL, which is what you want.

Show solution
QUERY = """
WITH with_prev AS (
    SELECT region, month, revenue,
           LAG(revenue) OVER (PARTITION BY region ORDER BY month) AS prev_revenue
    FROM sales
)
SELECT region, month, revenue, prev_revenue,
       ROUND(100.0 * (revenue - prev_revenue) / prev_revenue, 1) AS growth_pct
FROM with_prev
ORDER BY region, month
"""

Table employees(id, name, manager_id); the CEO has manager_id NULL. Write QUERY with a recursive CTE that returns (name, depth) for every employee who reports, directly or indirectly, to the employee with id 2, where depth is 1 for direct reports, 2 for their reports, and so on. Order by depth, then name. Employee 2 is not included.

Hint

Anchor: SELECT id, name, 1 FROM employees WHERE manager_id = 2. Recursive step: join employees e to reports r on e.manager_id = r.id with r.depth + 1.

Show solution
QUERY = """
WITH RECURSIVE reports(id, name, depth) AS (
    SELECT id, name, 1 FROM employees WHERE manager_id = 2
    UNION ALL
    SELECT e.id, e.name, r.depth + 1
    FROM employees e JOIN reports r ON e.manager_id = r.id
)
SELECT name, depth FROM reports ORDER BY depth, name
"""

Write load_inventory(conn, rows) where rows is a list of (sku, qty, as_of) tuples (as_of is an ISO date string). Create the table inventory(sku TEXT PRIMARY KEY, qty INTEGER, as_of TEXT) if it doesn't exist, then upsert every row so that an incoming row replaces the stored one only if its as_of is newer. Loading the same batch twice must leave the same table. Commit at the end.

Hint

ON CONFLICT (sku) DO UPDATE SET qty = excluded.qty, as_of = excluded.as_of WHERE excluded.as_of > inventory.as_of.

Show solution
def load_inventory(conn, rows):
    conn.execute("CREATE TABLE IF NOT EXISTS inventory (sku TEXT PRIMARY KEY, qty INTEGER, as_of TEXT)")
    conn.executemany("""
        INSERT INTO inventory (sku, qty, as_of) VALUES (?, ?, ?)
        ON CONFLICT (sku) DO UPDATE SET qty = excluded.qty, as_of = excluded.as_of
        WHERE excluded.as_of > inventory.as_of
    """, rows)
    conn.commit()

Mini-project: a sales analytics SQL report

Mini-projectMonthly sales report in SQL

Build a small reporting package whose every number comes from a tested SQL query. Generate a year of sales for a fictional store (products, categories, regions, salespeople) with a fixed seed, load it into SQLite, and produce a Markdown report.

pixi init sales-report && cd sales-report
pixi add python pytest
pixi add duckdb polars          # for the stretch goals
pixi run python generate.py     # writes sales.db
pixi run python report.py > report.md
pixi run pytest

Queries to write (each a constant in queries.py):

  1. Monthly revenue with month-over-month growth (LAG).
  2. Running year-to-date revenue and a 3-month moving average.
  3. Top 3 products per category by revenue (DENSE_RANK, ties included).
  4. Each salesperson's share of their region's revenue (window SUM over a partition).
  5. A region × quarter pivot with conditional aggregation.
  6. Customers whose order count dropped by half or more versus the previous quarter.
  • Queries are CTE-structured, use named columns, and bind every parameter
  • Each query has a pytest test against a small hand-checked fixture (including a tie and an empty month)
  • The report renders as a Markdown table per query
  • EXPLAIN QUERY PLAN output is checked for the two slowest queries, with an index added where it helps
  • Re-running generate.py produces identical data (fixed seed, upserts)

Stretch: export the tables to Parquet with DuckDB and run the same queries with DuckDB, replacing the top-N subquery with QUALIFY; compare timings on 10 million rows; return results as Polars frames.

Quiz

Recap

Going further