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.
- Explain SQL's logical evaluation order and structure queries with CTEs
- Walk hierarchies with recursive CTEs
- Use window functions for ranking, period-over-period change, running totals, and moving averages
- Pivot with CASE and conditional aggregation; write idempotent upserts with
ON CONFLICT - Handle dates in SQL, read query plans, and add the right index
- Query CSV and Parquet with DuckDB and move data between DuckDB, pandas, and Polars
- Write SQL that is readable and tested
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:
FROMandJOIN: build the input rows.WHERE: filter individual rows (no aggregates yet, noSELECTaliases).GROUP BY: collapse rows into groups.HAVING: filter groups (aggregates allowed).- Window functions are computed here, over the rows that survived.
SELECT: compute output columns and aliases.DISTINCT, thenORDER BY(aliases allowed), thenLIMIT/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):
PARTITION BYsplits rows into independent groups (likeGROUP BY, but without collapsing).ORDER BYorders rows inside each partition (needed for ranking,LAG, running totals).- The frame (
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) picks which rows around the current one an aggregate sees.
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:
- Index columns used in selective
WHEREfilters and join keys. A filter matching half the table gains nothing from an index; a scan is cheaper. - A composite index
(a, b)helps filters ona, oraandb, but not onbalone (leftmost-prefix rule). - A covering index includes every column the query needs, so the table is never touched.
- Indexes cost write speed and disk space. Columnar OLAP engines like DuckDB rarely need them: they rely on fast scans plus min/max zone maps to skip data.
- Run
ANALYZEafter bulk loads so the planner has statistics.
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:
- One clause per line; uppercase keywords (or consistently lowercase);
snake_casenames. - CTEs over nested subqueries, each named for what it holds (
paid_orders, nott2). - Explicit
JOIN … ON, never comma joins; qualify columns with short table aliases. - No
SELECT *in production queries: name the columns, so upstream additions can't change your output. - Always bind values with parameters (
?or:name), never f-strings: that prevents SQL injection and lets the database reuse plans. - Lint and format automatically:
pixi add sqlfluff, thenpixi run sqlfluff lint queries/.
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
Quiz
Recap
- Logical order: FROM → WHERE → GROUP BY → HAVING → windows → SELECT → ORDER BY → LIMIT.
- CTEs make queries readable; recursive CTEs walk hierarchies and generate series, with a termination condition.
- Window functions keep rows: ranking, LAG/LEAD, running totals, and moving averages via PARTITION BY, ORDER BY, and frames.
- Conditional aggregation pivots rows into columns; mind NULLs.
- Upserts on a natural key make loads idempotent; prefer ON CONFLICT DO UPDATE over INSERT OR REPLACE.
- Store UTC ISO text, bucket with date functions; read query plans and index selective filters.
- DuckDB brings columnar OLAP into your process, queries files directly, and swaps data with pandas and Polars.
- Treat SQL as code: style, named columns, parameters, and tests with hand-checked fixtures.
Going further
- sqlite3 in the Python docs
- SQLite: window functions, WITH clause, upsert, date functions, EXPLAIN QUERY PLAN
- DuckDB documentation, including the Python client
- modern-sql.com (Markus Winand) and Use The Index, Luke
- Next: M62 · Pipelines, Orchestration & Data Quality