Skip to content

Subqueries & CTE

Chapter 6 13 code blocks 3752 words

Chapter 2 made the point that a query returns a table. This chapter cashes that in: if a query returns a table, it can be used anywhere a table is expected — inside WHERE, inside FROM, even inside SELECT. That is all a subquery is.

This is also where messy queries become readable, because a CTE lets you name each step instead of nesting five levels of brackets.

What a subquery is — the three positions

A subquery (or inner query) is a SELECT written inside another statement, always in brackets. Where you put it determines what shape it must return.

Position Must return Called Typical use
In SELECT Exactly one row, one column Scalar subquery Compare each row to an overall total
In WHERE with = Exactly one row, one column Scalar subquery "the maximum", "the average"
In WHERE with IN/EXISTS One column, many rows Row subquery Membership tests
In FROM A full table Derived table Aggregate twice; filter on an alias

Scalar subquery

A subquery that returns a single value can be used anywhere a value can be used.

sql7 lines
SELECT
    title,
    price,
    (SELECT ROUND(AVG(price)) FROM courses)        AS avg_price,
    price - (SELECT AVG(price) FROM courses)       AS diff_from_avg
FROM courses
ORDER BY price DESC;
titlepriceavg_pricediff_from_avg
Deep Learning1 550 0001 183 333366 666.667
Project Management1 500 0001 183 333316 666.667
Financial Analysis1 500 0001 183 333316 666.667
Machine Learning1 450 0001 183 333266 666.667
Probability1 250 0001 183 33366 666.667
Python Basics1 200 0001 183 33316 666.667
Statistics 1011 200 0001 183 33316 666.667
Linear Algebra1 200 0001 183 33316 666.667
Web Development1 150 0001 183 333-33 333.333
Java Basics1 150 0001 183 333-33 333.333
Excel for Business1 100 0001 183 333-83 333.333
Graphic Design950 0001 183 333-233 333.333
Data Visualization900 0001 183 333-283 333.333
UX Design850 0001 183 333-333 333.333
SQL for Analytics800 0001 183 333-383 333.333

15 rows

1 ms

In WHERE it answers "compared to the average / the maximum":

sql7 lines
SELECT title, price FROM courses
WHERE price > (SELECT AVG(price) FROM courses);
-- Machine Learning, Deep Learning

SELECT enrollment_id, grade FROM enrollments
WHERE grade = (SELECT MAX(grade) FROM enrollments);
-- enrollment 13, grade 100

Result 1

titleprice
Python Basics1 200 000
Statistics 1011 200 000
Machine Learning1 450 000
Project Management1 500 000
Linear Algebra1 200 000
Deep Learning1 550 000
Financial Analysis1 500 000
Probability1 250 000

8 rows

Result 2

enrollment_idgrade
135100
322100
500100
699100
814100
1 096100
1 202100
1 832100
1 862100
2 126100
2 128100
2 161100
2 377100
2 424100

14 rows

1 ms

⚠️ More than one row = runtime error

WHERE price = (SELECT price FROM courses) fails once the subquery returns more than one row. If many values are possible, use IN instead of =. And if the subquery returns no rows, it evaluates to NULL — so the comparison becomes UNKNOWN and the outer query quietly returns nothing.

Derived table (subquery in FROM)

A subquery in FROM becomes a temporary table for that query. It must be given an alias. This is how you aggregate an aggregate — something a single GROUP BY cannot do.

python8 lines
-- "On average, how many enrollments does a course have?"
SELECT ROUND(AVG(cnt), 2) AS avg_enrollments_per_course
FROM (
    SELECT course_id, COUNT(*) AS cnt
    FROM enrollments
    GROUP BY course_id
) AS per_course;
-- 4.0   (counts are 5, 5, 3, 3, 4)
avg_enrollments_per_course
178.57

1 row

1 ms

The inner query collapses 20 enrollments into 5 rows; the outer query then averages those 5 numbers. Two levels of aggregation, two queries.

It also rescues the Chapter 2 problem of not being able to filter on an alias:

python9 lines
-- WHERE cannot see an alias — but it can see a column of a derived table
SELECT * FROM (
    SELECT title, ROUND(price * 1.0 / credits) AS per_credit
    FROM courses
) AS t
WHERE per_credit > 400000
ORDER BY per_credit DESC;
-- Deep Learning 600000, SQL for Analytics 500000,
-- Machine Learning 500000, Data Visualization 466667
titleper_credit
Excel for Business550 000
Project Management500 000
Financial Analysis500 000

