devnotes.

CTEs & Recursive Queries

Basic CTE

WITH dept_stats AS (
  SELECT department, count(*) AS total, avg(cgpa) AS avg_cgpa
  FROM students
  GROUP BY department
)
SELECT * FROM dept_stats WHERE avg_cgpa > 3.0 ORDER BY total DESC;

Multiple CTEs

WITH
active_depts AS (
  SELECT code, name_en FROM departments WHERE active
),
dept_stats AS (
  SELECT s.department, count(*) AS total, avg(s.cgpa) AS avg_cgpa
  FROM students s
  JOIN active_depts a ON s.department = a.code
  GROUP BY s.department
)
SELECT a.name_en, d.total, round(d.avg_cgpa, 2) AS avg_cgpa
FROM dept_stats d
JOIN active_depts a ON a.code = d.department;

MATERIALIZED and NOT MATERIALIZED

Before version 12, a CTE was always an optimisation fence — it was executed separately and its results could not be pushed into. Since 12, Postgres inlines a CTE that is used once, unless you say otherwise:

WITH heavy AS MATERIALIZED (SELECT … )        -- force it to run once, separately
WITH light AS NOT MATERIALIZED (SELECT … )    -- force inlining

Use MATERIALIZED when the CTE is expensive and referenced several times; use NOT MATERIALIZED when you want the planner to push a WHERE condition down into it.

Recursive CTEs

Two parts joined by UNION ALL: an anchor that seeds the result, and a recursive member that refers back to the CTE.

WITH RECURSIVE numbers AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;

Traversing a hierarchy

WITH RECURSIVE org_chart AS (
  SELECT id, name, manager_id, 1 AS level, name::text AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT e.id, e.name, e.manager_id, oc.level + 1, oc.path || ' > ' || e.name
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT level, path FROM org_chart ORDER BY path;

The same pattern walks category trees, threaded comments, bill-of-materials structures and graph paths.

To guard against cycles in messy data, track visited ids — or use the CYCLE clause available since Postgres 14:

WITH RECURSIVE t AS (
  SELECT id, parent_id FROM nodes WHERE id = 1
  UNION ALL
  SELECT n.id, n.parent_id FROM nodes n JOIN t ON n.parent_id = t.id
) CYCLE id SET is_cycle USING path
SELECT * FROM t WHERE NOT is_cycle;

Data-modifying CTEs

This is a genuinely distinctive Postgres feature: INSERT, UPDATE and DELETE can appear inside a WITH clause, with RETURNING feeding the next step. It lets you move rows between tables atomically in one statement:

WITH moved AS (
  DELETE FROM students
  WHERE status = 'graduated'
  RETURNING id, name_en, department
)
INSERT INTO alumni (student_id, name_en, department)
SELECT id, name_en, department FROM moved;
WITH updated AS (
  UPDATE students SET cgpa = cgpa + 0.1 WHERE department = 'CSE'
  RETURNING id, cgpa
)
SELECT count(*), avg(cgpa) FROM updated;

All sub-statements in one WITH see the same snapshot of the data, and they all commit or roll back together.

Recursion limits

There is no fixed recursion cap; a runaway recursive CTE will run until it exhausts memory or you cancel it. Always include a terminating condition, and consider LIMIT while developing.

PostgreSQL WITH Clause, Recursive CTEs and Data-Modifying CTEs | DevNotes