Lesson 5 of 8 · 48 min
CTEs, recursion & SQL debugging
Staged CTE style, materialization caveats, recursive hierarchies, fan-out detectors, broken-query clinic, EXPLAIN altitude.
Lesson 5 · CTEs & debugging
Structure complex SQL and fix it under the microscope
Long SQL without stages is un-reviewable
CTE style that interviews like
1WITH paid_orders AS ( -- grain: one row per paid order2 SELECT order_id, user_id, amount, created_at3 FROM orders4 WHERE status = 'paid'5),6user_month AS ( -- grain: user × month7 SELECT8 user_id,9 DATE_TRUNC('month', created_at) AS month,10 SUM(amount) AS gmv,11 COUNT(*) AS n_orders12 FROM paid_orders13 GROUP BY 1, 214),15with_prev AS ( -- decorate user-month16 SELECT *,17 LAG(gmv) OVER (18 PARTITION BY user_id ORDER BY month19 ) AS prev_gmv20 FROM user_month21)22SELECT *,23 gmv - prev_gmv AS gmv_delta24FROM with_prev25WHERE month = DATE '2026-01-01';Key idea
Materialization and optimization awareness
Common mistake
CTEs are always materialized once, so they always speed up repeated references.
Recursive CTEs
1-- Org chart: employee → managers upward2WITH RECURSIVE chain AS (3 SELECT employee_id, manager_id, name, 0 AS depth4 FROM employees5 WHERE employee_id = 42 -- anchor6 UNION ALL7 SELECT e.employee_id, e.manager_id, e.name, c.depth + 18 FROM employees e9 JOIN chain c ON e.employee_id = c.manager_id10 WHERE c.depth < 20 -- safety cap11)12SELECT * FROM chain;1314-- Hierarchy downward: children of a node15WITH RECURSIVE tree AS (16 SELECT id, parent_id, name, 1 AS depth17 FROM categories WHERE id = 118 UNION ALL19 SELECT c.id, c.parent_id, c.name, t.depth + 120 FROM categories c21 JOIN tree t ON c.parent_id = t.id22 WHERE t.depth < 1023)24SELECT * FROM tree;Debugging checklist (use live)
1-- Fan-out detector2SELECT3 COUNT(*) AS n_rows,4 COUNT(DISTINCT order_id) AS n_orders5FROM joined;6-- if n_rows >> n_orders, a join exploded orders78-- Null key detector9SELECT10 COUNT(*) FILTER (WHERE a_id IS NULL) AS a_null,11 COUNT(*) FILTER (WHERE b_id IS NULL) AS b_null12FROM joined;1314-- Distribution sniff15SELECT status, COUNT(*) FROM orders GROUP BY 1 ORDER BY 2 DESC;Broken query clinic (common exam prompts)
1-- BUG 1: double count from join then sum parent2SELECT SUM(o.amount)3FROM orders o4JOIN order_items i ON i.order_id = o.order_id;5-- FIX: SUM at order grain first or sum item prices67-- BUG 2: left join + where on right8SELECT u.user_id, COUNT(o.order_id)9FROM users u10LEFT JOIN orders o ON o.user_id = u.user_id11WHERE o.created_at >= DATE '2026-01-01'12GROUP BY u.user_id;13-- FIX: move date filter into ON, or filter orders in CTE first1415-- BUG 3: NOT IN with nulls16WHERE id NOT IN (SELECT user_id FROM deleted); -- deleted.user_id nulls17-- FIX: NOT EXISTS1819-- BUG 4: average of averages for global metric20-- FIX: sum/sum2122-- BUG 5: window filter in WHERE23-- FIX: qualify/subqueryCommon mistake
If EXPLAIN shows Seq Scan, the query is automatically wrong for an interview.
EXPLAIN at interview altitude
1EXPLAIN (ANALYZE, BUFFERS)2SELECT ...;34-- Interview talking points5-- * rows estimated vs actual (big miss → stats / bad join order)6-- * nested loop × large inner → need index or rewrite7-- * sort nodes for ORDER BY/window - can dominate8-- * parallel seq scan sometimes fine for warehouse-ish local PGDebugging narration script
- 01"How structure complex SQL?" → CTEs per grain; comment filters; final select thin.
- 02"Number too high?" → fan-out detector COUNT vs COUNT DISTINCT on parent key.
- 03"Number too low?" → inner join dropped rows; WHERE on left join; overly tight filters; timezone.
- 04"Recursive CTE risk?" → cycles and non-termination; add depth cap / cycle path array.
- 05"CTE slow?" → dialect materialization; may inline or not; rewrite if measured hot.
- 06"First debug step?" → intermediate counts + tiny fixture before changing business logic.
- 07"EXPLAIN meaning?" → operators + row estimates; propose indexes from predicates.
- 08"Name CTEs how?" → by grain/business meaning, not tmp1.
- 09"When subquery vs CTE?" → CTE for multi-use clarity; subquery fine for single-use filters.
- 10"Production habit?" → assert row counts in dbt tests / unit SQL fixtures for metrics.
Checkpoint
A staged query's CTE paid_orders is one row per order; after joining items, COUNT(*) is 4× COUNT(DISTINCT order_id). Diagnosis?
Checkpoint
Recursive CTE walking manager_id without a depth limit on a cyclic bad row - risk?
Checkpoint
Best first move when a take-home metric is 12% above the provided answer key?
Checkpoint
When is a CTE preferable to a deeply nested subquery in an interview?
Checkpoint
EXPLAIN shows nested loop with inner index scan estimated 1 row but actual 50k per outer. What do you say?
Can you stage a multi-CTE metric, run fan-out detectors, and sketch a safe recursive walk?
Takeaways
- CTEs name grains - that is documentation under stress.
- Debug with intermediate counts and tiny fixtures.
- Fan-out detector: COUNT vs COUNT DISTINCT.
- Recursive SQL needs termination strategy.
- EXPLAIN = estimates vs actuals, then grounded index/join talk.
Next: OLTP schema design interviews - keys, normalization, and tradeoffs you must defend.
Sources
Free to read · better with Enzo
Learn it with Enzo
Save your progress, answer the checkpoints, and let Enzo quiz you on what you just read.