3 rows

0 ms

Correlated subqueries An ordinary subquery runs once. A correlated subquery references a column from the outer query, so it is conceptually re-run for every outer row.

sql6 lines
SELECT
    s.first_name,
    (SELECT COUNT(*)     FROM enrollments e WHERE e.student_id = s.student_id) AS courses,
    (SELECT ROUND(AVG(grade),1) FROM enrollments e WHERE e.student_id = s.student_id) AS avg_grade
FROM students s
ORDER BY courses DESC, s.first_name;
first_namecoursesavg_grade
Zulfiya1971.1
Shohrux1774
Gulnora1671.7
Sardor1671.2
Aziz1576.5
Gulnora1577.5
Nilufar1573.4
Otabek1571.7
Shahnoza1570.7
Shohrux1573.2
Akmal1470.6
Aziza1475.5
Dilnoza1472.8
Kamola1473.4
Kamola1477.8

… first 15 of 300 rows

300 rows

35 ms

⚠️ The performance cost

Conceptually this runs the inner query once per outer row: 10 students × 2 subqueries = 20 executions here, but 1,000,000 executions on a million-row table. Modern optimisers often rewrite simple cases into a join, but do not rely on it. A LEFT JOIN with GROUP BY does the same job in one pass and scales:

sql4 lines
SELECT s.first_name, COUNT(e.enrollment_id) AS courses,
       ROUND(AVG(e.grade),1) AS avg_grade
FROM students s LEFT JOIN enrollments e ON s.student_id = e.student_id
GROUP BY s.student_id, s.first_name;
first_namecoursesavg_grade
Shahnoza786.3
Doston873.5
Zulfiya977.3
Kamola877.8
Gulnora1170.6
Bekzod982.8
Doston672.5
Madina971.2
Sardor769.8
Dilnoza777.3
Jasur678.5
Rustam973.2
Bekzod676.4
Otabek477.7
Madina970.8

… first 15 of 300 rows

300 rows

2 ms

Use correlated subqueries for clarity on small data; use joins for large data.

EXISTS and NOT EXISTS

EXISTS takes a subquery and returns TRUE if it produces at least one row. It does not care what the rows contain — which is why everyone writes SELECT 1 inside it.

python15 lines
-- Students with at least one active enrollment
SELECT s.first_name FROM students s
WHERE EXISTS (
    SELECT 1 FROM enrollments e
    WHERE e.student_id = s.student_id AND e.status = 'active'
);
-- Aziza, Dilnoza, Malika, Zarina, Ulugbek

-- Students who have never failed anything
SELECT s.first_name FROM students s
WHERE NOT EXISTS (
    SELECT 1 FROM enrollments e
    WHERE e.student_id = s.student_id AND e.status = 'failed'
);
-- everyone except Jasur and Sevara

Result 1

first_name
Shahnoza
Doston
Zulfiya
Kamola
Gulnora
Madina
Sardor
Jasur
Rustam
Madina
Shahnoza
Aziza
Aziza
Rustam
Dilnoza

… first 15 of 238 rows

238 rows

Result 2

first_name
Shahnoza
Doston
Gulnora
Bekzod
Doston
Madina
Sardor
Dilnoza
Jasur
Rustam
Bekzod
Otabek
Madina
Shahnoza
Rustam

… first 15 of 160 rows

160 rows

22 ms

IN EXISTS
Compares A value against a list of values Whether any row exists
Stops early No — builds the whole list Yes — quits at the first match
NULL-safe when negated No — NOT IN breaks Yes
Best for Short literal lists Correlated checks on big tables
💡 Default to NOT EXISTS

For "does not have a related row", NOT EXISTS is correct regardless of NULLs, reads like the English sentence, and is at least as fast as the alternatives. Chapter 3 showed exactly how NOT IN fails.

CTE — the WITH clause

A CTE (Common Table Expression) is a derived table that you name before the main query instead of burying it inside. Same result, dramatically better readability.

