◀ Course contents Part 3 · Module 3-05

Subqueries

A query inside a query, and the negation that returns nothing

A subquery is a SELECT written inside another statement. There are only four places one can go, and where it sits decides what it is allowed to return. One of those forms has a failure mode so quiet it is worth the whole module on its own: a NOT IN that hands back an empty result and no error.

Ready?

1

Where It Sits Decides What It May Return

Some questions cannot be answered in one pass. "Orders above the average" needs the average before anything can be compared to it — and WHERE amount > AVG(amount) is rejected, because WHERE runs before rows are aggregated. There is no value to compare against yet.

A subquery is the answer: a complete SELECT, in brackets, inside another statement. There are four places it can go, and the position is what sets the rules.

1

In WHERE, next to = or >

Must return one row, one column. A scalar subquery — it stands exactly where a plain value would.

2

In WHERE, with IN or EXISTS

May return many rows — or none. A membership or existence test rather than a value.

3

In the SELECT list

One row, one column, evaluated per output row. It becomes a column.

4

In FROM

A whole result set, used as a table. A derived table.

SELECT order_id, amount
FROM orders
WHERE status = 'paid'
  AND amount > (SELECT AVG(amount) FROM orders WHERE status = 'paid');

That inner query never mentions the outer one, so it runs once and its single number is reused for every row. A subquery like that is uncorrelated, and you can always test it by running it on its own.

The filter belongs in both places

Notice status = 'paid' appears twice — once outside, once inside. Drop the inner one and you are comparing paid orders against an average that includes refunds and pending: 81.29 rather than 90.05. In this dataset that happens to select the same eight rows, which is exactly the kind of luck that turns a bug into a habit.

Quick check

You write SELECT name, (SELECT country FROM users) AS c FROM products. What happens?

2

Membership, Existence, and the Bug That Returns Nothing

IN took a typed list back in module 1-02. It will just as happily take a computed one — the subquery must return exactly one column, and may return any number of rows.

WHERE user_id IN (SELECT user_id FROM orders)

This is a membership test, not a join: it cannot widen the result or duplicate a row, which is its advantage when you want no columns from the other table. A join to orders would return one row per order; this returns one row per user.

EXISTS asks a plainer question — did the subquery produce any row at all? What is in those rows is never examined, which is why the convention is to select the literal 1.

WHERE EXISTS (SELECT 1 FROM orders o
              WHERE o.user_id = u.user_id AND o.status = 'paid')

And now the trap

"Which users have referred nobody" looks like a job for NOT IN. Run it against this database and you get zero rows. The honest answer is six.

WHERE user_id NOT IN (SELECT referred_by FROM users)   -- 0 rows. Not one.

referred_by is NULL for the four users who arrived on their own, so that list contains a NULL. And NOT IN expands into a chain of inequalities:

x NOT IN (1, 2, NULL)
  →  x <> 1  AND  x <> 2  AND  x <> NULL
                              └── UNKNOWN, forever

A comparison with NULL is never true and never false — it is unknown. An AND chain ending in unknown can never evaluate to true, for any row, so WHERE keeps nothing. This is the three-valued logic of module 1-05, arriving somewhere it does real damage.

Silently empty

NOT IN with a nullable column

One NULL anywhere in the list empties the entire result. No error, no warning — just a report that says nothing qualified.

Immune

NOT EXISTS

Never compares values, so nothing can come back unknown. It only asks whether rows appeared. Reach for this when negating.

The other fix is WHERE referred_by IS NOT NULL inside the subquery. Note that plain IN is unaffected — a NULL in the list simply never matches, which is what you would expect. Only the negation breaks.

Quick check

A nightly job uses WHERE id NOT IN (SELECT parent_id FROM items) to find orphans. It worked for months, then started returning nothing. What most likely changed?

3

When the Inner Query Can See the Outer Row

Every subquery so far could have been run on its own. A correlated subquery cannot: it references the outer query, so it is re-evaluated once for each outer row, with that row's values in scope.

That is what makes a genuinely awkward question expressible — comparing each row not to a global aggregate, but to an aggregate of its own group.

