Lesson 8 of 30
Set Operations, CTEs, and Recursion
Set operations
Set operations combine the results of two queries that have the same number of columns with compatible types.
| Operator | Result |
|---|---|
UNION |
rows in either query, duplicates removed |
UNION ALL |
rows in either query, duplicates kept (faster) |
INTERSECT |
rows in both queries |
EXCEPT |
rows in the first query but not the second (MINUS in Oracle) |
Example 1: UNION vs UNION ALL
SELECT city FROM customers
UNION
SELECT 'Chennai'
ORDER BY city; city
-----------
Bengaluru
Chennai
Delhi
Mumbai
Pune
(5 rows)SELECT city FROM customers WHERE id <= 4
UNION ALL
SELECT city FROM customers WHERE id <= 4
ORDER BY city; city
-----------
Bengaluru
Bengaluru
Bengaluru
Bengaluru
Delhi
Delhi
Mumbai
Mumbai
(8 rows)Use UNION ALL unless you need duplicates removed. UNION has to sort or hash to find them.
Example 2: INTERSECT
Customers who have both delivered and shipped orders.
SELECT customer_id FROM orders WHERE status = 'delivered'
INTERSECT
SELECT customer_id FROM orders WHERE status = 'shipped'; customer_id
-------------
1
(1 row)Example 3: EXCEPT
Customers with orders but none delivered.
SELECT customer_id FROM orders
EXCEPT
SELECT customer_id FROM orders WHERE status = 'delivered'
ORDER BY customer_id; customer_id
-------------
4
5
(2 rows)Example 4: combine different tables
SELECT name, 'employee' AS kind FROM employees WHERE id <= 2
UNION ALL
SELECT name, 'customer' FROM customers WHERE id <= 2; name | kind
-------+----------
Meera | employee
Ravi | employee
Aarav | customer
Isha | customer
(4 rows)Common table expressions (CTEs)
A CTE names a subquery with WITH, so a long query can be read top to bottom.
Example 5: a basic CTE
WITH dept_avg AS (
SELECT dept_id, avg(salary) AS avg_salary
FROM employees
GROUP BY dept_id
)
SELECT e.name, e.salary, round(a.avg_salary) AS dept_avg
FROM employees e
JOIN dept_avg a ON a.dept_id = e.dept_id
WHERE e.salary > a.avg_salary
ORDER BY e.id; name | salary | dept_avg
--------+--------+----------
Meera | 250000 | 168000
Ravi | 180000 | 168000
Divya | 160000 | 111667
Vikram | 120000 | 100000
(4 rows)Example 6: several CTEs
Each CTE can use the ones defined before it.
WITH order_totals AS (
SELECT o.id, o.customer_id, sum(p.price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status <> 'cancelled'
GROUP BY o.id, o.customer_id
),
customer_totals AS (
SELECT customer_id, sum(total) AS spent
FROM order_totals
GROUP BY customer_id
)
SELECT c.name, ct.spent
FROM customer_totals ct
JOIN customers c ON c.id = ct.customer_id
ORDER BY ct.spent DESC; name | spent
--------+---------
Aarav | 1075.00
Karan | 1070.00
Isha | 460.00
Lata | 200.00
Manish | 125.00
(5 rows)Example 7: a CTE used twice
A CTE is defined once and can be referenced several times. Departments above the average department payroll:
WITH payroll AS (
SELECT dept_id, sum(salary) AS total
FROM employees
WHERE dept_id IS NOT NULL
GROUP BY dept_id
)
SELECT d.name, p.total
FROM payroll p
JOIN departments d ON d.id = p.dept_id
WHERE p.total > (SELECT avg(total) FROM payroll)
ORDER BY p.total DESC; name | total
-------------+--------
Engineering | 840000
(1 row)Since PostgreSQL 12, a CTE that is referenced once is inlined into the query like a subquery. Use WITH x AS MATERIALIZED (...) to force it to run once.
Recursive CTEs
A recursive CTE has a starting query and a recursive part that refers to the CTE itself. It repeats until the recursive part returns no rows. It is the standard way to walk hierarchies such as org charts and category trees.
Example 8: the management chain of one employee
Start at Arjun and walk up to the top.
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE name = 'Arjun'
UNION ALL
SELECT e.id, e.name, e.manager_id, c.level + 1
FROM employees e
JOIN chain c ON e.id = c.manager_id
)
SELECT level, name FROM chain ORDER BY level; level | name
-------+-------
1 | Arjun
2 | Kiran
3 | Ravi
4 | Meera
(4 rows)Example 9: everyone under a manager
Walk down from Ravi to all direct and indirect reports.
WITH RECURSIVE reports AS (
SELECT id, name, 0 AS depth
FROM employees
WHERE name = 'Ravi'
UNION ALL
SELECT e.id, e.name, r.depth + 1
FROM employees e
JOIN reports r ON e.manager_id = r.id
)
SELECT repeat(' ', depth) || name AS org_chart
FROM reports
ORDER BY depth, name; org_chart
-----------
Ravi
Kiran
Neha
Arjun
(4 rows)Example 10: generate a sequence
WITH RECURSIVE n(i) AS (
SELECT 1
UNION ALL
SELECT i + 1 FROM n WHERE i < 5
)
SELECT i FROM n; i
---
1
2
3
4
5
(5 rows)Without a stopping condition (WHERE i < 5 here), or with a cycle in the data, the query never ends. PostgreSQL also has generate_series(1, 5) for this.
Summary
- Use
UNION ALLunless duplicates must be removed. EXCEPTis "in A but not in B".INTERSECTis "in both".- A CTE names a step. A recursive CTE walks a tree.