sql17 lines
WITH student_stats AS (
    SELECT
        student_id,
        COUNT(*)      AS courses,
        AVG(grade)    AS avg_grade
    FROM enrollments
    WHERE grade IS NOT NULL
    GROUP BY student_id
)
SELECT
    s.first_name,
    st.courses,
    ROUND(st.avg_grade, 1) AS avg_grade
FROM student_stats st
JOIN students s ON s.student_id = st.student_id
WHERE st.avg_grade > (SELECT AVG(grade) FROM enrollments)
ORDER BY st.avg_grade DESC;
first_namecoursesavg_grade
Otabek387.3
Shahnoza386.3
Akmal284
Zulfiya284
Bekzod882.8
Aziz282.5
Shahnoza482.3
Doston582.2
Rustam481.8
Gulnora981.6
Ulug'bek681
Nilufar381
Aziz680.8
Malika480.8
Bekzod680.3

… first 15 of 147 rows

147 rows

1 ms

💡 Why CTEs matter for teaching
  • Named steps. WITH monthly_sales AS (...) documents itself; a bracket at depth 3 does not.
  • Reusable. A CTE can be referenced several times in the same query; a derived table must be repeated.
  • Debuggable. Students can run just the CTE body to check it before writing the rest.
  • Required for recursion. There is no other way to write a recursive query.

Chaining several CTEs

Separate multiple CTEs with commas. Each one can reference the ones defined before it, so a complicated report becomes a readable pipeline.

sql23 lines
WITH graded AS (
    -- step 1: only enrollments that actually have a grade
    SELECT * FROM enrollments WHERE grade IS NOT NULL
),
course_avg AS (
    -- step 2: average per course
    SELECT course_id, COUNT(*) AS n, AVG(grade) AS avg_grade
    FROM graded GROUP BY course_id
),
overall AS (
    -- step 3: one number for the whole school
    SELECT AVG(grade) AS school_avg FROM graded
)
SELECT
    c.title,
    ca.n                                   AS graded,
    ROUND(ca.avg_grade, 1)                 AS course_avg,
    ROUND((SELECT school_avg FROM overall), 1) AS school_avg,
    CASE WHEN ca.avg_grade > (SELECT school_avg FROM overall)
         THEN 'above' ELSE 'below' END     AS vs_school
FROM course_avg ca
JOIN courses c ON c.course_id = ca.course_id
ORDER BY ca.avg_grade DESC;
titlegradedcourse_avgschool_avgvs_school
Excel for Business12279.973above
Python Basics13279.273above
UX Design11876.173above
Web Development11875.273above
Project Management13175.173above
Financial Analysis13574.773above
SQL for Analytics14674.373above
Data Visualization11974.273above
Java Basics12773.973above
Graphic Design14773.573above
Machine Learning12569.473below
Linear Algebra14068.373below
Probability13865.673below
Statistics 10112863.673below

14 rows

1 ms

Recursive CTE

A recursive CTE refers to itself. It has two parts joined by UNION ALL: an anchor (the starting rows) and a recursive step (how to get from one row to the next). It stops when the recursive step returns nothing.

sql8 lines
WITH RECURSIVE numbers(n) AS (
    SELECT 1                       -- anchor: where we start
    UNION ALL
    SELECT n + 1 FROM numbers      -- recursive step
    WHERE n < 10                   -- STOP condition — never omit this
)
SELECT n FROM numbers;
-- 1 2 3 4 5 6 7 8 9 10
n
1
2
3
4
5
6
7
8
9
10

10 rows

0 ms

The genuinely useful analytics version is generating a complete date series, so that months with no data still appear in a report as zero:

sql13 lines
WITH RECURSIVE months(m) AS (
    SELECT '2025-09'
    UNION ALL
    SELECT strftime('%Y-%m', DATE(m || '-01', '+1 month'))
    FROM months WHERE m < '2026-09'
)
SELECT
    months.m                       AS month,
    COUNT(e.enrollment_id)         AS enrollments
FROM months
LEFT JOIN enrollments e ON strftime('%Y-%m', e.enrolled_at) = months.m
GROUP BY months.m
ORDER BY months.m;
monthenrollments
2025-091 188
2025-10104
2025-110
2025-120
2026-010
2026-020
2026-030
2026-040
2026-050
2026-060
2026-070
2026-080
2026-090

13 rows

6 ms

Now all 13 months appear — September 2025 with 6, February 2026 with 12, September 2026 with 2, and the ten empty months with 0. Without the generated series those months would be invisible, and a line chart of the result would be badly misleading.

