- Write subqueries that return a single value or a list
- Use
EXISTSand correlated subqueries - Break a complex query into parts with a subquery in
FROMand withWITH - Avoid the
NOT INandNULLtrap
“Which products cost more than the average?” This question has two steps: first find the average price (293.56), then compare with it. You could type the number into the query by hand, but tomorrow, when prices change, the query would be out of date. A better way is to do both steps in one query. For that we use a subquery.
A subquery in WHERE
A SELECT query written in brackets inside another query. Its result is used by the outer query as a value, a list or a table.
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESC;▸ Expected output
name | price Laptop | 1450 Smartphone | 899.99 Monitor | 310
The inner query runs first and returns one number; the outer query then uses it like an ordinary number. Such a subquery is called scalar: it must return exactly one row and one column. This is also the portable answer to the earlier question “what is the name of the most expensive product?”:
SELECT name, price
FROM products
WHERE price = (SELECT MAX(price) FROM products);▸ Expected output
name | price Laptop | 1450
Subqueries with IN and NOT IN
If a subquery returns many rows in one column, it can serve as the list for IN. Let's find the students enrolled in physics courses. Subqueries can be nested too: the innermost one returns the ids of physics courses, the middle one the ids of students enrolled in them.
SELECT first_name, last_name
FROM students
WHERE id IN (
SELECT student_id
FROM enrollments
WHERE course_id IN (SELECT id FROM courses WHERE subject = 'Physics')
)
ORDER BY id;▸ Expected output
first_name | last_name Murad | Əliyev Elvin | Quliyev Rəşad | Kərimov Fidan | Cəfərova
SELECT name, category
FROM products
WHERE id NOT IN (SELECT product_id FROM orders)
ORDER BY id;▸ Expected output
name | category Monitor | Electronics
LEFT JOIN ... IS NULL in the last lesson.SELECT COUNT(*) AS found
FROM products
WHERE id NOT IN (1, 2, NULL);▸ Expected output
found 0
NULL from the list and run it again — the result will be 8.EXISTS and correlated subqueries
A correlated subquery refers to a column of the outer query, for example o.customer_id = c.id. Logically it is recalculated for every row of the outer query. EXISTS (...) is true when the subquery returns at least one row — what the rows contain doesn't matter, so people usually write SELECT 1 inside.
SELECT c.name, c.country
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
JOIN products AS p ON p.id = o.product_id
WHERE o.customer_id = c.id
AND p.category = 'Electronics'
)
ORDER BY c.id;▸ Expected output
name | country Anar Mustafayev | Azerbaijan Lalə Əhmədova | Azerbaijan Emre Yılmaz | Türkiye Zəhra Hüseynli | Azerbaijan
A correlated subquery can also sit in SELECT — then it calculates one value for each row. Below we see how many courses each 10th grader takes and their best score.
SELECT s.first_name,
(SELECT COUNT(*) FROM enrollments AS e WHERE e.student_id = s.id) AS courses,
(SELECT MAX(score) FROM enrollments AS e WHERE e.student_id = s.id) AS best
FROM students AS s
WHERE s.grade = 10
ORDER BY s.id;▸ Expected output
first_name | courses | best Murad | 2 | 81 Rəşad | 2 | 99 Səbinə | 2 | 88
In which enrollments is the score higher than the average of that particular course?
Show solutionHide solution
We use the same table twice and tell the copies apart with aliases: outer
e, inner e2.Condition:
e.score > (SELECT AVG(e2.score) FROM enrollments AS e2 WHERE e2.course_id = e.course_id).The above-average enrollments of every course remain — 9 rows in total.
SELECT e.student_id, e.course_id, e.score
FROM enrollments AS e
WHERE e.score > (
SELECT AVG(e2.score)
FROM enrollments AS e2
WHERE e2.course_id = e.course_id
)
ORDER BY e.course_id, e.score DESC;▸ Expected output
student_id | course_id | score 11 | 1 | 97 1 | 1 | 92 3 | 2 | 95 11 | 3 | 94 2 | 3 | 81 4 | 4 | 72 5 | 5 | 93 6 | 6 | 99 3 | 7 | 90
Subqueries in FROM and WITH
A subquery's result is a table, so it can also go in FROM, where it works like a temporary table. Most DBMSs require such a subquery to have an alias (AS t). Below we first find each course's average, then the average of those averages and the best one.
SELECT ROUND(AVG(avg_score), 1) AS avg_of_courses,
ROUND(MAX(avg_score), 1) AS best_course
FROM (
SELECT course_id, AVG(score) AS avg_score
FROM enrollments
GROUP BY course_id
) AS t;▸ Expected output
avg_of_courses | best_course 82 | 92.7
As subqueries pile up inside each other, a query gets hard to read. The WITH clause (a CTE — common table expression) lets you name a subquery up front and then use it like an ordinary table. WITH is supported by SQLite, PostgreSQL, SQL Server and MySQL from version 8.0.
WITH course_avg AS (
SELECT course_id, ROUND(AVG(score), 1) AS avg_score
FROM enrollments
GROUP BY course_id
)
SELECT c.title, ca.avg_score
FROM course_avg AS ca
JOIN courses AS c ON c.id = ca.course_id
WHERE ca.avg_score > 85
ORDER BY ca.avg_score DESC;▸ Expected output
title | avg_score Python Basics | 92.7 World History | 90.5 Algebra | 87.3
Show the first name and age of the students who are older than the average age of all students. Sort by age descending, then by first name. Don't type the average in by hand — calculate it with a subquery.
SELECT first_name, age
FROM students
WHERE age > ( /* average age here */ )
ORDER BY age DESC, first_name;▸ Expected output
first_name | age Elvin | 17 Fidan | 17 Tural | 17 Murad | 16 Rəşad | 16 Səbinə | 16
Show the names of the products ordered by customers from Türkiye (country = 'Türkiye'). Don't use JOIN — use nested IN subqueries. Sort by name.
SELECT name
FROM products
WHERE id IN (
-- product ids from the orders of Turkish customers
)
ORDER BY name;▸ Expected output
name Chess set Pen set Smartphone
Key points
- A subquery is a
SELECTin brackets; its result is used as a value, a list or a table. - A scalar subquery returns exactly one value and is compared with operators like
=and>. IN (SELECT ...)works with a list;NOT INreturns nothing if the list containsNULL.- A correlated subquery refers to the outer row;
EXISTSchecks whether at least one row exists. - Give a subquery in
FROMan alias; split complex queries into parts withWITH.
Check yourself
10 questions. Every correct answer earns XP.