A subquery is a query nested inside another query — a small question whose answer feeds a larger one. Master the four places a subquery can live and you unlock some of SQL's most expressive patterns.

What Is a Subquery?

Imagine you walk into a library and ask: "Which books were written by the author who won last year's prize?" The librarian can't answer in one step. First they look up who won, then they fetch that author's books. That nested lookup is exactly what a subquery does in SQL.

A subquery is a SELECT statement embedded inside another SQL statement. The inner query runs first; the outer query uses its result. Subqueries are always wrapped in parentheses and can appear almost anywhere an expression or table is allowed.

Depending on where it sits and how it's written, the result might be a single value, a list of values, an entire virtual table, or a yes/no existence test.

SELECT name, ( subquery ) AS extra_col — scalar value in the SELECT list FROM ( subquery ) AS derived_table — temporary table (inline view) WHERE col IN ( subquery ) — filter with = IN EXISTS ANY ALL Correlated — inner query references the outer row → runs once per row WHERE o.total > ( SELECT AVG(total) FROM orders o2 WHERE o2.city = o.city )
The four positions a subquery can occupy. The first three describe where it sits; "correlated" describes how it is wired to the outer query.

The Sample Dataset

Every example in this article uses the same two tables. You can trace each query by hand using these rows.

customers

customer_idnamecity
1AliceLondon
2BobParis
3CarlaBerlin
4DiegoMadrid

orders

order_idcustomer_idtotalstatus
101189.50shipped
102142.00pending
1032150.00shipped
104327.75cancelled
105560.00shipped

Two things worth noticing: Diego (customer 4) has no orders at all. Order 105 points at customer_id = 5, who doesn't exist in customers — an orphan row. Both quirks make the examples below more revealing.

Subqueries in WHERE — the Most Common Use

The most frequent home for a subquery is the WHERE clause, where it filters rows based on the result of another query. There are four classic variants.

Single value with =

When the inner query returns exactly one value, you can compare it with =. Here we find all orders placed by Bob without knowing his id in advance:

SELECT order_id, total, status FROM orders WHERE customer_id = (SELECT customer_id FROM customers WHERE name = 'Bob');

① Subquery result

customer_id
2

② Final result

order_idtotalstatus
103150.00shipped

The inner query returns 2 (Bob's id). The outer query behaves as if you had written WHERE customer_id = 2. A subquery with = must return a single value — if it returns more than one, the database raises an error (a pitfall covered below).

Multiple values with IN

When the inner query returns a list, use IN. This finds every order placed by a customer in London or Paris:

SELECT order_id, total, status FROM orders WHERE customer_id IN ( SELECT customer_id FROM customers WHERE city IN ('London', 'Paris') );

① Subquery result

customer_id
1
2

② Final result

order_idtotalstatus
10189.50shipped
10242.00pending
103150.00shipped

Existence test with EXISTS / NOT EXISTS

Sometimes you don't care what the inner query returns — only whether it returns anything at all. That is the job of EXISTS. Here we list customers who have placed at least one order:

SELECT customer_id, name, city FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id );

The database walks through every row in customers and for each one asks: "does at least one matching order exist?" — yes or no. The SELECT 1 is a convention that signals to the reader: "I don't care what is returned, only whether anything is returned." You could write SELECT * or SELECT 'banana' and the behaviour would be identical.

① Check per customer

nameorders found?
Alice✓ TRUE
Bob✓ TRUE
Carla✓ TRUE
Diego✗ FALSE

② Final result

customer_idnamecity
1AliceLondon
2BobParis
3CarlaBerlin

Swap in NOT EXISTS and you get the opposite — Diego, the only customer with no orders.

Comparison with ANY and ALL

ANY and ALL sit between a comparison operator and a subquery returning a column of values. > ANY means "greater than the smallest value in the list"; > ALL means "greater than every value" — i.e. greater than the maximum. IN is exactly equivalent to = ANY.

-- Orders larger than AT LEAST ONE shipped order SELECT order_id, total FROM orders WHERE total > ANY (SELECT total FROM orders WHERE status = 'shipped'); -- Orders larger than EVERY shipped order (> the maximum) SELECT order_id, total FROM orders WHERE total > ALL (SELECT total FROM orders WHERE status = 'shipped');

① Shipped totals

total
89.50
150.00
60.00

② > ANY result (min = 60.00)

order_idtotal
10189.50
103150.00

The minimum shipped total is 60.00, so > ANY returns orders 101 and 103. The maximum is 150.00, so > ALL returns no rows — nothing exceeds 150.00.

Subqueries in FROM — Derived Tables

A subquery in the FROM clause produces a temporary result set that the outer query treats as an ordinary table. These are called derived tables. One rule is non-negotiable: a derived table must be given an alias.

Here we find customers who have placed more than one order. We compute per-customer counts in a subquery, then filter on that count in the outer query:

SELECT customer_id, order_count FROM ( SELECT customer_id, COUNT(*) AS order_count FROM orders GROUP BY customer_id ) AS counts WHERE order_count > 1;

① Derived table

customer_idorder_count
12
21
31
51

