Advanced Querying — Recursive CTEs & Window Function Extras

PostgreSQL

Chapter 5 · Advanced Querying — Recursive CTEs & Window Function Extras

mysql3-6 already covered window functions in real depth, and this course isn't going to re-teach ROW_NUMBER, RANK, or PARTITION BY from scratch. This chapter covers exactly two things: a feature MySQL only gained much later than Postgres (recursive CTEs), and a set of genuinely Postgres-specific window function extras that go beyond the shared ANSI SQL baseline.

Recursive CTEs

Both engines support ordinary (non-recursive) CTEs via WITH. A recursive CTE, using WITH RECURSIVE, lets a query reference itself, building up a result iteratively — the standard way to traverse a hierarchical structure (an org chart, a category tree, a bill of materials) whose depth isn't known in advance.

A recursive CTE has two parts, unioned together with UNION ALL: an anchor term (the non-recursive base case) and a recursive term (which references the CTE's own name and is executed repeatedly until it returns no more rows).

A Worked Example — The Employee Hierarchy

-- employees table has a self-referencing manager_id
WITH RECURSIVE org_chart AS (
  -- Anchor: the manager we're starting from
  SELECT id, name, manager_id, 0 AS depth
  FROM employees
  WHERE id = 7  -- the manager whose full team we want

  UNION ALL

  -- Recursive term: find direct reports of anyone already found
  SELECT e.id, e.name, e.manager_id, org_chart.depth + 1
  FROM employees e
  JOIN org_chart ON e.manager_id = org_chart.id
)
SELECT * FROM org_chart ORDER BY depth, name;

This returns every employee reporting to manager 7, at any depth — direct reports, their reports, and so on — with no fixed limit on how many levels deep the hierarchy goes.

A real, meaningful gap in MySQL's own history
MySQL only added recursive CTE support in MySQL 8.0, released in 2018. Before that, this exact query had no equivalent in pure SQL at all — it required either application-level recursion or a stored-procedure loop. Postgres has had WITH RECURSIVE since version 8.4, released in 2009 — roughly a decade earlier.

Window Function Extras

Both engines support the core ANSI SQL window function specification — mysql3-6 already covered that shared ground. This chapter covers what Postgres adds beyond it.

  • The FILTER clauseaggregate FILTER (WHERE condition) is a genuinely cleaner way to do conditional aggregation than MySQL's CASE WHEN trick:
-- Postgres
SELECT COUNT(*) FILTER (WHERE status = 'completed') AS completed_count
FROM orders;

-- MySQL's equivalent
SELECT SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed_count
FROM orders;
  • Named window definitions — a WINDOW clause lets a window definition be declared once and reused across several function calls in the same query, instead of repeating an identical OVER(...) clause each time:
SELECT
  name,
  RANK() OVER w AS rank,
  AVG(salary) OVER w AS dept_avg
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY salary DESC);
  • Ordered-set aggregates — Postgres natively supports statistical aggregates like percentile_cont, percentile_disc, and mode(), which MySQL doesn't provide as built-in functions at all.

Combining Recursive CTEs and Window Functions

The two features compose naturally — the employee hierarchy example above could add a window function to rank each employee's direct reports by salary within their own manager's group, applied directly on top of the recursive result set, since a recursive CTE's output is just an ordinary result set once it's finished expanding.

A recursive CTE that never terminates is a genuinely different failure mode
If the recursive term doesn't actually converge — a cyclic manager_id relationship (A reports to B, who reports back to A), or a bug in the termination logic — a recursive CTE can loop indefinitely, consuming real, growing resources rather than simply returning wrong results. This isn't an ordinary query bug: an incorrect join returns wrong-but-finite output; a non-terminating recursive CTE can hang or exhaust memory. Postgres doesn't detect cycles automatically by default (a CYCLE clause exists in newer Postgres versions specifically for explicit cycle detection) — worth testing recursive CTEs against known cyclic data before relying on them in production.

Hands-On Exercises

Exercise 1

Explain what a recursive CTE is, using this chapter's own employee hierarchy example, and explain why MySQL couldn't run an equivalent query in pure SQL before version 8.0.

📄 View solution
Exercise 2

Explain the FILTER clause and, using this chapter's own example, show how it replaces MySQL's CASE WHEN conditional-aggregation trick.

📄 View solution
Exercise 3

Explain this chapter's own warn-box about a recursive CTE that never terminates — what's the difference between this failure mode and an ordinary incorrect-results query bug, and what actually causes it?

📄 View solution

Chapter 5 Quick Reference

  • WITH RECURSIVE — anchor term (UNION ALL) recursive term, referencing itself until no more rows return; Postgres has had this since 8.4 (2009), MySQL only since 8.0 (2018)
  • Window function basics already covered in mysql3-6 — this chapter covers Postgres-only extras
  • FILTER — cleaner conditional aggregation than MySQL's CASE WHEN trick
  • WINDOW clause — named, reusable window definitions across multiple function calls
  • Ordered-set aggregates — percentile_cont/percentile_disc/mode(), no native MySQL equivalent
  • A non-terminating recursive CTE hangs/consumes resources — a genuinely different failure mode than wrong-but-finite results; no automatic cycle detection by default
  • Next chapter: Full-Text Search