10.16. Common Table Expressions
Table of Contents
Understanding Common Table Expressions
Common Table Expressions, usually called CTEs, let you write temporary, named result sets inside a query. You can think of a CTE as a query inside a query, with a name that you can reuse.
CTEs make complex SQL easier to read, debug, and maintain, especially compared to very deeply nested subqueries.
In this chapter we focus on how to write and use CTEs, not on basic SQL or joins, which are covered in other chapters.
Basic CTE Syntax
A CTE has a simple pattern:
WITH cte_name AS (
-- some SELECT query
SELECT ...
)
SELECT ...
FROM cte_name;You define the CTE first, then use it in a main query that follows.
A very small example:
WITH high_salary_employees AS (
SELECT id, name, salary
FROM employees
WHERE salary > 70000
)
SELECT name, salary
FROM high_salary_employees
ORDER BY salary DESC;Here:
high_salary_employeesis the CTE name.- It contains a
SELECTthat filters theemployeestable. - The main query selects from
high_salary_employeesas if it were a real table.
CTE rule:
Every CTE must start with WITH, have a name, and contain one SELECT query in parentheses, followed by a main query that uses it.
When to Use a CTE Instead of a Subquery
You could write the same logic with a subquery:
SELECT name, salary
FROM (
SELECT id, name, salary
FROM employees
WHERE salary > 70000
) AS high_salary_employees
ORDER BY salary DESC;Both versions work. You usually prefer a CTE when:
- The subquery is long or complex.
- You want to reuse the same subquery multiple times.
- You want to break a big problem into clear steps.
Naming Columns in a CTE
You can optionally list column names right after the CTE name:
WITH high_salary_employees (emp_id, emp_name, emp_salary) AS (
SELECT id, name, salary
FROM employees
WHERE salary > 70000
)
SELECT emp_name, emp_salary
FROM high_salary_employees;
If you do not list columns there, the names from the inner SELECT are used.
The number of column names must match the number of columns returned by the SELECT.
Important:
If you list column names after the CTE name, they replace the inner query column names for the outer query.
Multiple CTEs in One Query
You can define more than one CTE in a single WITH clause, separated by commas:
WITH recent_orders AS (
SELECT id, customer_id, total_amount, created_at
FROM orders
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
),
big_spenders AS (
SELECT customer_id, SUM(total_amount) AS total_spent
FROM recent_orders
GROUP BY customer_id
HAVING SUM(total_amount) > 1000
)
SELECT c.id, c.name, b.total_spent
FROM customers c
JOIN big_spenders b ON c.id = b.customer_id
ORDER BY b.total_spent DESC;Here:
- First CTE
recent_ordersselects orders from the last 30 days. - Second CTE
big_spendersaggregatesrecent_orders. - The final query joins
customerswithbig_spenders.
Order matters. Each CTE can use CTEs defined before it in the list, but not after.
Example: Step by Step Data Preparation
Assume you have:
orders(id, customer_id, total_amount, created_at)order_items(id, order_id, product_id, quantity, unit_price)
You want: total quantity and total revenue per product for the last month, but only for products with more than 50 units sold.
WITH last_month_orders AS (
SELECT id
FROM orders
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month'
AND created_at < DATE_TRUNC('month', CURRENT_DATE)
),
last_month_items AS (
SELECT oi.product_id,
oi.quantity,
oi.quantity * oi.unit_price AS line_revenue
FROM order_items oi
JOIN last_month_orders o ON oi.order_id = o.id
),
product_totals AS (
SELECT product_id,
SUM(quantity) AS total_qty,
SUM(line_revenue) AS total_revenue
FROM last_month_items
GROUP BY product_id
)
SELECT product_id, total_qty, total_revenue
FROM product_totals
WHERE total_qty > 50
ORDER BY total_revenue DESC;Each CTE is a simple piece. Together they form a clear pipeline.
Using CTEs for Readable Stepwise Queries
CTEs shine when you want to:
- Apply filters in stages.
- Build aggregations step by step.
- Prepare intermediate results with clear names.
Example: Active Customers With Their Latest Order
Tables:
customers(id, name, status)orders(id, customer_id, total_amount, created_at)
Goal: list only active customers and their latest order total, if any.
Without CTEs, a typical solution uses a subquery inside a join:
SELECT c.id, c.name, o.total_amount, o.created_at
FROM customers c
LEFT JOIN orders o
ON o.id = (
SELECT id
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC
LIMIT 1
)
WHERE c.status = 'active';With CTEs you can separate the steps:
WITH active_customers AS (
SELECT id, name
FROM customers
WHERE status = 'active'
),
latest_orders AS (
SELECT DISTINCT ON (customer_id)
customer_id,
total_amount,
created_at
FROM orders
ORDER BY customer_id, created_at DESC
)
SELECT ac.id, ac.name,
lo.total_amount,
lo.created_at
FROM active_customers ac
LEFT JOIN latest_orders lo
ON ac.id = lo.customer_id
ORDER BY ac.name;Here each CTE has a clear job:
active_customers: filter active customers.latest_orders: pick the latest order per customer.- Final query: join the two.
Example: Filtering Before Aggregation
Imagine page_views(user_id, page, viewed_at).
Goal: For each user, count views in the last 7 days, then only keep users with more than 100 views.
WITH last_week AS (
SELECT user_id, page, viewed_at
FROM page_views
WHERE viewed_at >= NOW() - INTERVAL '7 days'
),
user_view_counts AS (
SELECT user_id, COUNT(*) AS view_count
FROM last_week
GROUP BY user_id
)
SELECT user_id, view_count
FROM user_view_counts
WHERE view_count > 100
ORDER BY view_count DESC;This makes both the filter and aggregation steps obvious.
Recursive CTEs
A recursive CTE is a powerful feature that lets you repeat a query until a condition is met.
Use cases:
- Hierarchies, like companies and departments.
- Trees, like category / subcategory structures.
- Graph traversal, like friend-of-a-friend.
Recursive CTE Syntax
General pattern:
WITH RECURSIVE cte_name AS (
-- 1. Anchor member: base query, runs once
SELECT ...
FROM ...
WHERE ... -- base condition
UNION ALL
-- 2. Recursive member: refers to cte_name
SELECT ...
FROM ...
JOIN cte_name ON ... -- use previous results
WHERE ... -- stop condition
)
SELECT * FROM cte_name;Recursive CTE rules:
- Use
WITH RECURSIVEkeyword. - The CTE body has two parts joined by
UNIONorUNION ALL: - Anchor member (no recursion).
- Recursive member (refers to the CTE itself).
- The recursive part must have a condition that eventually stops, or the query can loop forever.
Example: Employee Hierarchy
Table employees:
| id | name | manager_id |
|---|---|---|
| 1 | CEO | NULL |
| 2 | VP Sales | 1 |
| 3 | VP Tech | 1 |
| 4 | Sales Rep A | 2 |
| 5 | Sales Rep B | 2 |
| 6 | Engineer A | 3 |
| 7 | Engineer B | 3 |
Goal: Find all employees under manager id = 1 with their levels in the hierarchy.
WITH RECURSIVE employee_hierarchy AS (
-- Anchor: start from the top manager
SELECT
id,
name,
manager_id,
0 AS level
FROM employees
WHERE id = 1
UNION ALL
-- Recursive: find direct reports of people already in the hierarchy
SELECT
e.id,
e.name,
e.manager_id,
eh.level + 1 AS level
FROM employees e
JOIN employee_hierarchy eh
ON e.manager_id = eh.id
)
SELECT id, name, manager_id, level
FROM employee_hierarchy
ORDER BY level, id;Output:
| id | name | manager_id | level |
|---|---|---|---|
| 1 | CEO | NULL | 0 |
| 2 | VP Sales | 1 | 1 |
| 3 | VP Tech | 1 | 1 |
| 4 | Sales Rep A | 2 | 2 |
| 5 | Sales Rep B | 2 | 2 |
| 6 | Engineer A | 3 | 2 |
| 7 | Engineer B | 3 | 2 |
You can change the starting manager by changing the WHERE id = 1 condition.
Example: Path and Depth
You can also track the path:
WITH RECURSIVE employee_hierarchy AS (
SELECT
id,
name,
manager_id,
0 AS level,
name::text AS path
FROM employees
WHERE id = 1
UNION ALL
SELECT
e.id,
e.name,
e.manager_id,
eh.level + 1 AS level,
eh.path || ' > ' || e.name AS path
FROM employees e
JOIN employee_hierarchy eh
ON e.manager_id = eh.id
)
SELECT id, name, level, path
FROM employee_hierarchy
ORDER BY level, id;
Now path shows the full chain, like CEO > VP Tech > Engineer A.
Example: Simple Number Series
You can generate sequences of numbers without a table.
Goal: numbers from 1 to 10.
WITH RECURSIVE numbers AS (
SELECT 1 AS n -- anchor
UNION ALL
SELECT n + 1 -- recursive
FROM numbers
WHERE n < 10 -- stop condition
)
SELECT n FROM numbers;Output: 1,2,3,4,5,6,7,8,9,10.
This is useful for date ranges, reports per day, filling gaps, and more.
Recursive CTE for Category Trees
Table categories:
| id | name | parent_id |
|---|---|---|
| 1 | Electronics | NULL |
| 2 | Phones | 1 |
| 3 | Laptops | 1 |
| 4 | Android | 2 |
| 5 | iOS | 2 |
| 6 | Gaming | 3 |
Goal: get a full tree under "Electronics" with depth and full path.
WITH RECURSIVE category_tree AS (
-- start from the root category
SELECT
id,
name,
parent_id,
0 AS depth,
name::text AS path
FROM categories
WHERE name = 'Electronics'
UNION ALL
-- find children of categories already in the tree
SELECT
c.id,
c.name,
c.parent_id,
ct.depth + 1 AS depth,
ct.path || ' > ' || c.name AS path
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, name, depth, path
FROM category_tree
ORDER BY depth, name;Avoiding Infinite Loops
If your data has a cycle (for example, an employee managed by their own subordinate), a recursive CTE can loop forever. Many databases have a recursion depth limit, but you still should protect yourself.
A common trick is to store visited IDs in a text or array column and check that you do not revisit them. Example with a path string:
WITH RECURSIVE safe_hierarchy AS (
SELECT
id,
name,
manager_id,
0 AS level,
id::text AS visited_ids
FROM employees
WHERE id = 1
UNION ALL
SELECT
e.id,
e.name,
e.manager_id,
sh.level + 1 AS level,
sh.visited_ids || ',' || e.id::text AS visited_ids
FROM employees e
JOIN safe_hierarchy sh ON e.manager_id = sh.id
WHERE sh.visited_ids NOT LIKE '%' || e.id::text || '%'
)
SELECT id, name, level
FROM safe_hierarchy;
The WHERE clause blocks revisiting any id that is already part of visited_ids.
Using CTEs With Aggregations and Window Functions
CTEs are very often combined with aggregations and window functions to structure complex analytical queries.
Example: Top N per Group
Goal: For each department, find the top 3 highest paid employees.
Tables:
departments(id, name)employees(id, name, department_id, salary)
WITH ranked_salaries AS (
SELECT
e.id,
e.name,
e.department_id,
e.salary,
RANK() OVER (
PARTITION BY e.department_id
ORDER BY e.salary DESC
) AS salary_rank
FROM employees e
)
SELECT
rs.id,
rs.name,
rs.department_id,
rs.salary
FROM ranked_salaries rs
WHERE rs.salary_rank <= 3
ORDER BY rs.department_id, rs.salary DESC;
The window function RANK() adds the ranking, and the outer query just filters.
Example: Monthly Revenue and Percentage Change
Tables:
orders(id, total_amount, created_at)
Goal: For each month, show total revenue and percentage change vs previous month.
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(total_amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
),
monthly_with_prev AS (
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT
month,
revenue,
prev_revenue,
CASE
WHEN prev_revenue IS NULL OR prev_revenue = 0 THEN NULL
ELSE ROUND(
(revenue - prev_revenue) * 100.0 / prev_revenue,
2
)
END AS pct_change
FROM monthly_with_prev
ORDER BY month;Pipeline:
monthly_revenue: aggregate by month.monthly_with_prev: attach previous month revenue usingLAG.- Final
SELECT: calculate percentage change.
This approach is easier to understand than one giant query with nested subqueries.
CTEs vs Subqueries vs Views
CTEs are similar to subqueries and views, but they serve different purposes.
| Feature | CTE | Subquery | View |
|---|---|---|---|
| Scope | Single query | Single query | Database wide |
| Definition place | At top with WITH | Inline in FROM / WHERE | Created once, stored in DB |
| Reusable in SQL | Only in that main query | Only where written | Reusable across many queries |
| Good for | Complex one off reports | Simple in place logic | Shared common query patterns |
Rules of thumb:
- Use a CTE when:
- You want clear stepwise logic in one query.
- You may need the same intermediate result several times in that query.
- Use a subquery when:
- The logic is small and only used once.
- Use a view when:
- Many queries need the same complex logic.
Some databases treat CTEs as optimization barriers, which can affect performance compared to inline views or subqueries. Modern versions of PostgreSQL and others often optimize CTEs better, but you should still measure performance for very heavy queries.
Practical Tips and Patterns
Pattern: Cleaning and Reusing Filters
Instead of copying the same filter in different places, put it in a CTE.
Bad practice:
SELECT COUNT(*)
FROM orders
WHERE status = 'completed'
AND created_at >= NOW() - INTERVAL '7 days';
SELECT customer_id, SUM(total_amount)
FROM orders
WHERE status = 'completed'
AND created_at >= NOW() - INTERVAL '7 days'
GROUP BY customer_id;Better with a CTE:
WITH last_week_completed AS (
SELECT *
FROM orders
WHERE status = 'completed'
AND created_at >= NOW() - INTERVAL '7 days'
)
SELECT COUNT(*) FROM last_week_completed;
SELECT customer_id, SUM(total_amount)
FROM last_week_completed
GROUP BY customer_id;
You can even combine both into one query with multiple final SELECTs in some environments, but many frameworks expect a single result set. For clarity in application code you usually keep one SELECT per statement.
Pattern: Limiting Recursion Depth
Always consider a maximum depth in recursive CTEs to avoid pathological cases.
WITH RECURSIVE employee_hierarchy AS (
SELECT id, name, manager_id, 0 AS level
FROM employees
WHERE id = 1
UNION ALL
SELECT e.id, e.name, e.manager_id, eh.level + 1
FROM employees e
JOIN employee_hierarchy eh ON e.manager_id = eh.id
WHERE eh.level < 10 -- do not go deeper than 10 levels
)
SELECT * FROM employee_hierarchy;This gives you a safety net even if the data is not perfect.
Summary
- A CTE is a named temporary result set used only in the following query.
- Basic syntax uses
WITH cte_name AS (SELECT ...) SELECT ... FROM cte_name;. - You can define multiple CTEs in one
WITHclause and use them like building blocks. - CTEs improve readability and make complex queries easier to maintain.
- Recursive CTEs let you work with hierarchies and sequences by using
WITH RECURSIVE, an anchor member, and a recursive member. - CTEs work very well with aggregations and window functions to build stepwise analytical queries.
- Choose between CTEs, subqueries, and views based on scope and reuse needs.
With these patterns, you can write clearer, more maintainable SQL for complex reporting and hierarchical data, which is very useful in backend development.
Views: 9
KAHIBARO