Queries inside queries: subqueries

A subquery is a query nested inside another query. It can provide one value, a list to compare against, or a temporary table.

A subquery that returns one value

A subquery that returns exactly one row and one column can appear wherever a single value is expected.

1

SELECT name,
       price,
       ROUND((SELECT AVG(price) FROM products), 2) AS average_price
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESC
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

namepriceaverage_price
Glide GPS Watch Pro2484.41230.21
Terra 3D Puzzle2481.771230.21
Aria Castle Kit2479.411230.21
Aria LED Strip Pro2463.411230.21
Nova Brief Answers2445.491230.21

Opens Planche in a separate browser tab, with the sample database loaded and this query ready. Every example on the page reuses that tab, and nothing runs until you press F9.

(SELECT AVG(price) FROM products) produces one number, and the rest of the query treats it as if you had typed that number. This is the answer to "why can't I write WHERE price > AVG(price)" — an aggregate summarizes a group, and while WHERE runs there are no groups yet. A subquery sidesteps that by being its own complete query, finished before the outer one is filtered.

A subquery that returns a list

2

SELECT first_name, last_name, email, country
FROM customers
WHERE id IN (
  SELECT customer_id
  FROM orders
  WHERE status = 'refunded'
)
ORDER BY last_name
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

first_namelast_nameemailcountry
MayaAhmedmaya.ahmed1@mail.exampleUS
NoahAhmednoah.ahmed@inbox.exampleFR
LiamAhmedliam.ahmed1@mail.exampleDE
AishaAhmedaisha.ahmed@mail.exampleGB
SofiaAhmedsofia.ahmed@example.comES

The inner query returns customer IDs, and IN checks whether each outer customer ID is in that list. The result is customers who have refunded orders.

EXISTS: asking whether anything matched

3

SELECT c.first_name, c.last_name, c.country
FROM customers c
WHERE EXISTS (
  SELECT 1
  FROM orders o
  WHERE o.customer_id = c.id
    AND o.total > 1000
)
ORDER BY c.last_name
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

first_namelast_namecountry
AishaAhmedGB
AishaAhmedJP
AvaAhmedIT
EmmaAhmedIN
EthanAhmedFR

EXISTS is true when its subquery returns at least one row. SELECT 1 is conventional because only the existence of a row matters, not the selected value.

This subquery mentions c.id from the outer query, which makes it correlated — a different question for every customer, rather than one list computed once. For "does a related row exist", that is usually both the clearest shape and the one engines optimize best.

The NOT IN trap

Of the 120 employees, 110 have no direct reports. This query attempts to find them:

4

-- Employees nobody reports to. 110 of the 120 qualify,
-- and this query finds none of them.
SELECT first_name, last_name, position
FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
Run in Planche

No rows

The result is zero rows, with no error.

manager_id is nullable, and the ten people at the top of the org chart have NULL in that column. The inner query therefore returns a list containing NULL. x NOT IN (1, 2, NULL) must also ask whether x differs from an unknown value, so the condition becomes unknown rather than true. A single NULL in the list can make the result empty.

IN is not affected the same way: a row that matches a real value is kept, whatever else is in the list. Only the negated form needs every comparison to be true, which is why one unknown is enough to empty it.

You can add WHERE manager_id IS NOT NULL to the subquery. In most cases, NOT EXISTS is safer because it does not compare NULL values:

5

SELECT e.first_name, e.last_name, e.position
FROM employees e
WHERE NOT EXISTS (
  SELECT 1
  FROM employees m
  WHERE m.manager_id = e.id
)
ORDER BY e.last_name
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

first_namelast_nameposition
PriyaAhmedSenior Engineer
DiegoAhmedIntern
YukiAhmedEngineer
RaviBrownLead
JonasBrownSpecialist

NOT EXISTS tests for the presence of rows rather than comparing values, so NULL does not enter the comparison.

A subquery as a table

A query returning many rows and columns can stand where a table would, in FROM. Such a query is called a derived table.

6

SELECT category, products, average
FROM (
  SELECT cat.name AS category,
         COUNT(*) AS products,
         ROUND(AVG(p.price), 2) AS average
  FROM products p
  JOIN categories cat ON cat.id = p.category_id
  GROUP BY cat.id
)
WHERE products >= 10
ORDER BY average DESC;
Run in Planche

Result — first 5 of 26 rows

categoryproductsaverage
Wearables111672.29
Programming101585.67
Tablets171529.45
Audio111482.89
Kitchen121443.21

To filter an aggregate result, group in the inner query and filter that result as an ordinary table. This can do the same job as HAVING and also allows filtering on an alias, which is not portable in HAVING.

Derived tables can be nested, but deeply nested queries are hard to read because they must be followed from the inside out. The next lesson replaces that structure with named steps you read top to bottom.

One subquery per row

7

SELECT p.name,
       p.price,
       (SELECT COUNT(*) FROM reviews r WHERE r.product_id = p.id) AS reviews,
       (SELECT ROUND(AVG(r.rating), 2) FROM reviews r WHERE r.product_id = p.id) AS avg_rating
FROM products p
ORDER BY avg_rating DESC, reviews DESC
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

namepricereviewsavg_rating
Atlas LED Strip1856.7525
Orbit Road Bike1731.2915
Onyx Sneakers187844.75
Brief Answers (3rd ed.)117.9664.67
Pulse Good to Great392.9734.67

What you can do now

The next lesson shows how to make these more complex queries easier to read with CTEs and window functions.

Try it yourself

  1. Which customers have never written a review?

    Show the answer

    Show the answer

    SELECT c.id, c.first_name, c.last_name
    FROM customers c
    WHERE NOT EXISTS (
      SELECT 1 FROM reviews r WHERE r.customer_id = c.id
    )
    ORDER BY c.id
    LIMIT 20;
    Run in Planche

    Result — first 5 of 20 rows

    idfirst_namelast_name
    3YukiJang
    4InesMeyer
    12KenjiPark
    13JonasLarsen
    26AvaSilva

    NOT EXISTS rather than NOT IN. Here reviews.customer_id happens to have no NULLs so both would work, but reaching for NOT EXISTS by habit means the one time it does matter, you are already right.

  2. Products holding more stock than the average product, most first.

    Show the answer

    Show the answer

    SELECT name, stock_quantity
    FROM products
    WHERE stock_quantity > (SELECT AVG(stock_quantity) FROM products)
    ORDER BY stock_quantity DESC
    LIMIT 15;
    Run in Planche

    Result — first 5 of 15 rows

    namestock_quantity
    Luma Phone Lite500
    Atlas Lens 35mm X499
    Sol Good to Great Plus498
    Echo Phone Pro Plus498
    Pulse Phone Pro Max497

    The scalar subquery computes the average once, and the outer query compares each row against it. This is the shape to remember for every “above/below the average” question.