PostgreSQL

PostgreSQL CTEs and Recursive Queries for Hierarchical Data

Use PostgreSQL CTEs and recursive queries for category trees, graph traversal, and complex aggregations. Covers MATERIALIZED vs NOT MATERIALIZED and cycle prevention.

JusDB Team
October 24, 2025
5 min read
201 views

Common Table Expressions (CTEs) make complex queries readable and enable recursive queries for hierarchical data like org charts, category trees, and graph traversal.

Basic CTE

sql
-- CTE replaces a subquery with a named, readable block
WITH active_users AS (
  SELECT id, email FROM users WHERE status = 'active'
),
recent_orders AS (
  SELECT user_id, count(*) AS order_count
  FROM orders
  WHERE created_at > now() - INTERVAL '30 days'
  GROUP BY user_id
)
SELECT u.email, o.order_count
FROM active_users u
JOIN recent_orders o ON o.user_id = u.id
ORDER BY o.order_count DESC;

MATERIALIZED vs NOT MATERIALIZED (PG12+)

sql
-- MATERIALIZED: CTE executes once, result stored — optimization fence
WITH MATERIALIZED expensive_calc AS (
  SELECT user_id, heavy_computation() AS result FROM large_table
)
SELECT * FROM expensive_calc WHERE result > 100;

-- NOT MATERIALIZED: planner can inline and optimize (default in PG12+)
WITH NOT MATERIALIZED recent AS (
  SELECT * FROM orders WHERE created_at > now() - INTERVAL '1 day'
)
SELECT * FROM recent WHERE status = 'pending';

Recursive CTE: Category Tree

sql
CREATE TABLE categories (
  id        INT PRIMARY KEY,
  name      TEXT,
  parent_id INT REFERENCES categories(id)
);

-- Get full category path for each node
WITH RECURSIVE category_path AS (
  -- Base case: top-level categories
  SELECT id, name, parent_id, name::TEXT AS path, 1 AS depth
  FROM categories
  WHERE parent_id IS NULL

  UNION ALL

  -- Recursive case: children
  SELECT c.id, c.name, c.parent_id,
         cp.path || ' > ' || c.name,
         cp.depth + 1
  FROM categories c
  JOIN category_path cp ON cp.id = c.parent_id
)
SELECT id, name, path, depth
FROM category_path
ORDER BY path;

Recursive CTE: Graph Traversal

sql
-- Find all connections within 3 hops
WITH RECURSIVE connections AS (
  SELECT user_id, friend_id, 1 AS hops
  FROM friendships
  WHERE user_id = 42

  UNION

  SELECT f.user_id, f.friend_id, c.hops + 1
  FROM friendships f
  JOIN connections c ON c.friend_id = f.user_id
  WHERE c.hops < 3
)
SELECT DISTINCT friend_id, min(hops) AS min_hops
FROM connections
GROUP BY friend_id
ORDER BY min_hops;
Warning: Recursive CTEs can loop infinitely on cyclic graphs. Always add a depth/hop limit (WHERE depth < 100) or use UNION (deduplicates) instead of UNION ALL.

Key Takeaways

  • CTEs improve readability by naming intermediate result sets — use them freely
  • In PostgreSQL 12+, CTEs are NOT materialized by default — the planner can optimize through them
  • Use MATERIALIZED only when you want to prevent the planner from inlining the CTE
  • Always add a depth limit to recursive CTEs on data that may contain cycles

JusDB Can Help

Recursive queries and complex CTEs are powerful tools for hierarchical data. JusDB can help you model and query tree structures efficiently.

Share this article

JusDB Team

Official JusDB content team