The other classic use is walking a hierarchy — an employee's whole management chain, or a category tree of unknown depth.

⚠️ Always write the stop condition first

A recursive CTE without a terminating WHERE loops until the database kills it or fills the disk. Write the WHERE before you write the recursive SELECT. PostgreSQL and SQLite need the RECURSIVE keyword; SQL Server does not use it.

Subquery vs JOIN vs CTE — which to use

Situation Best tool Why
Need columns from another table JOIN That is literally what a join is for
Need only a yes/no existence check EXISTS Stops at the first match; no fan-out
Need one summary number in every row Scalar subquery or window function Cheap, reads naturally
Need to aggregate an aggregate Derived table / CTE Two levels of GROUP BY need two queries
Query is longer than ~15 lines CTE Named steps beat nested brackets
Need the same subresult twice CTE Define once, reference many times
Walking a hierarchy or generating a series Recursive CTE Nothing else can do it
⚠️ The one case where JOIN and subquery genuinely differ

They are not always interchangeable. A join to the many side duplicates rows (fan-out, Chapter 5); EXISTS and IN never do. So:

python8 lines
-- 8 rows — students appear once per matching enrollment
SELECT s.first_name FROM students s
JOIN enrollments e ON s.student_id = e.student_id WHERE e.status = 'completed';

-- 8 distinct students — each appears exactly once
SELECT s.first_name FROM students s
WHERE EXISTS (SELECT 1 FROM enrollments e
              WHERE e.student_id = s.student_id AND e.status = 'completed');

Result 1

first_name
Rustam
Gulnora
Ulug'bek
Sevara
Dilnoza
Otabek
Kamola
Gulnora
Zulfiya
Zulfiya
Akmal
Zulfiya
Bekzod
Akmal
Kamola

… first 15 of 1634 rows

1634 rows

Result 2

first_name
Shahnoza
Doston
Zulfiya
Kamola
Gulnora
Bekzod
Doston
Madina
Sardor
Dilnoza
Jasur
Rustam
Bekzod
Otabek
Madina

… first 15 of 300 rows

300 rows

4 ms

If you only want to know whether a match exists, use EXISTS. Reaching for SELECT DISTINCT to clean up a join is a sign you wanted EXISTS all along.

Practice set

python44 lines
-- 1. Courses more expensive than average
SELECT title, price FROM courses
WHERE price > (SELECT AVG(price) FROM courses);

-- 2. The student(s) with the highest average grade
WITH avg_per_student AS (
    SELECT student_id, AVG(grade) AS avg_grade
    FROM enrollments WHERE grade IS NOT NULL GROUP BY student_id
)
SELECT s.first_name, ROUND(a.avg_grade,1) AS avg_grade
FROM avg_per_student a JOIN students s USING (student_id)
WHERE a.avg_grade = (SELECT MAX(avg_grade) FROM avg_per_student);

-- 3. Students who take Machine Learning but not Python Basics
SELECT s.first_name FROM students s
WHERE EXISTS     (SELECT 1 FROM enrollments e WHERE e.student_id = s.student_id AND e.course_id = 5)
  AND NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.student_id = s.student_id AND e.course_id = 1);

-- 4. Average number of enrollments per course
SELECT ROUND(AVG(cnt),2) FROM (
    SELECT course_id, COUNT(*) AS cnt FROM enrollments GROUP BY course_id
) t;

-- 5. Each course with its share of all enrollments
WITH per_course AS (
    SELECT course_id, COUNT(*) AS n FROM enrollments GROUP BY course_id
)
SELECT c.title, p.n,
       ROUND(100.0 * p.n / (SELECT SUM(n) FROM per_course), 1) AS pct_of_all
FROM per_course p JOIN courses c USING (course_id)
ORDER BY pct_of_all DESC;

-- 6. Students who never failed
SELECT first_name FROM students s
WHERE NOT EXISTS (SELECT 1 FROM enrollments e
                  WHERE e.student_id = s.student_id AND e.status = 'failed');

-- 7. Numbers 1 to 12 (recursive)
WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x < 12)
SELECT x FROM n;

-- 8. Rewrite exercise: express #1 as a CTE instead of a subquery
WITH stats AS (SELECT AVG(price) AS avg_price FROM courses)
SELECT c.title, c.price FROM courses c, stats WHERE c.price > stats.avg_price;

