PostgreSQL Lesson 31 – Views | Dataplexa

Common Table Expressions (CTEs)

A Common Table Expression — written with the WITH keyword — is a named, temporary result set that you define at the top of a query and reference like a table anywhere below it. CTEs solve the same problems as subqueries in the FROM clause but are dramatically easier to read, debug, and extend. Complex multi-step logic that would require deeply nested subqueries becomes a clean sequence of named steps. This lesson covers CTE syntax, chaining multiple CTEs, using CTEs with INSERT/UPDATE/DELETE, and recursive CTEs for hierarchical data.

Basic CTE Syntax

A CTE starts with WITH cte_name AS (...) followed by the main query that references it. The CTE behaves exactly like a temporary table scoped to that single query — it does not persist beyond the statement.

-- Basic CTE: calculate customer order totals, then filter
WITH customer_totals AS (
  SELECT customer_id,
         COUNT(*)                    AS order_count,
         ROUND(SUM(total_amount), 2) AS total_spent
  FROM orders
  WHERE status != 'cancelled'
  GROUP BY customer_id
)
SELECT customer_id, order_count, total_spent
FROM customer_totals
WHERE total_spent > 300
ORDER BY total_spent DESC;

-- Same query as a subquery — compare readability
SELECT customer_id, order_count, total_spent
FROM (
  SELECT customer_id,
         COUNT(*)                    AS order_count,
         ROUND(SUM(total_amount), 2) AS total_spent
  FROM orders
  WHERE status != 'cancelled'
  GROUP BY customer_id
) AS customer_totals
WHERE total_spent > 300
ORDER BY total_spent DESC;
-- Both return the same result — CTE version is far more readable:
customer_id | order_count | total_spent
-------------+-------------+-------------
          3 |           8 |      892.45
         12 |           7 |      741.20
         28 |           5 |      530.80

Joining a CTE to Another Table

Once defined, a CTE can be used exactly like a regular table — you can JOIN it, filter it, aggregate it, or reference it multiple times in the same query. This is one of the biggest advantages over subqueries: you name the result once and can use it anywhere.

-- CTE + JOIN: enrich customer totals with actual customer names
WITH customer_totals AS (
  SELECT customer_id,
         COUNT(*)                    AS order_count,
         ROUND(SUM(total_amount), 2) AS total_spent
  FROM orders
  WHERE status != 'cancelled'
  GROUP BY customer_id
)
SELECT c.first_name || ' ' || c.last_name AS customer,
       c.state,
       ct.order_count,
       ct.total_spent
FROM customer_totals ct
JOIN customers c ON ct.customer_id = c.id
ORDER BY ct.total_spent DESC
LIMIT 5;
     customer     | state | order_count | total_spent
--------------+-------+-------------+-------------
Liam Brown   |  PA   |           8 |      892.45
Emma Wilson  |  AZ   |           7 |      741.20
Noah Martinez|  TX   |           5 |      530.80

Chaining Multiple CTEs

You can define multiple CTEs in a single query by separating them with commas. Each CTE can reference the ones defined before it. This lets you break a complex query into a clear sequence of named logical steps — like writing a chain of thought that PostgreSQL executes top to bottom.

-- Step 1: revenue per product
-- Step 2: rank products within their category
-- Step 3: return only the top product per category
WITH product_revenue AS (
  SELECT p.id,
         p.name,
         p.category,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM products p
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY p.id, p.name, p.category
),
category_top AS (
  SELECT *,
         RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rank_in_category
  FROM product_revenue
)
SELECT name, category, ROUND(revenue, 2) AS revenue
FROM category_top
WHERE rank_in_category = 1
ORDER BY revenue DESC;
      name       |  category   | revenue
----------------+-------------+---------
USB Hub        | Electronics | 3548.58
Standing Desk  | Furniture   | 2799.92
Desk Lamp      | Office      | 3359.04
Monitor Arm    | Accessories | 2099.58

CTEs with INSERT, UPDATE, and DELETE

CTEs are not limited to SELECT queries. You can use a CTE to compute the rows you want to act on, and then INSERT, UPDATE, or DELETE based on that result. This makes complex data modification statements much easier to understand and maintain.

