SQL for Quant Interviews: Joins, Groups, and Window Functions
3 min read
Quant research runs on tabular data, and a surprising number of research screens include SQL — not exotic SQL, but fluent use of three ideas: aggregation, joins, and window functions. The third is where candidates separate.
Aggregation: GROUP BY and its gotcha
SELECT symbol, COUNT(*) AS trades, SUM(qty * price) AS notional
FROM trades
WHERE ts >= '2026-07-01'
GROUP BY symbol
HAVING SUM(qty * price) > 1e6
ORDER BY notional DESC;
The two rules that generate most interview corrections: every selected column is either grouped or aggregated; and WHERE filters rows before grouping, HAVING filters groups after — "symbols with over $1M traded" must be HAVING. Logical evaluation order (FROM → WHERE → GROUP → HAVING → SELECT → ORDER) explains every "why can't I use my alias here?" confusion in one line.
Joins: semantics, then row counts
INNER keeps matches; LEFT keeps all left rows with NULLs for misses; FULL keeps everything. The interview substance is in the edges:
- Row multiplication: joining trades to a quotes table with multiple quotes per key duplicates trade rows — the classic silent bug that inflates every downstream sum. Say "I'd check the join keys are unique on the right side" and you've preempted the trap.
- NULL logic:
NULL = NULLis not true — it's NULL; useIS NULL. Anti-joins ("clients with no trades") viaLEFT JOIN ... WHERE right.key IS NULLorNOT EXISTS. - The quant-specific join is as-of (each trade matched to the latest quote at or before it — the point-in-time discipline from the backtesting lesson, in SQL form). Standard SQL does it awkwardly (correlated subquery /
LATERAL); kdb+ and DuckDB have nativeasofjoins — knowing the concept and that specialized tools exist is the expected answer.
Window functions: the difference-maker
Aggregates that don't collapse rows — each row sees a computation over its "window":
SELECT ts, symbol, price,
LAG(price) OVER w AS prev_price,
price / LAG(price) OVER w - 1 AS ret,
AVG(price) OVER (w ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS ma20,
ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY price DESC) AS price_rank
FROM prices
WINDOW w AS (PARTITION BY symbol ORDER BY ts);
Returns, moving averages, ranks, cumulative sums — the pandas operations of the Python lesson, executed in-database. The three families: ranking (ROW_NUMBER, RANK, NTILE — cross-sectional quantiles for the factor construction of the ML course), offsets (LAG/LEAD — returns and point-in-time comparisons), and framed aggregates (rolling windows). The canonical interview task — "top 3 trades by size per symbol" — is ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY qty DESC) wrapped in a subquery filtering rn <= 3; produce it without hesitation.
Performance, one paragraph
Enough to answer the follow-up: indexes make point lookups and joins fast at the cost of write overhead (B-trees — the sorted structures of the data-structures lesson); analytical stores (columnar: Parquet, ClickHouse, kdb+) scan compressed columns fast and are why research databases differ from trading databases; and EXPLAIN is how you check what the engine actually did rather than guessing. "Filter early, join on keys, let the optimizer work, verify with EXPLAIN" is a complete senior-sounding answer.
The interview version
"Daily P&L per desk, plus each desk's rank within its region, from a trades table." — One GROUP BY for the P&L, one window RANK() OVER (PARTITION BY region ORDER BY pnl DESC) over it, done in eight lines. The screen is testing whether SQL is a language you think in or one you look up — and window functions are the tell.