Queries inside queries

Use one query's answer inside another, as a single value, a list, or a whole table.

Intermediate10 min readLesson 23 of 38
In this lesson 8 sections
  1. Three shapes, three places
  2. One value: compare every row to a calculated number
  3. A list: filter by another table
  4. A whole table: query a result
  5. Correlated subqueries: one per row
  6. Common mistakes
  7. Dialect notes
  8. Snowflake notes
Jump to practice

Some questions need two steps. “Which deals are bigger than average?” means working out the average first, then comparing every deal to it. A subquery is a query in parentheses that runs inside another query and hands back its answer.

You’ve already used one: lesson 18’s percent-of-total divided by (SELECT SUM(amount) FROM ...).

Three shapes, three places

The subquery returns Put it Example
One value Anywhere a single value fits: WHERE, SELECT amount > (SELECT AVG(amount) FROM ...)
A list of values After IN account_id IN (SELECT account_id FROM ...)
A whole table In FROM, with an alias FROM (SELECT ... ) AS rep_totals

One value: compare every row to a calculated number

SELECT name, amount
FROM opportunities
WHERE amount = (SELECT MAX(amount) FROM opportunities);

The inner query runs first and returns 150,000. The outer query then keeps every deal worth exactly that. Four deals tie, and all four come back, which is something ORDER BY amount DESC LIMIT 1 would miss.

A one-value subquery also works in SELECT, where it repeats on every row:

SELECT name,
       amount,
       (SELECT MAX(amount) FROM opportunities) - amount AS gap_to_biggest
FROM opportunities
WHERE stage = 'Proposal';

Trap: a one-value subquery must return exactly one value. If it returns several rows, SQL Server and PostgreSQL stop with an error. SQLite, the sandbox, silently uses the first row and gives a wrong answer with no warning. Use an aggregate like MAX or AVG so there’s only ever one.

A list: filter by another table

Which deals belong to Healthcare accounts? The industry lives in accounts, but the deals live in opportunities:

SELECT name, amount
FROM opportunities
WHERE account_id IN (SELECT account_id
                     FROM accounts
                     WHERE industry = 'Healthcare');

The inner query returns a list of account IDs, and IN keeps the deals that match one of them: 15 deals worth $592,000. A join (lesson 14) gets the same answer. The subquery reads closer to the question when you only need columns from one table.

NOT IN has a nasty trap with NULLs. Lesson 26 covers it and the safer alternative.

A whole table: query a result

A subquery in FROM acts like a temporary table. This is how you aggregate twice, for example the average open pipeline per rep:

SELECT AVG(pipeline) AS avg_rep_pipeline
FROM (SELECT owner_id, SUM(amount) AS pipeline
      FROM opportunities
      WHERE closed_at IS NULL
      GROUP BY owner_id) AS rep_totals;

The inner query makes one row per rep. The outer query averages those rows: $206,500. You can’t write AVG(SUM(amount)) in one step, because SQL won’t put one aggregate directly inside another (Aggregating an aggregate).

Always give a FROM subquery an alias, like rep_totals. Most databases require it.

Correlated subqueries: one per row

A subquery can refer to the outer row. Then it runs again for every row:

SELECT o.name, o.amount
FROM opportunities AS o
WHERE o.amount > 2 * (SELECT AVG(x.amount)
                      FROM opportunities AS x
                      WHERE x.account_id = o.account_id);

For each deal, the inner query averages that deal’s account, then the outer query keeps deals worth more than double it. The x.account_id = o.account_id line is what ties the inner query to the current outer row.

They’re powerful but can be slow on big tables, because the inner query reruns for every row. When you see one getting complicated, the next lesson’s CTEs are usually clearer.

Common mistakes

Mistake Fix
A one-value subquery that returns several rows Wrap it in an aggregate, or filter it down to one row
Different filters inside and outside If the outer query is about open deals, the inner average usually should be too
Forgetting the alias on a FROM subquery FROM (...) AS some_name
Nesting three or four levels deep Rewrite with a CTE (next lesson)

Dialect notes

Database Notes
SQL Server, PostgreSQL Error when a one-value subquery returns more than one row. SQL Server requires an alias on a FROM subquery. PostgreSQL did too until version 16.
MySQL Errors on a multi-row one-value subquery. Requires the alias.
SQLite (the sandbox) Uses the first row silently. The alias is optional, but write it anyway.

Snowflake notes

Snowflake errors when a one-value subquery returns more than one row, and supports correlated subqueries in WHERE and SELECT. Some correlated forms aren’t supported and fail with an “unsupported subquery type” error. When that happens, rewrite it as a join or a CTE.

More in the field guide: Subquery and Aggregating an aggregate.

Try it

Which lost deals were worth more than the average lost deal? These are the big ones that got away. Return the name and amount, largest first. Make sure the average only covers lost deals too: averaging every deal gives a different answer.

Write it in the sandbox below, then press Check answer.

SQL sandbox

Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.

Back to the top of the lesson