Skip to main content
SQL intermediate Lesson 11 of 22

Common Table Expressions (CTEs)

Write cleaner queries with WITH clauses, chain multiple CTEs, and use recursive CTEs for hierarchical data.

Common Table Expressions (CTEs) let you name a subquery and reference it by name within a larger query. The primary benefit is readability: a complex query with three nested subqueries becomes three named, independently-understandable steps. CTEs don’t add power you couldn’t get with subqueries, but they make complex queries dramatically easier to write, read, and debug — which matters enormously when a query needs to be maintained over time.

Basic WITH Clause

The simplest CTE replaces a subquery in FROM with a named, readable building block. The difference in clarity compounds as queries grow more complex.

-- Without CTE: the logic is buried and hard to follow
SELECT customer_id, total_spent
FROM (
  SELECT customer_id, SUM(total) AS total_spent
  FROM orders
  GROUP BY customer_id
) AS totals
WHERE total_spent > 1000;

-- With CTE: reads like prose — name the concept first, then use it
WITH customer_totals AS (
  SELECT customer_id, SUM(total) AS total_spent
  FROM orders
  GROUP BY customer_id
)
SELECT customer_id, total_spent
FROM customer_totals
WHERE total_spent > 1000;

The CTE is defined in the WITH clause and can be referenced in the main query just like a table.

Chaining Multiple CTEs

Multiple CTEs can be defined in a single WITH clause, and each one can reference those defined before it. This turns complex queries into a sequence of named steps that mirror how you’d describe the logic in plain language — making the query self-documenting.

WITH
-- Step 1: revenue per customer in the past year
customer_revenue AS (
  SELECT customer_id, SUM(total) AS revenue
  FROM orders
  WHERE created_at >= NOW() - INTERVAL '1 year'
  GROUP BY customer_id
),
-- Step 2: classify customers into tiers based on revenue
customer_tiers AS (
  SELECT
    customer_id,
    revenue,
    CASE
      WHEN revenue >= 10000 THEN 'platinum'
      WHEN revenue >= 2500  THEN 'gold'
      WHEN revenue >= 500   THEN 'silver'
      ELSE 'bronze'
    END AS tier
  FROM customer_revenue
),
-- Step 3: summarize how many customers are in each tier
tier_summary AS (
  SELECT tier, COUNT(*) AS customer_count, SUM(revenue) AS total_revenue
  FROM customer_tiers
  GROUP BY tier
)
SELECT * FROM tier_summary ORDER BY total_revenue DESC;

Breaking the logic into named steps makes each piece independently readable and debuggable — you can test each CTE in isolation by temporarily turning it into a standalone query.

MATERIALIZED vs NOT MATERIALIZED

In PostgreSQL 12+, CTEs are inlined by default (treated like subqueries), allowing the planner to push filters down into them. This is usually what you want. Use MATERIALIZED to force the CTE to execute once and cache its results — useful when the CTE is expensive and referenced multiple times, or when inlining produces a worse plan.

-- Force execution once — the result is computed once and reused for both joins
WITH expensive_calc AS MATERIALIZED (
  SELECT customer_id, complex_calculation(data) AS score
  FROM raw_events
)
SELECT c.name, e.score
FROM customers c
JOIN expensive_calc e ON e.customer_id = c.id
WHERE e.score > 50;

Use NOT MATERIALIZED to explicitly tell PostgreSQL to inline it (the default since PG12):

WITH simple_filter AS NOT MATERIALIZED (
  SELECT * FROM orders WHERE status = 'active'
)
SELECT * FROM simple_filter WHERE total > 100;

Recursive CTEs

Recursive CTEs let a query reference itself, enabling traversal of hierarchical or graph-like data that can’t be queried with a flat JOIN. The canonical use cases are org charts, category trees, and path-finding in graphs. Without recursive CTEs, you’d need to either write application code that issues multiple queries or know the depth of the hierarchy in advance.

The structure is always:

WITH RECURSIVE cte_name AS (
  -- Anchor: the starting point (non-recursive, executes once)
  SELECT ...
  UNION ALL
  -- Recursive term: joins the CTE to itself, executes repeatedly until no new rows
  SELECT ... FROM source JOIN cte_name ON ...
)
SELECT * FROM cte_name;

Org Chart Traversal

CREATE TABLE employees (
  id         INT PRIMARY KEY,
  name       TEXT,
  manager_id INT REFERENCES employees(id)
);

-- Find all reports under employee 1, at any depth
WITH RECURSIVE reports AS (
  -- Anchor: start with the specified root employee
  SELECT id, name, manager_id, 0 AS depth
  FROM employees
  WHERE id = 1

  UNION ALL

  -- Recursive step: add the direct reports of everyone found so far
  SELECT e.id, e.name, e.manager_id, r.depth + 1
  FROM employees e
  JOIN reports r ON r.id = e.manager_id
)
SELECT id, name, depth
FROM reports
ORDER BY depth, name;

Generating a Date Series

Recursive CTEs can generate sequences of any kind — dates, integers, intervals. PostgreSQL’s built-in generate_series() is more efficient for this specific case, but the recursive pattern shows the general approach.

-- Generate all dates in January 2024
WITH RECURSIVE dates AS (
  SELECT '2024-01-01'::DATE AS d    -- anchor: start date
  UNION ALL
  SELECT d + 1 FROM dates WHERE d < '2024-01-31'  -- add one day until end date
)
SELECT d FROM dates;

Cycle Detection

Recursive CTEs can loop infinitely if the data has cycles (A → B → A). The safe approach is to track the path of visited nodes and stop when a repeat is detected. PostgreSQL 14+ added a native CYCLE clause, but the manual array approach works on all versions.

WITH RECURSIVE graph AS (
  SELECT from_node, to_node, ARRAY[from_node] AS path
  FROM edges
  WHERE from_node = 1

  UNION ALL

  SELECT e.from_node, e.to_node, path || e.from_node
  FROM edges e
  JOIN graph g ON g.to_node = e.from_node
  WHERE NOT e.from_node = ANY(path)   -- stop if we've visited this node already
)
SELECT * FROM graph;

CTEs vs Subqueries: When to Use Each

Use a CTE when:

  • The same subquery is referenced more than once
  • The query has multiple logical steps that benefit from named intermediate results
  • You want to make a complex query self-documenting

Use a subquery when:

  • The logic is simple and only used once
  • You need the planner to push filters into the subquery (avoid MATERIALIZED)
  • You’re in an older PostgreSQL version where CTEs were always materialized

Frequently Asked Questions

Are CTEs faster than subqueries?
In PostgreSQL 12+, CTEs are inlined by default (like subqueries), so performance is often similar. Use WITH ... AS MATERIALIZED to force PostgreSQL to execute the CTE once and cache the result.
What can recursive CTEs be used for?
Recursive CTEs are great for hierarchical data like org charts, category trees, and graph traversal. They work by repeatedly joining a base result set with the recursive term.