② Final result (count > 1)

customer_idorder_count
12

Derived tables are the natural solution when you need to filter on the result of an aggregate — something you can't do in a plain WHERE clause, which runs before GROUP BY.

Subqueries in SELECT — Scalar Subqueries

A subquery in the SELECT list must return a single column and a single row — a scalar subquery. Its value becomes a computed column on every output row. If the inner query returns no rows, the value is NULL.

SELECT o.order_id, o.total, (SELECT c.name FROM customers c WHERE c.customer_id = o.customer_id) AS customer_name FROM orders o;
order_idtotalcustomer_name
10189.50Alice
10242.00Alice
103150.00Bob
10427.75Carla
10560.00NULL

Order 105 produces NULL because customer_id = 5 matches no customer — the "no rows means NULL" rule in action. This subquery also references o.customer_id from the outer query, which makes it correlated.

Correlated Subqueries

A correlated subquery references columns from the outer query, so it can't be run on its own. Because the value it depends on changes row by row, the inner query is logically re-evaluated once for every row the outer query processes.

Here we find every order whose total beats the average for orders from the same customer's city:

SELECT c.name, c.city, o.total FROM orders o JOIN customers c ON o.customer_id = c.customer_id WHERE o.total > ( SELECT AVG(o2.total) FROM orders o2 JOIN customers c2 ON o2.customer_id = c2.customer_id WHERE c2.city = c.city -- the correlation: references the outer row );

① Evaluated per row

nametotalcity avgqualifies?
Alice89.5065.75✓ yes
Alice42.0065.75✗ no
Bob150.00150.00✗ no
Carla27.7527.75✗ no

② Final result

namecitytotal
AliceLondon89.50

For Alice's 89.50 order, the inner query averages London's totals (89.50 + 42.00 = 65.75); since 89.50 > 65.75, the row qualifies. Bob's 150.00 is exactly equal to Paris's average — not strictly greater, so excluded. The reference c2.city = c.city is the correlation — remove it and the meaning collapses entirely.

Subquery vs JOIN — When to Use Each

Many subqueries can be rewritten as joins and vice versa. Modern query optimizers routinely transform one into the other behind the scenes. Write whichever version is clearest, then check the execution plan with EXPLAIN if performance becomes a concern.

SituationPreferWhy
Need columns from both tablesJOINSubquery can only return one side
Large datasets, performance criticalJOINOptimizer handles joins more predictably
Simple existence checkEXISTS subqueryReads more naturally; stops at first match
Filter on an aggregateDerived tableCan't filter on GROUP BY result in WHERE directly

Common Pitfalls

1 — Scalar subquery returns more than one row

Using = with a subquery that returns multiple rows causes an error. If London had two customers, WHERE customer_id = (SELECT ... WHERE city = 'London') would break. Fix: switch to IN or = ANY.

2 — NOT IN with a NULL in the subquery

This is the silent trap. If the inner query of a NOT IN returns even one NULL, the entire condition returns no rows at all — silently. Because NULL means "unknown," SQL can't prove your value is not in a list that contains an unknown element.

-- Risky: returns nothing if orders.customer_id contains any NULL SELECT name FROM customers WHERE customer_id NOT IN (SELECT customer_id FROM orders); -- Safe: NOT EXISTS handles NULL correctly SELECT name FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id );

3 — Correlated subqueries at scale

A correlated subquery re-executes for every outer row. On a million-row table, that's a million inner executions — the classic "N+1" problem. When performance matters, pre-aggregate in a derived table or rewrite as a join.

4 — Forgetting to alias a derived table

Omit the alias on a FROM-clause subquery and MySQL raises error 1248: "Every derived table must have its own alias." Always append a name — ) AS summary — after the closing parenthesis.

Putting It All Together

A single query combining three techniques: a derived table in FROM, a scalar subquery in SELECT, and a correlated EXISTS in WHERE. It lists every customer who has placed at least one order, sorted from most active to least.

SELECT t.name, t.city, t.order_count FROM ( SELECT c.name, c.city, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ) ) AS t ORDER BY t.order_count DESC;
namecityorder_count
AliceLondon2
BobParis1
CarlaBerlin1

The EXISTS filter removes Diego. The scalar subquery counts each remaining customer's orders. The derived table wraps it all so the outer query can sort on that computed column. One statement, three techniques.

Takeaway

Think of a subquery as a question-within-a-question. Decide first what shape of answer you need — a single value (=), a list (IN), a yes/no (EXISTS), or a whole table (derived table) — and the right form follows naturally. Always watch the NOT IN / NULL trap.

Main References

  1. PostgreSQL Global Development GroupSubquery Expressionspostgresql.org/docs/current/functions-subquery.html
  2. Oracle CorporationMySQL 8.0 Reference Manual: Subqueriesdev.mysql.com/doc/refman/8.0/en/subqueries.html
  3. MicrosoftSubqueries (SQL Server)learn.microsoft.com/en-us/sql/…/subqueries
  4. Amazon Web ServicesRedshift Developer Guide: Subquery examplesdocs.aws.amazon.com/redshift/…/subquery_examples
← Back to all articles