SELECT o.order_id, u.country, o.amount
FROM orders o
JOIN users u ON u.user_id = o.user_id
WHERE o.amount > (SELECT AVG(o2.amount)
                  FROM orders o2
                  JOIN users u2 ON u2.user_id = o2.user_id
                  WHERE u2.country = u.country);

The inner query has its own aliases — o2 and u2 — and that is not decoration. It is what lets u.country in the inner WHERE unambiguously mean the outer row's country. Reuse u inside and the reference resolves to the inner copy: the correlation vanishes, the query still runs, and it silently computes a global average instead.

An easy self-test

Copy the subquery out and run it alone. If it works, it was uncorrelated. If it fails with an unknown column, it was correlated — and that failure is the proof it is doing what you wanted.

The same correlation in the SELECT list gives a column computed per row, which is a real alternative to LEFT JOIN … GROUP BY:

SELECT u.name,
       (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders
FROM users u;

And it dodges a trap from 3-02 for free. COUNT over no rows is 0, so a user who never ordered comes back as 0 — no padded row to accidentally count, no COALESCE needed.

Correlated subqueries are the portable way to write "compared with its own group". Part 4 does the same thing with window functions, which are usually faster and often clearer.

Quick check

In the country example above, you write the inner query using u instead of u2. What happens?

4

A Subquery Used as a Table

The fourth position is the most powerful. A subquery in FROM is a derived table — a whole result set treated exactly as though it were a table in the database.

It solves a specific and common problem: wanting to filter, or aggregate, on something you had to compute first. You cannot put an aggregate in WHERE, and HAVING only tests the groups of the current query. So aggregate in an inner query, and treat its output as ordinary rows.

SELECT t.name, t.spent
FROM (SELECT u.name, SUM(o.amount) AS spent
      FROM users u
      JOIN orders o ON o.user_id = u.user_id
      WHERE o.status = 'paid'
      GROUP BY u.user_id, u.name) t
WHERE t.spent > 200;

Postgres requires the alias. That trailing t is not optional — omit it and you get subquery in FROM must have an alias. Every column the inner query produced is then reached through it.

Two levels of aggregation

The shape that genuinely needs this is an aggregate of an aggregate. "The average customer's total spend" is not the average order — it is the average of the per-customer totals, and those totals have to exist before they can be averaged.

SELECT AVG(spent)
FROM (SELECT SUM(amount) AS spent
      FROM orders
      WHERE status = 'paid'
      GROUP BY user_id) z;      -- 210.11, not 90.05

Those two numbers answer different questions and it is easy to reach for the wrong one. 90.05 is the average order; 210.11 is the average customer. Comparing customer totals against 90.05 would report almost everybody as above average — a result suspicious enough to catch, if anyone looks.

There is a nicer spelling coming

Nested derived tables get hard to read quickly — the query is assembled inside out, and the thing computed first appears deepest in the text. Module 3-06 introduces the WITH clause, which is the same machinery written top to bottom with names on each step. It is the last module of Part 3, and it exists mostly to make this readable.

Quick check

Which of these genuinely needs a derived table rather than a HAVING clause?

0 of 9 completed

Loading the tables…

01

To do

"Orders above the average" cannot be written in one pass. You need the average before you can compare anything to it, and WHERE amount > AVG(amount) is rejected — an aggregate cannot appear in WHERE, because WHERE runs before the rows are aggregated.

A scalar subquery solves it. Put a whole SELECT in brackets where a value would go; it runs first, returns exactly one row and one column, and its result is used as that value.

WHERE amount > (SELECT AVG(amount) FROM orders WHERE status = 'paid')

The inner query is completely independent — it does not mention the outer one, so it is evaluated once and the number reused for every row.

Your task: return order_id and amount for every paid order worth more than the average paid order, ordered by order_id.

query.sql
PostgreSQL
Hint

The bracketed query is (SELECT AVG(amount) FROM orders WHERE status = 'paid') — the same paid filter inside, or you compare paid orders against an average that includes refunds.

Output

      
    02

    To do

    The second place a subquery can sit is the SELECT list, where it produces a column. The rule is stricter here: it must return one row and one column, for every row of the outer query — more than one row and Postgres raises more than one row returned by a subquery used as an expression.

    This one is correlated: it mentions u from the outer query, so it is re-evaluated for each user rather than once.

    SELECT u.name,
    (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders
    FROM users u

    It is a genuine alternative to LEFT JOIN … GROUP BY, and it has one real advantage: COUNT over no rows is 0, so users who never ordered come back as 0 with no COALESCE and no risk of counting a padded row.

    Your task: return every user's name and their number of orders as orders, using a subquery in the SELECT list, ordered by u.user_id.

    query.sql
    PostgreSQL
    Hint

    (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS orders — the reference to u.user_id is what makes it run once per user.

    Output
    
          
      03

      To do

      Back in module 1-02, IN took a list you typed: WHERE country IN ('UK', 'US'). It will just as happily take a list a query produces.

      WHERE user_id IN (SELECT user_id FROM orders)

      The subquery must return exactly one column; it may return any number of rows, including none. This is a membership test — "is this value among those?" — and it does not widen the result or duplicate anything, which is a real advantage over a join when you want no columns from the other table.

      Your task: return user_id and name for every user who has placed at least one order, using IN, ordered by user_id.

      query.sql
      PostgreSQL
      Hint

      (SELECT user_id FROM orders) — one column, however many rows. Duplicates in that list are harmless; IN only asks whether the value is present.

      Output
      
            
        04

        To do

        This is the most notorious silent bug in SQL, it is live in this database, and it produces an empty result rather than an error.

        "Which users have never referred anybody" looks like a job for NOT IN against referred_by. Run it and you get zero rows. The honest answer is six.

        WHERE user_id NOT IN (SELECT referred_by FROM users)   -- 0 rows

        referred_by is NULL for four users, so the list contains a NULL. x NOT IN (1, 2, NULL) expands to x <> 1 AND x <> 2 AND x <> NULL — and that last comparison is UNKNOWN, never false. The whole AND chain can therefore never be true, for any row. The three-valued logic of module 1-05, arriving where it does real damage.

        Two fixes: exclude the NULLs inside the subquery, or use NOT EXISTS, which is immune. Plain IN is unaffected — only the negation breaks.

        Your task: return user_id and name for every user who has referred nobody, using NOT IN with the NULLs excluded, ordered by user_id.

        query.sql
        PostgreSQL
        Hint

        Add WHERE referred_by IS NOT NULL inside the subquery. One NULL in that list is enough to empty the entire result.

        Output
        
              
          05

          To do

          EXISTS takes a subquery and returns true if it produced any row at all. It does not care what is in those rows, which is why the convention is to select the literal 1 — writing SELECT * is equally valid and slightly misleading, because nothing is ever read.

          WHERE EXISTS (SELECT 1 FROM orders o
          WHERE o.user_id = u.user_id AND o.status = 'paid')

          It is nearly always correlated — the inner query references the outer row, which is what makes it a question about this user rather than about the table in general.

          Compared with IN, it handles multi-column conditions naturally, and it can stop at the first matching row rather than building a whole list.

          Your task: return user_id and name for every user with at least one paid order, using EXISTS, ordered by user_id.

          query.sql
          PostgreSQL
          Hint

          (SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'paid') — the o.user_id = u.user_id is the correlation and the whole point.

          Output
          
                
            06

            To do

            NOT EXISTS is true when the subquery returns nothing. It is the third way of asking the question that 3-02 answered with a LEFT JOIN and 3-04 answered with EXCEPT.

            Its decisive advantage over NOT IN is that it is immune to the NULL trap. It never compares values, so there is no comparison to come back UNKNOWN — it only asks whether rows came back. When negating, this is the default worth reaching for.

            1

            LEFT JOIN … IS NULL

            Use when you also want columns from the other table.

            2

            EXCEPT

            Use when both sides are the same shape and you want a plain list of keys.

            3

            NOT EXISTS

            Use when the condition is more than one column, or NULLs are in play.

            Your task: return user_id and name for every user who has never placed an order, using NOT EXISTS, ordered by user_id.

            query.sql
            PostgreSQL
            Hint

            (SELECT 1 FROM orders o WHERE o.user_id = u.user_id) — no status condition this time, because "never placed an order" means none of any kind.

            Output
            
                  
              07

              To do

              The fourth place, and the most powerful. A subquery in FROM is a derived table: a whole result set, used exactly as though it were a table.

              It solves the problem of wanting to filter on something you had to compute first. You cannot put an aggregate in WHERE, and HAVING only tests the groups of the current query — so aggregate in an inner query, then treat its output as rows.

              FROM (SELECT u.name, SUM(o.amount) AS spent
              FROM users u JOIN orders o ON o.user_id = u.user_id
              WHERE o.status = 'paid'
              GROUP BY u.user_id, u.name) t
              WHERE t.spent > 200

              Postgres requires the alias — the t at the end. Leave it off and you get subquery in FROM must have an alias. Everything the inner query named is then reached through it.

              Your task: return name and spent for every user whose total paid spend is over 200, using a derived table, ordered by spent descending.

              query.sql
              PostgreSQL
              Hint

              Inside the brackets: SELECT u.name, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name.

              Output
              
                    
                08

                To do

                A correlated subquery re-runs for every outer row, and can see that row's values. That makes a question expressible which is genuinely awkward otherwise: compare each row against an average computed from only its own group.

                WHERE o.amount > (SELECT AVG(o2.amount)
                FROM orders o2
                JOIN users u2 ON u2.user_id = o2.user_id
                WHERE u2.country = u.country)

                The inner query has its own aliases — o2, u2 — precisely so that u.country unambiguously means the outer row's country. Reuse the outer names inside and the correlation silently disappears.

                Correlated subqueries are the honest, portable way to write this. Part 4 does it again with window functions, which are usually faster and occasionally clearer.

                Your task: return order_id, country and amount for every order worth more than the average order in that order's own country, ordered by order_id.

                query.sql
                PostgreSQL
                Hint

                The inner query needs its own aliases o2 and u2, and its WHERE compares u2.country = u.country — inner to outer.

                Output
                
                      
                  09

                  To do

                  Two quantifiers that turn a comparison into a statement about a whole list. They are rarely necessary — an aggregate usually says the same thing — but they read well and they turn up in other people's queries.

                  ALL

                  True against every row

                  price > ALL (…) means greater than the largest. Same as > (SELECT MAX(…)).

                  ANY

                  True against at least one

                  price > ANY (…) means greater than the smallest. = ANY is exactly IN.

                  One caution, familiar by now: an empty subquery makes ALL vacuously true and ANY false, and NULLs inside the list can make either UNKNOWN. The MAX/MIN form is usually easier to reason about.

                  Your task: return name and price for every product costing more than every product in the addon category, using ALL, ordered by price descending.

                  query.sql
                  PostgreSQL
                  Hint

                  (SELECT price FROM products WHERE category = 'addon') — greater than all of them means greater than the most expensive addon.

                  Output
                  
                        

                    The spending report

                    To do

                    Finance wants each paying customer's total, and — for each one — whether they are above or below what a typical paying customer spends. That benchmark is not a column anywhere. It has to be computed from the per-customer totals, which themselves have to be computed first.

                    Your task: one row per user who has at least one paid order, with:

                    • name — the user's name
                    • country — their country
                    • spent — their total paid amount
                    • vs_average — the text above if their total beats the average per-user paid total, otherwise below

                    Order by spent descending, then name.

                    Two levels of aggregation, so two subqueries. Note carefully what the benchmark averages: not the average order (90.05) but the average user total (210.11). Getting that wrong returns a report where almost everybody is above average, which should itself look suspicious.

                    query.sql
                    PostgreSQL
                    Hint

                    The derived table is SELECT u.user_id, u.name, u.country, SUM(o.amount) AS spent FROM users u JOIN orders o ON o.user_id = u.user_id WHERE o.status = 'paid' GROUP BY u.user_id, u.name, u.country. The benchmark is SELECT AVG(spent) FROM (SELECT SUM(amount) AS spent FROM orders WHERE status = 'paid' GROUP BY user_id) z — an average of totals, so it needs its own derived table.

                    Output
                    
                          

                      Notification