DBMS 📂 SQL · 5 of 6 32 min read

Nested Queries & Sub-queries in SQL

A hands-on DBMS tutorial on SQL sub-queries and nested queries, taught on one small employees/departments dataset. It covers the three return shapes (scalar, column, table), where sub-queries can appear (SELECT, FROM, WHERE, HAVING), and every major form: scalar comparisons, IN/NOT IN with the NULL pitfall, ANY/ALL, correlated sub-queries, EXISTS/NOT EXISTS, derived tables, and multi-level nesting.

Section 01

The Story That Explains Sub-queries

Asking a Question Before You Can Ask the Real One
Suppose someone asks you: "Who earns more than the company average?" You cannot answer in one step. First you must work out a smaller question — "What is the average salary?" — and only then can you compare each person against that number.

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.

🧮
The One Idea to Hold Onto

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.


Section 02

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.

employeesemp_idnamedept_idsalary
101Asha1095,000
102Bilal1062,000
103Chen2058,000
104Diya2047,000
105Esha3051,000
106Farid3072,000
departmentsdept_iddept_name
10Engineering
20Sales
30Marketing
40Research
🔢
Numbers Worth Memorising for This Tutorial

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.


Section 03

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.

🔢
Scalar — one value
1 row × 1 column
Returns a single number or string, e.g. (SELECT AVG(salary) FROM employees). Use it anywhere a single value is expected: after =, >, <, or inside the SELECT list.
📋
Column — many values
N rows × 1 column
Returns a list, e.g. (SELECT dept_id FROM departments). Must be used with a set operator: IN, NOT IN, ANY, or ALL.
📊
Table — rows & columns
N rows × M columns
Returns a full result set used in the FROM clause as a derived table, e.g. a per-department average you then query again.
⚠️
The Classic Crash

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 =.


Section 04

Where a Sub-query Can Appear

🔍
In WHERE
Filter rows against a computed value or list. The most common place by far.
📤
In SELECT
Return a scalar value as an extra column for every output row.
📂
In FROM
Treat the inner result as a temporary table (a derived table) and query it.
🧮
In HAVING
Filter groups after aggregation against a computed threshold.

Section 05

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.

⏳ How a Scalar Sub-query Runs — once, then flows up
OUTER: SELECT name FROM employees WHERE salary > 64,167 INNER (runs once) SELECT AVG(salary) FROM employees 64,167 one value computed first → reused for every outer row

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);
OUTPUT (avg = 64,167)
name | salary Asha | 95,000 Farid | 72,000

Section 06

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;
OUTPUT
name | salary | company_avg | diff Asha | 95,000 | 64,167 | +30,833 Bilal | 62,000 | 64,167 | -2,167 Chen | 58,000 | 64,167 | -6,167 Diya | 47,000 | 64,167 | -17,167 Esha | 51,000 | 64,167 | -13,167 Farid | 72,000 | 64,167 | +7,833

Section 07

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')
       );
OUTPUT
name Asha Bilal Chen Diya
-- Departments that currently have NO employees
SELECT dept_name
FROM   departments
WHERE  dept_id NOT IN (SELECT dept_id FROM employees);
OUTPUT
dept_name Research
💥
The NOT IN + NULL Trap — Memorise This

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.


Section 08

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).

> ALL (Sales salaries)
namesalary
Asha95,000
Bilal62,000
Farid72,000
> ANY (Sales salaries)
namesalary
Asha95,000
Bilal62,000
Esha51,000
Farid72,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);
💡
Shorthand Equivalences

= 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.


Section 09

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."

🔄 Correlated Execution — inner query fires for each outer row
OUTER rows (e1) INNER re-runs (e2) Asha · dept10 · 95,000 Chen · dept20 · 58,000 Esha · dept30 · 51,000 SELECT AVG(salary) FROM employees e2 WHERE e2.dept_id = e1.dept_id avg=78,500 · 95k>78.5k ✓ keep avg=52,500 · 58k>52.5k ✓ keep avg=61,500 · 51k>61.5k ✗ skip 3 outer rows → inner query evaluated 3 times, each with that row's dept_id

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
       );
OUTPUT
name | dept_id | salary Asha | 10 | 95,000 (dept avg 78,500) Chen | 20 | 58,000 (dept avg 52,500) Farid | 30 | 72,000 (dept avg 61,500)
⏱️
Correlated = Repeated Work

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.


