Subqueries, CTEs and UNION
Build queries in steps with subqueries and common table expressions, compare rows to their own group with correlated subqueries, and stack results with UNION.
This lesson builds on: Joins — combining tables without double counting.
- Use subqueries in WHERE, FROM and SELECT
- Explain how a correlated subquery runs and when to avoid it
- Structure multi-step logic with CTEs (WITH)
- Choose between UNION and UNION ALL
Subqueries
A subquery is a query inside another query, in parentheses.
In WHERE — compare with a value computed from the data. Products priced above the average list price:
SELECT product_name, list_price
FROM products
WHERE list_price > (SELECT AVG(list_price) FROM products)
ORDER BY list_price
LIMIT 6;| product_name | list_price |
|---|---|
| Rain Jacket Basic | 58.65 |
| Perfume Plus | 59 |
| Fitness Tracker Plus | 59 |
| Jeans Pro | 60.75 |
| Sneakers Basic | 61.2 |
| Coffee Maker Basic | 62.9 |
With IN — customers who bought during Black Friday 2025:
SELECT COUNT(*) AS black_friday_2025_customers
FROM customers
WHERE customer_id IN (
SELECT customer_id FROM orders
WHERE campaign_id = 11 AND status = 'completed'
);| black_friday_2025_customers |
|---|
| 528 |
In FROM (a derived table) — aggregate first, then query the summary:
SELECT ROUND(AVG(orders_per_customer), 2) AS avg_orders_per_buying_customer
FROM (
SELECT customer_id, COUNT(*) AS orders_per_customer
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
) AS per_customer;| avg_orders_per_buying_customer |
|---|
| 9.86 |
Correlated subqueries
A correlated subquery refers to the outer query, so it is (logically) evaluated once per outer row. Products whose price is above their own category's average:
SELECT p.product_name, p.category, p.list_price
FROM products AS p
WHERE p.list_price > (
SELECT AVG(p2.list_price) FROM products AS p2
WHERE p2.category = p.category
)
ORDER BY p.category, p.list_price
LIMIT 6;| product_name | category | list_price |
|---|---|---|
| Rain Jacket Basic | Apparel | 58.65 |
| Jeans Pro | Apparel | 60.75 |
| Sneakers Basic | Apparel | 61.2 |
| Rain Jacket Plus | Apparel | 69 |
| Sneakers Plus | Apparel | 72 |
| Running Shoes Basic | Apparel | 72.25 |
p2.category = p.category links the inner query to the current outer row. Correlated subqueries are expressive but can be slow on big tables; window functions (later) usually do the same job more efficiently.
EXISTS is a correlated pattern that is often fast, because it stops at the first match:
SELECT COUNT(*) AS customers_with_a_return
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.status = 'returned');| customers_with_a_return |
|---|
| 1408 |
CTEs: WITH … AS
A common table expression names a subquery and puts it at the top, so the query reads top to bottom like a recipe:
WITH order_revenue AS ( -- step 1: revenue per order (aggregate lines first: no fan-out)
SELECT order_id,
SUM(quantity * unit_price * (1 - discount_pct)) AS revenue
FROM order_items
GROUP BY order_id
),
completed_2025 AS ( -- step 2: completed 2025 orders with their revenue
SELECT o.order_id, o.channel, r.revenue
FROM orders o JOIN order_revenue r ON r.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
)
SELECT channel, -- step 3: summarise
COUNT(*) AS orders,
ROUND(SUM(revenue), 0) AS revenue,
ROUND(AVG(revenue), 2) AS avg_order_value
FROM completed_2025
GROUP BY channel;| channel | orders | revenue | avg_order_value |
|---|---|---|---|
| online | 2851 | 241793 | 84.81 |
| store | 9862 | 902734 | 91.54 |
Each step can be tested on its own (run SELECT * FROM order_revenue LIMIT 10), named meaningfully, and reviewed by colleagues. Readability is correctness: bugs hide in unreadable SQL.
UNION and UNION ALL
UNION stacks the results of queries vertically. The queries must return the same number of columns with compatible types.
UNION ALLkeeps every row (fast).UNIONremoves duplicate rows (slower — it must compare rows).
SELECT 'stores' AS source, COUNT(*) AS rows FROM stores
UNION ALL
SELECT 'products', COUNT(*) FROM products
UNION ALL
SELECT 'customers', COUNT(*) FROM customers;| source | rows |
|---|---|
| stores | 9 |
| products | 108 |
| customers | 4000 |
If you stack two months of transactions with UNION, two genuinely identical transactions collapse into one and totals shrink. Use UNION ALL unless you specifically want de-duplication.
Practice
Return product_name of products whose unit_cost is above the average unit_cost of all products.
Using a CTE, compute the average order value (net revenue per completed order) for each channel in 2025, rounded to 2 decimals. Return channel, aov.
You stack January and February transaction tables to total revenue. Which operator should you use?
Summary
- Subqueries can sit in
WHERE(values,IN,EXISTS),FROM(derived tables) andSELECT. - Correlated subqueries run per outer row — expressive, sometimes slow.
- CTEs (
WITH) make multi-step logic readable and testable. UNION ALLstacks all rows;UNIONremoves duplicates.