Subqueries
Queries inside queries — scalar, IN, EXISTS, and the correlated subquery every analyst eventually writes.
What you'll learn
- When a subquery reads cleaner than a JOIN
- Scalar vs IN vs EXISTS, and why EXISTS handles NULLs better
- What makes a subquery 'correlated', and its performance cost
- Derived tables (a subquery in FROM) for aggregate-then-compare
Before you start
A subquery is just a SELECT nested inside another SELECT. The whole skill is recognising where to put it — each position has its own job. We will use these two tables:
| products | category | price |
|---|---|---|
| Pencil | Stationery | 2 |
| Notebook | Stationery | 6 |
| Coffee | Grocery | 9 |
| Laptop | Electronics | 900 |
| Cable | Electronics | 12 |
| orders | user_id |
|---|---|
| 101 | 1 |
| 102 | 1 |
| 103 | 3 |
In WHERE — a value, a set, or a truth check
A scalar subquery returns exactly one value, usable anywhere a value goes. It runs once, and the outer query compares each row to it:
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products); -- average is 185.8
| name | price |
|---|---|
| Laptop | 900 |
IN (subquery) checks membership in the set the subquery returns; EXISTS (subquery) ignores what the subquery returns and asks only whether it returns any row. EXISTS is the NULL-safe favourite — “users who have placed at least one order”:
SELECT u.name
FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
That o.user_id = u.id references a column from the outer query, which makes it a correlated subquery — one that logically re-runs for every outer row, rather than once. It is the source of both its power and its cost.
In SELECT — annotate each row (the slow way)
A scalar subquery in SELECT computes a per-row value:
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u
ORDER BY order_count DESC;
This works, but a correlated subquery in SELECT fires a fresh scan for every row — O(n²) in the worst case, fine on small data and ruinous on a 100-million-row table. The senior move is to spot the pattern (“a per-row aggregate over a related set”) and rewrite it as a window function: COUNT(*) OVER (PARTITION BY user_id) does the same job in a single pass.
In FROM — the derived table
When you need to aggregate first and then compare, wrap the aggregate in a subquery, alias it, and treat it like a table. “Products priced above the average for their own category”:
SELECT p.name, p.category, p.price, c.avg_price
FROM products p
JOIN (SELECT category, AVG(price) AS avg_price
FROM products GROUP BY category) c
ON c.category = p.category
WHERE p.price > c.avg_price;
| name | category | price | avg_price |
|---|---|---|---|
| Notebook | Stationery | 6 | 4 |
| Laptop | Electronics | 900 | 456 |
The inner query collapses to one row per category (Stationery 4, Grocery 9, Electronics 456); the outer joins each product to its category’s average and keeps the ones above it. That “aggregate, then compare back” shape is the skeleton of most non-trivial analytics SQL.
| Use a subquery when… | Use a JOIN when… |
|---|---|
| Filtering by membership (IN/EXISTS) | You need columns from both sides |
| Comparing to a single aggregate | You’re combining rows row-for-row |
Practice
Quick check
Practice this in an interview
All questionsAn uncorrelated subquery executes once and its result is fed into the outer query; a correlated subquery references columns from the outer query and re-executes for every row the outer query processes. The distinction matters because correlated subqueries can silently turn an O(n) query into O(n²).
A derived table is an inline subquery in the FROM clause that acts as a virtual table for the duration of the query; it is not correlated to the outer query and has no name reuse. A CTE is named and can be referenced multiple times, while a correlated subquery executes per-row in WHERE or SELECT.
A scalar subquery is a subquery that returns exactly one column and one row, and can appear anywhere a single value is valid — SELECT list, WHERE clause, HAVING clause, or even a JOIN ON condition. If it returns more than one row at runtime the database raises a runtime error, not a compile-time error.
A correlated subquery in the SELECT list executes once per output row, turning what looks like a simple projection into an O(n) nested loop. The preferred rewrites are a window function or a pre-aggregating JOIN, both of which the optimizer can execute in a single pass.