Section 10

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.

⚡ EXISTS Short-circuits — stops at the first match
Inner scan: are there employees in dept 10? Asha · dept10 ✓ match Bilal · dept10 (skipped) Chen · dept20 (skipped) EXISTS = TRUE remaining rows never checked first hit → returns TRUE immediately → scan stops

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
       );
EXISTS →
dept_name
Engineering
Sales
Marketing
NOT EXISTS →
dept_name
Research
🔑
Why 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.


Section 11

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;
OUTPUT
dept_id | avg_sal 10 | 78,500 30 | 61,500
📝
A Derived Table Must Be Named

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.


Section 12

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.

🧮 Reading It Inside-Out
Level 3
SELECT dept_id FROM departments WHERE dept_name = 'Engineering' → returns 10
Level 2
SELECT MAX(salary) FROM employees WHERE dept_id = 10 → returns 95,000
Level 1
Outer query keeps employees in dept 10 whose salary equals 95,000 → Asha
-- 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'));
OUTPUT
name | salary Asha | 95,000
🧹
Deep Nesting Hurts Readability

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.


Section 13

Sub-query vs JOIN, and IN vs EXISTS

SituationPreferWhy
Need columns from both tablesJOINA sub-query in WHERE can't return the other table's columns to the output.
Simple "is it in this list?"IN / sub-queryReads naturally; fine for small inner result sets.
"Does at least one match exist?"EXISTSShort-circuits on first match; immune to the NULL trap.
Large inner result setEXISTS over INEXISTS often stops early; a big IN list can be slow to materialise.
Per-group aggregate compared per rowJOIN + GROUP BYComputes 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;
Same Result, Better Plan

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.


Section 14

Common Mistakes

= with a multi-row sub-query
Using = when the inner query can return many rows. Switch to IN, ANY, or ALL.
NOT IN with NULLs
A single NULL in the list makes NOT IN return nothing. Filter NULLs or use NOT EXISTS.
Unaliased derived table
A sub-query in FROM without an alias is rejected by most engines. Always name it.
⚠️
Accidental correlation
Mistyping an inner column that doesn't exist makes SQL silently bind it to the outer table, changing results. Qualify columns with aliases.
⚠️
Scalar that returns 0 rows
A scalar sub-query returning no rows yields NULL, which can quietly drop outer rows. Account for the empty case.
Prefer CTEs when deep
Beyond two levels, refactor nesting into named CTEs for readability and to avoid repeating sub-queries.

Section 15

Practice Problems

Solve each using the sample tables before checking the approach.

🧠 Work These Out
Q1
List employees who earn below the company average. → scalar sub-query with <.
Q2
Find names of employees in the Marketing department. → dept_id = (SELECT dept_id ... WHERE dept_name='Marketing').
Q3
List departments having more than one employee. → derived table with GROUP BY + HAVING COUNT(*) > 1, or a correlated count.
Q4
Show employees whose salary is the maximum within their department. → correlated sub-query salary = (SELECT MAX(salary) ... WHERE e2.dept_id = e1.dept_id).
Q5
Find departments with no employees. → 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);
OUTPUT
name | dept_id | salary Asha | 10 | 95,000 Chen | 20 | 58,000 Farid | 30 | 72,000

Section 16

Golden Rules

🧮 Nested & Sub-queries — The Essentials
1
Always read a sub-query inside-out. The inner query's return shape — scalar, column, or table — dictates the legal operators around it.
2
Use = only with a scalar sub-query. For a list, use IN, ANY, or ALL.
3
A non-correlated sub-query runs once; a correlated one runs once per outer row. Reach for a JOIN when the correlated cost is high.
4
Beware NOT IN with a sub-query that can return NULL — it returns no rows. Prefer NOT EXISTS, which is NULL-safe.
5
Use EXISTS for "does at least one match exist?" — it short-circuits on the first hit instead of counting every match.
6
A sub-query in FROM (a derived table) must have an alias. Query it like a normal table.
7
When nesting goes beyond two levels, refactor into CTEs (WITH ... AS) for readability and to avoid repeating the same inner query.
8
Qualify columns with table aliases (e1.dept_id, e2.dept_id) so you never trigger an accidental correlation.