-- Use a CTE to identify customers to promote, then update their tier
WITH high_spenders AS (
  SELECT customer_id
  FROM orders
  WHERE status != 'cancelled'
  GROUP BY customer_id
  HAVING SUM(total_amount) > 500
)
UPDATE customers
SET tier = 'gold'
WHERE id IN (SELECT customer_id FROM high_spenders);

-- Use a CTE to find old cancelled orders, then delete them
WITH old_cancelled AS (
  SELECT id FROM orders
  WHERE status = 'cancelled'
    AND order_date < CURRENT_DATE - INTERVAL '1 year'
)
DELETE FROM orders
WHERE id IN (SELECT id FROM old_cancelled);

-- Use a CTE to build insert data from existing data
WITH new_vip AS (
  SELECT id, first_name, last_name, email
  FROM customers
  WHERE tier = 'gold'
)
INSERT INTO vip_customers (id, first_name, last_name, email)
SELECT id, first_name, last_name, email
FROM new_vip
ON CONFLICT (id) DO NOTHING;
UPDATE 18
DELETE 7
INSERT 0 18

Recursive CTEs — Traversing Hierarchies

A recursive CTE can reference itself, allowing it to traverse tree-like structures — org charts, category trees, threaded comments, bill of materials. It has two parts separated by UNION ALL: the anchor (base case, runs once) and the recursive member (runs repeatedly, adding one level at a time, until no new rows are produced). The RECURSIVE keyword must be included.

-- Traverse the employee management hierarchy from the top down
WITH RECURSIVE org_chart AS (
  -- Anchor: start with the CEO (no manager)
  SELECT id,
         first_name || ' ' || last_name AS name,
         job_title,
         manager_id,
         0 AS level,
         first_name || ' ' || last_name AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive member: find each employee's direct reports
  SELECT e.id,
         e.first_name || ' ' || e.last_name,
         e.job_title,
         e.manager_id,
         oc.level + 1,
         oc.path || ' > ' || e.first_name || ' ' || e.last_name
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT level,
       REPEAT('  ', level) || name AS employee,   -- indent by level
       job_title,
       path
FROM org_chart
ORDER BY path;
level |     employee         |     job_title      |         path
-------+--------------------+------------------+------------------------
     0 | Sofia Taylor       | Head of Sales    | Sofia Taylor
     1 |  James Carter      | Account Manager  | Sofia Taylor > James Carter
     2 |    Marcus Reed     | Senior Sales Rep | Sofia Taylor > James Carter > Marcus Reed
     1 |  Sarah Reed        | Sales Rep        | Sofia Taylor > Sarah Reed

CTE vs Subquery — When to Choose Each

Feature CTE (WITH) Subquery
Readability Named, defined at the top — very clear Inline — harder to read when nested deeply
Reuse in same query Yes — reference the CTE multiple times No — must duplicate the subquery
Recursion Yes — WITH RECURSIVE No
Use with DML Yes — INSERT, UPDATE, DELETE Limited
Best for Multi-step logic, reusable intermediate results, hierarchies Simple one-off filters and scalar values

🧪 Practice Questions

Answer based on what you learned in this lesson.

1. Which SQL keyword starts a Common Table Expression?




2. What punctuation separates multiple CTEs defined in the same WITH clause?




3. What keyword must you add to a CTE to allow it to reference itself and traverse a hierarchy?




4. What is one key advantage of a CTE over a subquery in the FROM clause?




5. In a recursive CTE, which keyword separates the anchor member from the recursive member?



🎯 Quiz — Test Your Understanding

Q1. A CTE is defined with WITH. How long does it persist?







Q2. You want to UPDATE customers based on a complex aggregation. How do you use a CTE with UPDATE?







Q3. In a recursive CTE, what is the role of the anchor member vs the recursive member?







Q4. You need two intermediate result sets — customer totals and product revenue. How do you define both in one query?







Q5. When should you prefer a CTE over a subquery?






Next up: Window functions — running totals, rankings, and row-by-row calculations without collapsing your data.