The Story That Explains Sub-queries
A sub-query is exactly this: a query written inside another query. The inner query answers the small question first, and hands its result to the outer query, which answers the real one. When sub-queries are stacked several layers deep, we call them nested queries.
A sub-query (also called an inner query or inner select) is a complete SELECT
statement enclosed in parentheses and embedded inside another statement. The
outer query uses whatever the inner query returns. This tutorial covers every
flavour — scalar, multi-row, correlated, EXISTS, derived tables, and true
multi-level nesting — each grounded in one small dataset you can trace by hand.
Read every sub-query from the inside out. Whatever the inner query produces — a single value, a list of values, or a whole table — becomes an ingredient the outer query consumes. The shape of that ingredient decides which operators are legal around it.
The Sample Database We Will Use
Every example below uses these two tables. Keep them in view — tracing the numbers by hand is how sub-queries truly click.
| employees | emp_id | name | dept_id | salary |
|---|---|---|---|---|
| 101 | Asha | 10 | 95,000 | |
| 102 | Bilal | 10 | 62,000 | |
| 103 | Chen | 20 | 58,000 | |
| 104 | Diya | 20 | 47,000 | |
| 105 | Esha | 30 | 51,000 | |
| 106 | Farid | 30 | 72,000 |
| departments | dept_id | dept_name |
|---|---|---|
| 10 | Engineering | |
| 20 | Sales | |
| 30 | Marketing | |
| 40 | Research |
Company average salary = 64,167. Department averages:
Engineering (10) = 78,500, Sales (20) = 52,500,
Marketing (30) = 61,500. Research (40) has
no employees — that gap powers the NOT IN and
NOT EXISTS examples later.
The Three Shapes a Sub-query Can Return
Before writing one, you must know what a sub-query gives back. There are exactly three shapes, and each shape allows different operators around it.
(SELECT AVG(salary) FROM employees).
Use it anywhere a single value is expected: after =, >,
<, or inside the SELECT list.
(SELECT dept_id FROM departments). Must be used with a
set operator: IN, NOT IN, ANY, or ALL.
FROM clause as a
derived table, e.g. a per-department average you then query again.
Using = with a sub-query that returns more than one row throws
"subquery returns more than one row." If the inner query can return a list, you must
use IN (or ANY/ALL), never =.
Where a Sub-query Can Appear
Scalar Sub-query — A Single Value
The most common case: the inner query produces one value, and the outer query compares against it. Crucially, a non-correlated scalar sub-query runs exactly once, before the outer query begins filtering.
The inner query is evaluated a single time; its one value is substituted into the outer WHERE and reused for all rows.
-- Employees earning more than the company average
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
Sub-query in the SELECT List
A scalar sub-query can also sit in the SELECT list, producing an extra computed
column for every row.
-- Show each salary alongside the company average and the gap
SELECT name,
salary,
(SELECT AVG(salary) FROM employees) AS company_avg,
salary - (SELECT AVG(salary) FROM employees) AS diff
FROM employees;
Multi-row Sub-queries — IN and NOT IN
When the inner query returns a list of values, use IN to keep rows
whose value is in the list, or NOT IN to keep those that are not.
-- Employees who work in Engineering or Sales
SELECT name
FROM employees
WHERE dept_id IN (
SELECT dept_id FROM departments
WHERE dept_name IN ('Engineering', 'Sales')
);
-- Departments that currently have NO employees
SELECT dept_name
FROM departments
WHERE dept_id NOT IN (SELECT dept_id FROM employees);
If the sub-query inside NOT IN returns even a single NULL, the whole
NOT IN yields no rows at all — because comparing anything to
NULL is unknown, never true. Always guard with
WHERE col IS NOT NULL inside the sub-query, or use NOT EXISTS instead,
which is immune to this trap.
ANY and ALL — Comparing Against a List
ANY and ALL let you use comparison operators against a list of values.
> ALL means "greater than every value" (i.e. greater than the maximum).
> ANY means "greater than at least one" (i.e. greater than the minimum).
| name | salary |
|---|---|
| Asha | 95,000 |
| Bilal | 62,000 |
| Farid | 72,000 |
| name | salary |
|---|---|
| Asha | 95,000 |
| Bilal | 62,000 |
| Esha | 51,000 |
| Farid | 72,000 |
-- Earn more than EVERY Sales employee (> max of dept 20 = 58,000)
SELECT name, salary
FROM employees
WHERE salary > ALL (SELECT salary FROM employees WHERE dept_id = 20);
-- Earn more than AT LEAST ONE Sales employee (> min of dept 20 = 47,000)
SELECT name, salary
FROM employees
WHERE salary > ANY (SELECT salary FROM employees WHERE dept_id = 20);
= ANY (...) is identical to IN (...).
<> ALL (...) is identical to NOT IN (...).
Many writers prefer IN/NOT IN for readability and keep
ANY/ALL for the inequality cases like > ALL.
Correlated Sub-queries — The Inner Query Re-runs Per Row
A correlated sub-query references a column from the outer query. Because it depends on the current outer row, it cannot run once — it re-executes for every outer row. The animation below shows the loop in action for the query "employees earning above their own department's average."
The amber box steps through each outer row; the inner query recomputes its department average every time. Slower than a scalar sub-query, but able to compare each row to a per-row value.
-- Employees earning more than the average of THEIR OWN department
SELECT name, dept_id, salary
FROM employees e1
WHERE salary > (
SELECT AVG(salary)
FROM employees e2
WHERE e2.dept_id = e1.dept_id -- the correlation
);
A correlated sub-query conceptually runs once per outer row, so a 1-million-row table can
trigger a million inner executions. For large data, a JOIN with
GROUP BY (or a window function) is usually far faster — see Section 13.
EXISTS and NOT EXISTS
EXISTS returns true the moment the inner query finds its
first matching row, then stops — it never cares how many rows match, only
whether any do. This short-circuit makes it efficient for "is there at least one?" checks.
Unlike COUNT(*) > 0, which counts every matching row, EXISTS can quit after the first one.
-- Departments that have at least one employee
SELECT dept_name
FROM departments d
WHERE EXISTS (
SELECT 1 FROM employees e
WHERE e.dept_id = d.dept_id
);
-- Departments that have NO employees (immune to the NULL trap)
SELECT dept_name
FROM departments d
WHERE NOT EXISTS (
SELECT 1 FROM employees e
WHERE e.dept_id = d.dept_id
);
| dept_name |
|---|
| Engineering |
| Sales |
| Marketing |
| dept_name |
|---|
| Research |
SELECT 1 Inside EXISTS?
EXISTS only checks whether rows come back, never what they
contain, so the select list is irrelevant. Writing SELECT 1 (or
SELECT *) makes the intent clear; the optimiser treats them identically.
Derived Tables — A Sub-query in FROM
When a sub-query returns a whole table, place it in the FROM clause, give it an alias,
and query it like any other table. This is a derived table (or inline view).
-- Which departments have an average salary above 60,000?
SELECT dept_id, avg_sal
FROM (
SELECT dept_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY dept_id
) AS dept_avg
WHERE avg_sal > 60000;
Every derived table needs an alias (AS dept_avg above), even if you never
reference it elsewhere. Many engines reject an unnamed sub-query in FROM.
For reuse across a query, a CTE (WITH dept_avg AS (...)) is the
cleaner, more readable cousin of a derived table.
Nested Sub-queries — Queries Within Queries
A sub-query can itself contain another sub-query, several layers deep. The engine resolves the innermost query first, then works outward. Below, three levels cooperate to find the top earner in Engineering.
SELECT dept_id FROM departments WHERE dept_name = 'Engineering' → returns 10
SELECT MAX(salary) FROM employees WHERE dept_id = 10 → returns 95,000
-- Highest-paid employee in the Engineering department
SELECT name, salary
FROM employees
WHERE dept_id = (SELECT dept_id FROM departments
WHERE dept_name = 'Engineering')
AND salary = (SELECT MAX(salary) FROM employees
WHERE dept_id = (SELECT dept_id FROM departments
WHERE dept_name = 'Engineering'));
Three or more levels become hard to read and debug, and the same sub-query is often repeated (notice Engineering's lookup appears twice above). A CTE lets you name each step once and stack them top-to-bottom — almost always clearer than deep nesting.
Sub-query vs JOIN, and IN vs EXISTS
| Situation | Prefer | Why |
|---|---|---|
| Need columns from both tables | JOIN | A sub-query in WHERE can't return the other table's columns to the output. |
| Simple "is it in this list?" | IN / sub-query | Reads naturally; fine for small inner result sets. |
| "Does at least one match exist?" | EXISTS | Short-circuits on first match; immune to the NULL trap. |
| Large inner result set | EXISTS over IN | EXISTS often stops early; a big IN list can be slow to materialise. |
| Per-group aggregate compared per row | JOIN + GROUP BY | Computes each group's value once instead of re-running per row like a correlated sub-query. |
The Same Question, Two Ways
-- Correlated sub-query (re-runs per row)
SELECT name, salary
FROM employees e1
WHERE salary > (SELECT AVG(salary) FROM employees e2
WHERE e2.dept_id = e1.dept_id);
-- Equivalent JOIN against a pre-aggregated derived table (computed once)
SELECT e.name, e.salary
FROM employees e
JOIN (SELECT dept_id, AVG(salary) AS avg_sal
FROM employees GROUP BY dept_id) d
ON e.dept_id = d.dept_id
WHERE e.salary > d.avg_sal;
Both queries return Asha, Chen, and Farid. The JOIN version computes each department's average a single time, then joins — typically much faster on large tables than re-running the correlated inner query once per employee.
Common Mistakes
= when the inner query can return many rows. Switch to IN, ANY, or ALL.NOT IN return nothing. Filter NULLs or use NOT EXISTS.FROM without an alias is rejected by most engines. Always name it.NULL, which can quietly drop outer rows. Account for the empty case.Practice Problems
Solve each using the sample tables before checking the approach.
<.
dept_id = (SELECT dept_id ... WHERE dept_name='Marketing').
GROUP BY + HAVING COUNT(*) > 1, or a correlated count.
salary = (SELECT MAX(salary) ... WHERE e2.dept_id = e1.dept_id).
NOT EXISTS (preferred) or NOT IN with a NULL guard.
-- Q4 solution: top earner(s) in each department
SELECT name, dept_id, salary
FROM employees e1
WHERE salary = (SELECT MAX(salary) FROM employees e2
WHERE e2.dept_id = e1.dept_id);
Golden Rules
= only with a scalar sub-query. For a list, use
IN, ANY, or ALL.
NOT IN with a sub-query that can return NULL — it
returns no rows. Prefer NOT EXISTS, which is NULL-safe.
EXISTS for "does at least one match exist?" — it short-circuits on the
first hit instead of counting every match.
FROM (a derived table) must have an
alias. Query it like a normal table.
WITH ... AS) for readability and to avoid repeating the same inner query.
e1.dept_id,
e2.dept_id) so you never trigger an accidental correlation.