Lesson 8 of 12 · 0 of 2 exercises solved
Subqueries & CTEs
When one query needs the answer to another query, you have two options: nest it as a , or name it up front as a CTE. CTEs read top to bottom, which makes longer queries easier to follow.
-- Scalar subquery: returns exactly one value
SELECT name, price,
(SELECT AVG(price) FROM products) AS avg_price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- IN: does this value appear in that result set?
SELECT name
FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE total > 500);
-- EXISTS: usually faster, and it stops at the first match
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.total > 500
);
-- NOT EXISTS: customers who have never ordered
SELECT c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- Derived table: a subquery in the FROM clause (it needs an alias)
SELECT region, order_count
FROM (
SELECT region, COUNT(*) AS order_count
FROM orders
GROUP BY region
) AS totals
WHERE order_count > 10;-- A CTE names a result set so you can read the query in order.
WITH monthly_sales AS (
SELECT
customer_id,
DATE_TRUNC('month', created_at) AS month,
SUM(total) AS revenue
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY customer_id, DATE_TRUNC('month', created_at)
),
customer_totals AS (
SELECT customer_id, SUM(revenue) AS lifetime_value
FROM monthly_sales
GROUP BY customer_id
),
tiers AS (
SELECT
customer_id,
lifetime_value,
CASE
WHEN lifetime_value >= 10000 THEN 'platinum'
WHEN lifetime_value >= 2000 THEN 'gold'
ELSE 'standard'
END AS tier
FROM customer_totals
)
SELECT c.name, t.tier, t.lifetime_value
FROM tiers t
JOIN customers c ON c.id = t.customer_id
WHERE t.tier <> 'standard'
ORDER BY t.lifetime_value DESC;-- Recursive CTEs walk hierarchies: org charts, category trees, graphs.
WITH RECURSIVE org_chart AS (
-- Anchor: where the walk starts
SELECT id, name, manager_id, 1 AS depth, name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive step: joins back onto the CTE itself
SELECT e.id, e.name, e.manager_id, oc.depth + 1, oc.path || ' > ' || e.name
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
WHERE oc.depth < 10 -- always bound the recursion
)
SELECT depth, path
FROM org_chart
ORDER BY path;
-- Generating a series of dates to fill gaps in a report
WITH RECURSIVE days AS (
SELECT DATE '2026-01-01' AS day
UNION ALL
SELECT day + INTERVAL '1 day' FROM days WHERE day < DATE '2026-01-31'
)
SELECT d.day, COALESCE(SUM(o.total), 0) AS revenue
FROM days d
LEFT JOIN orders o ON DATE(o.created_at) = d.day
GROUP BY d.day
ORDER BY d.day;Practice
Return the names of customers who have placed at least one order with a total above 500. Use IN or EXISTS with a subquery, not a JOIN.
Exercise
2 checksSubquery filter
SELECT name FROM customers WHERE ...;
Press Run to try your code, then Check answer to grade it.Using a CTE named customer_totals, compute each customer's total spending, then return name and total_spent for everyone who has spent more than 300, highest first.
Exercise
3 checksA CTE for readability
WITH customer_totals AS (
SELECT customer_id, SUM(total) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT ...
Press Run to try your code, then Check answer to grade it.Finished reading?2 exercises still open. You can come back to them later.
Tip: use the ← and → keys to move between lessons.