MySQL – Subqueries

August 24, 20245 min readUpdated 8/25/2026

A subquery is a SELECT inside another statement. It lets one query answer a question that depends on the answer to another, and it goes in four places: the select list, the WHERE clause, the FROM clause, and — as of MySQL 8 — a CTE, which is usually the more readable version of the same idea.

In the select list: scalar subqueries

SELECT name, (SELECT COUNT(*) FROM product_size ps WHERE ps.product_id = p.id) AS sizes
FROM   product p WHERE p.type = 'PIZZA' ORDER BY p.id LIMIT 3;
+-----------------+-------+
| name            | sizes |
+-----------------+-------+
| Pepperoni Pizza |     3 |
| Cheese Pizza    |     3 |
| Supreme Pizza   |     3 |
+-----------------+-------+

A subquery here must return one row and one column — more than one row is an error, and no rows gives NULL. This one is correlated: it mentions p.id from the outer query, so conceptually it runs once per outer row. A LEFT JOIN with GROUP BY usually does the same job with one pass; see the note on performance below.

In WHERE: IN, EXISTS and comparisons

SELECT id, customer_name, total FROM customer_order
WHERE  id IN (SELECT order_id FROM order_item WHERE product_name = 'Pepsi')
ORDER  BY id;
+----+---------------+-------+
| id | customer_name | total |
+----+---------------+-------+
|  1 | Demo Customer | 28.91 |
| 11 | Demo Customer | 58.19 |
| 18 | Demo Customer | 27.02 |
+----+---------------+-------+

IN takes a list of values. EXISTS takes a correlated subquery and asks only whether it produced anything — which is why SELECT 1 is the convention inside one; the columns are never read.

Comparison operators work too, and this is the neat way to ask "above average" — a question you cannot write with WHERE total > AVG(total), because WHERE runs before aggregates exist:

SELECT id, total FROM customer_order
WHERE  total > (SELECT AVG(total) FROM customer_order)
ORDER  BY total DESC LIMIT 3;

ANY and ALL extend that to multi-row subqueries — > ALL (…) means greater than every value.

The NOT IN trap

This one is worth real attention, because it returns a wrong answer rather than an error. Two conditions set it up, both of which happen naturally in a real database — a product nobody has ordered yet, and a NULL in the column the subquery selects (order_item.product_id is nullable, and goes NULL when a product is deleted):

INSERT INTO product (id, name, description, type, active, display_order,
                     created_at, updated_at, public_id, deleted)
VALUES (99, 'Garlic Knots', 'On the menu, never ordered', 'PIZZA', TRUE, 99,
        '2026-01-01', '2026-01-01', 'aaaaaaaa-0000-4000-8000-000000000099', FALSE);

UPDATE order_item SET product_id = NULL WHERE id = 1;

Now ask "which products have never been ordered?" three ways. The answer is one — Garlic Knots:

SELECT
    (SELECT COUNT(*) FROM product p WHERE NOT EXISTS (SELECT 1 FROM order_item i WHERE i.product_id = p.id))      AS not_exists,
    (SELECT COUNT(*) FROM product p WHERE p.id NOT IN (SELECT i.product_id FROM order_item i))                    AS not_in,
    (SELECT COUNT(*) FROM product p WHERE p.id NOT IN (SELECT i.product_id FROM order_item i
                                                        WHERE i.product_id IS NOT NULL))                          AS not_in_guarded;
+------------+--------+----------------+
| not_exists | not_in | not_in_guarded |
+------------+--------+----------------+
|          1 |      0 |              1 |
+------------+--------+----------------+

The correct answer is 1. NOT IN says 0.

The reason is NULL logic. x NOT IN (1, 2, NULL) expands to x <> 1 AND x <> 2 AND x <> NULL, and that last comparison is never true — it is unknown. Unknown ANDed with anything is at best unknown, and WHERE keeps only true. So a single NULL anywhere in the subquery makes NOT IN return no rows at all, silently.

Two fixes: add WHERE col IS NOT NULL to the subquery, or — better — use NOT EXISTS, which is immune because it asks about row existence rather than value equality. Prefer NOT EXISTS. Plain IN is unaffected; only the negation is.

In FROM: derived tables

SELECT t.order_type, ROUND(AVG(t.order_total), 2) AS avg_order
FROM   (SELECT o.order_type, o.total AS order_total FROM customer_order o WHERE o.status = 'COMPLETED') t
GROUP  BY t.order_type ORDER BY t.order_type;
+------------+-----------+
| order_type | avg_order |
+------------+-----------+
| CARRYOUT   |     19.30 |
| DELIVERY   |     32.61 |
+------------+-----------+

A subquery in FROM is a table for the duration of the query and must have an aliast here. Leaving it off is "Every derived table must have its own alias", which is one of the more common MySQL errors.

Derived tables are the standard way to aggregate twice: group once inside, then group or filter the result. They are also how you work around the alias-not-visible-in-WHERE rule from SELECT.

Correlated or not, and what it costs

An uncorrelated subquery does not mention the outer query. MySQL can run it once and reuse the result. A correlated one depends on the current outer row, so conceptually it runs per row — which on a large table is the difference between one scan and a million lookups.

MySQL 8's optimizer is good at rewriting these, frequently turning IN subqueries into semi-joins and materialising derived tables. Do not assume either way: EXPLAIN tells you what it actually chose.

As a rule of thumb, when a subquery and a join express the same thing, the join is easier for the optimizer and usually easier to read. The exceptions worth keeping are EXISTS/NOT EXISTS, which say what they mean and cannot duplicate rows the way a join to a "many" table can.

When to reach for a CTE instead

Anything more than one level of nesting gets hard to read, because you have to read a query inside-out. A common table expression gives each step a name and lets you read top-down:

WITH completed AS (
    SELECT order_type, total FROM customer_order WHERE status = 'COMPLETED'
)
SELECT order_type, ROUND(AVG(total), 2) AS avg_order
FROM   completed GROUP BY order_type ORDER BY order_type;

Same result as the derived table above, and a CTE can also be referenced more than once, which a derived table cannot.

What to remember

  • A scalar subquery must return one row and one column.
  • NOT IN with a NULL in the subquery returns nothing. Use NOT EXISTS.
  • Derived tables need an alias.
  • Correlated subqueries run per outer row — check with EXPLAIN before assuming.
  • Past one level of nesting, use a CTE.