Result 1

titleprice
Python Basics1 200 000
Statistics 1011 200 000
Machine Learning1 450 000
Project Management1 500 000
Linear Algebra1 200 000
Deep Learning1 550 000
Financial Analysis1 500 000
Probability1 250 000

8 rows

Result 2

first_nameavg_grade
Otabek87.3

1 row

Result 3

first_name
Gulnora
Doston
Sardor
Madina
Rustam
Shahnoza
Shahnoza
Dilnoza
Aziz
Ulug'bek
Akmal
Aziz
Kamola
Aziz
Shohrux

… first 15 of 74 rows

74 rows

Result 4

ROUND(AVG(cnt),2)
178.57

1 row

Result 5

titlenpct_of_all
Linear Algebra2008
Python Basics1937.7
Financial Analysis1897.6
Java Basics1877.5
Probability1887.5
SQL for Analytics1867.4
Graphic Design1797.2
Statistics 1011747
Web Development1747
Project Management1706.8
Data Visualization1676.7
Machine Learning1676.7
Excel for Business1656.6
UX Design1616.4

14 rows

Result 6

first_name
Shahnoza
Doston
Gulnora
Bekzod
Doston
Madina
Sardor
Dilnoza
Jasur
Rustam
Bekzod
Otabek
Madina
Shahnoza
Rustam

… first 15 of 160 rows

160 rows

Result 7

x
1
2
3
4
5
6
7
8
9
10
11
12

12 rows

Result 8

titleprice
Python Basics1 200 000
Statistics 1011 200 000
Machine Learning1 450 000
Project Management1 500 000
Linear Algebra1 200 000
Deep Learning1 550 000
Financial Analysis1 500 000
Probability1 250 000

8 rows

34 ms

Interview questions

Q: What is a correlated subquery?

One that references a column from the outer query, so it must be evaluated per outer row and cannot run standalone. It is expressive but potentially O(n) executions; a JOIN + GROUP BY usually scales better.

Q: CTE vs subquery — is there a performance difference?

Usually none: most engines inline a CTE exactly like a derived table. The difference is readability and reuse. Two caveats: PostgreSQL before version 12 always materialised CTEs (an optimisation fence), and a CTE referenced several times may be computed several times unless the engine materialises it.

Q: When must you use a CTE rather than a subquery?

For recursion, and when the same intermediate result is needed more than once in the same query.

Q: What are the two parts of a recursive CTE?

An anchor member (the seed rows) and a recursive member that references the CTE, combined with UNION ALL, plus a condition that eventually makes the recursive member return no rows.

Q: IN vs EXISTS vs JOIN for "students who have at least one completed course"?

EXISTS is the best fit: it stops at the first match and returns each student once. A JOIN returns a student once per completed course (fan-out) and needs DISTINCT. IN is fine here but becomes dangerous the moment it is negated.

Q: Can a subquery in SELECT return multiple rows?

No — it must be scalar (one row, one column) or the query fails at runtime. Returning zero rows is allowed and yields NULL.

Q: How do you show months that have no data in a monthly report?

Generate the complete month series (a recursive CTE or a calendar table), then LEFT JOIN the data onto it and COUNT a column from the data side.

Summary

  • A subquery is a SELECT inside another statement. Its position dictates its required shape: scalar in SELECT/=, a column of values for IN, a whole table in FROM.
  • Scalar subqueries put an overall summary next to every row. Derived tables let you aggregate an aggregate and filter on an alias.
  • Correlated subqueries reference the outer query and run per row — clear on small data, slow on large; prefer JOIN + GROUP BY at scale.
  • EXISTS/NOT EXISTS answer "is there at least one?", stop early, never fan out, and are NULL-safe.
  • CTEs (WITH) name each step. Chain them with commas to turn a monster query into a readable pipeline.
  • Recursive CTEs (anchor + recursive step + stop condition) generate series and walk hierarchies — used in reporting to make empty periods appear as 0.
  • JOIN duplicates rows on the many side; EXISTS does not. Reaching for DISTINCT after a join usually means you wanted EXISTS.

Next: Chapter 7 — window functions, which give you aggregate values without collapsing your rows. This is the single most valuable SQL skill in an analytics interview.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents