SQL Fundamentals · Subqueries and Set Operations

Subqueries and Set Operations

Comparing each row with an aggregate, correlated and derived-table subqueries, EXISTS versus IN, and UNION, INTERSECT and EXCEPT.

  • 9 min read
  • 13 practice questions
  • 3 graded problems
On this page
  1. Description
  2. Scalar subqueries
  3. Correlated subqueries
  4. Derived tables: aggregate first, then join
  5. IN, EXISTS, ANY and ALL
  6. Set operations
  7. Where it breaks
  8. Which one should I write?
  9. Tradeoffs
  10. Check yourself
  11. Key takeaways
  12. Quick revise
  13. Practice

Description

Some questions can't be answered by one simple query. "Which products cost more than their category's average?" needs a number worked out first and then compared. "Which newsletter subscribers never bought anything?" compares one list with another. "Merge the webinar list with the newsletter list, no duplicates" stacks two results into one. This lesson is about building queries out of other queries.

There are two tools for that. A subquery is a query in parentheses inside another one - as a single value, as a list to check against, or as a small table to join. A set operation (UNION, INTERSECT, EXCEPT) stacks two query results on top of each other. Both are easy to write, and each has one NULL or duplicate rule that can turn a sensible-looking query into a wrong one. We'll go step by step:

  1. Scalar subqueries - a query that stands in for a single value.
  2. Correlated subqueries - a subquery that depends on the current row.
  3. Derived tables - work out a summary first, then join it.
  4. IN, EXISTS, ANY and ALL, including the surprising empty-set rule.
  5. UNION, INTERSECT and EXCEPT, and how they treat NULLs and duplicates.
  6. Which form to pick for which question.

Examples use a products table (6 products in three categories), a newsletter list of emails and a buyers list of emails - one of which is NULL (a buyer who checked out without one). Every result was produced by PostgreSQL 18.

Scalar subqueries

A subquery in parentheses where a single value is expected - in SELECT, WHERE, SET - must return at most one row with one column. Two rules follow:

  • No rows → the value is NULL, and a comparison with NULL is unknown, so the row is filtered out without an error.
  • More than one row → a runtime error ("more than one row returned by a subquery used as an expression"). It may pass every test and fail in production the day a second row appears.

Correlated subqueries

A subquery that refers to a column of the outer query is correlated: logically it runs once per outer row, with that row's values plugged in.

SELECT p.name, p.category, p.price
FROM products p
WHERE p.price > (SELECT AVG(p2.price)
                 FROM products p2
                 WHERE p2.category = p.category)
ORDER BY p.id;
namecategoryprice
Espresso Beanscoffee18.00
Grindergear120.00

Coffee averages 17.00 and gear 63.33; Green Tea is the only tea, so it equals its own average and isn't above it. "Runs once per row" describes the meaning, not the plan - Postgres often turns correlated EXISTS into a join - but a correlated scalar subquery in SELECT over millions of rows can genuinely run millions of times.

Derived tables: aggregate first, then join

A subquery in FROM is a derived table: a temporary result you join like any table. It's the natural way to compute something per group once and compare rows against it - and to aggregate a child table before joining it, which is the cure for fan-out from the previous module.

SELECT p.name, p.price, s.avg_price
FROM products p
JOIN (SELECT category, ROUND(AVG(price), 2) AS avg_price
      FROM products
      GROUP BY category) AS s ON s.category = p.category
WHERE p.price > s.avg_price
ORDER BY p.id;
namepriceavg_price
Espresso Beans18.0017.00
Grinder120.0063.33

Since PostgreSQL 16 a derived table no longer needs an alias (FROM (SELECT ...) is enough), though naming it still makes the join readable. A WITH clause (next topic) does the same job with the subquery pulled up front.

IN, EXISTS, ANY and ALL

EXISTS (subquery) is true when the subquery returns any row; x IN (subquery) when some row equals x. For "not in", remember the rule from Querying Basics: NOT IN against a list containing a NULL is never true, while NOT EXISTS is safe.

x > ANY (subquery) is true if the comparison holds for at least one row; x > ALL (subquery) if it holds for every row - and ALL over an empty set is true:

SELECT COUNT(*) AS cheaper_than_every_toy
FROM products
WHERE price < ALL (SELECT price FROM products WHERE category = 'toys');
cheaper_than_every_toy
6

There are no toys, so every product qualifies. If "no toys" should mean "no answer", check for existence explicitly or compare with MIN(...), which is NULL over no rows.

Set operations

UNION, INTERSECT and EXCEPT combine the results of two queries vertically:

The four set operations on the lesson's two email lists. UNION keeps the shared address and the NULL once each; UNION ALL keeps every row; INTERSECT keeps what's on both; EXCEPT keeps what's only on the first.
  • They match columns by position, not by name - the first query's column names win - and the column counts and types must line up.
  • UNION, INTERSECT and EXCEPT remove duplicates; the ALL variants keep them. Deduplicating means sorting or hashing the whole result, so use UNION ALL whenever duplicates are impossible or wanted.
  • An ORDER BY at the end sorts the combined result.
SELECT
    (SELECT COUNT(*) FROM (SELECT email FROM newsletter UNION     SELECT email FROM buyers)) AS union_rows,
    (SELECT COUNT(*) FROM (SELECT email FROM newsletter UNION ALL SELECT email FROM buyers)) AS union_all_rows;
union_rowsunion_all_rows
56

ava@example.com is on both lists: UNION keeps it once. The NULL is kept once too - for duplicate removal, set operations treat NULLs as equal to each other.

That same rule makes EXCEPT behave differently from NOT IN:

SELECT COUNT(*) AS not_buyers
FROM newsletter
WHERE email NOT IN (SELECT email FROM buyers);
not_buyers
0
SELECT email FROM newsletter
EXCEPT
SELECT email FROM buyers
ORDER BY email;

NOT IN meets the NULL in buyers and returns nothing; EXCEPT compares whole rows as "distinct or not", where NULL is just another value, and gives the right answer. NOT EXISTS would too.

Where it breaks

  • A scalar subquery that can return two rows - works in testing, errors in production. Aggregate it (MAX, COUNT) or add a key that guarantees one row.
  • A scalar subquery that returns no rows - NULL, which silently filters the outer row out of a comparison.
  • NOT IN with a NULL in the subquery - an empty result. Use NOT EXISTS or EXCEPT.
  • > ALL over an empty set - true for every row.
  • UNION where UNION ALL was meant - a legitimately repeated row (two identical payments) disappears, and the query pays for a sort it didn't need.
  • Set operations line up columns by position - swap two columns in one branch and the result mixes them without an error when the types match.

Which one should I write?

Most questions can be written several ways. Start from what the question asks for:

The questionWriteAvoid
Compare each row with one overall number ("above the average price")Scalar subquery: WHERE price > (SELECT AVG(price) FROM products) - computed once-
Compare each row with its own group's number ("above its category's average")Derived table joined on the group, or a window function: AVG(price) OVER (PARTITION BY category)A correlated scalar subquery on a big table - it may run once per row
Rows that have at least one matchEXISTS (or IN)JOIN + DISTINCT
Rows that have no matchNOT EXISTSNOT IN on a column that can be NULL
Two lists stacked, duplicates impossible or wantedUNION ALLUNION - it pays to remove duplicates and may drop real ones
Two lists stacked, as a setUNION-
Values on both lists / only on the firstINTERSECT / EXCEPT-
Columns from another table, one row per matchJOINA scalar subquery per column
The top N rows of each groupA window function (ROW_NUMBER()), or LATERAL with LIMITA correlated subquery with LIMIT in WHERE
A step you'll reuse, or a long query you want readableA WITH clause (the CTEs module)Copy-pasting the same subquery

Tradeoffs

  • Correlated subquery vs derived table vs window function for "compare with the group": the correlated subquery reads most like the question; the derived table computes each group once and is easy to extend; AVG(price) OVER (PARTITION BY category) (the Window Functions module) does it in one pass without a join.
  • EXISTS vs JOIN for "has at least one": a join returns a row per match (duplicates the outer row); EXISTS returns each outer row once and can stop at the first match.
  • EXCEPT vs NOT EXISTS: EXCEPT is concise when comparing the same columns, but it deduplicates and can't return columns that aren't compared; NOT EXISTS keeps the outer rows as they are.

Going further. The deep dive (next tab) shows what the planner does with each kind of subquery: which ones become joins, which run once, and the one that can run once per row - plus LATERAL, how set operations execute, and row comparisons.

Check yourself

  1. A scalar subquery in WHERE price > (SELECT price FROM products WHERE name = 'Mug') worked for months, then started failing. What probably changed?
Show answer

A second product named 'Mug' was added, so the subquery returns two rows and Postgres raises "more than one row returned by a subquery used as an expression". Look it up by a unique key, or decide which one you mean (MIN(price), MAX(price)).

  1. buyers.email contains one NULL. Why does email NOT IN (SELECT email FROM buyers) return nothing, while EXCEPT works?
Show answer

NOT IN expands to email <> a AND email <> NULL AND ...; the NULL comparison is unknown, so the whole condition can never be true. EXCEPT removes rows that are "not distinct" from a row in the second query, and for that comparison NULL is treated like a value.

  1. When should you write UNION ALL instead of UNION?
Show answer

Whenever the branches can't overlap or duplicates are meaningful (two identical transactions). UNION removes duplicates - dropping real repeated rows - and has to sort or hash the whole result to do it.

Key takeaways

  • A scalar subquery must return at most one value: no rows gives NULL, two rows is an error.
  • A correlated subquery refers to the outer row; logically it runs once per row.
  • A derived table (subquery in FROM) computes a result once to join against - including aggregating a child table before joining it.
  • NOT IN with a NULL returns nothing; NOT EXISTS and EXCEPT don't have that problem. ALL over an empty set is true.
  • Set operations match columns by position; UNION/INTERSECT/EXCEPT deduplicate (treating NULLs as equal), ALL variants don't.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the form

The questionWriteAvoid
Compare each row with one overall numberScalar subquery: WHERE price > (SELECT AVG(price) FROM products)-
Compare each row with its own group's numberDerived table joined on the group, or AVG(price) OVER (PARTITION BY category)A correlated scalar subquery on a big table
Rows with at least one matchEXISTS / INJOIN + DISTINCT
Rows with no matchNOT EXISTSNOT IN on a nullable column
Stack two lists, duplicates impossible or wantedUNION ALLUNION (pays for a dedup, may drop real rows)
Stack two lists as a setUNION-
On both lists / only on the firstINTERSECT / EXCEPT-
Top N per groupLATERAL (... ORDER BY ... LIMIT n) or ROW_NUMBER()A correlated subquery with LIMIT in WHERE

The rules to remember

  • A scalar subquery returns at most one value: no rows → NULL (the comparison quietly fails), two rows → a runtime error.
  • A correlated subquery refers to the outer row - logically it runs once per row.
  • A derived table (a subquery in FROM) is computed once and joined like a table; since PostgreSQL 16 it doesn't need an alias.
  • x > ALL (empty set) is true for every row. Use MIN(...) (NULL over no rows) or check existence when "nothing" should mean "no answer".
  • Set operations match columns by position, not by name; the first query's names win.
  • UNION, INTERSECT, EXCEPT remove duplicates and treat NULLs as equal; the ALL versions keep duplicates.
  • NOT IN with a NULL in the list returns nothing; NOT EXISTS and EXCEPT both handle it.

Snippets worth knowing by heart

-- above the category average, each average computed once
SELECT p.name, p.price, s.avg_price
FROM products p
JOIN (SELECT category, AVG(price) AS avg_price FROM products GROUP BY category) s
  ON s.category = p.category
WHERE p.price > s.avg_price;

-- top 3 per category, using an index on (category, price DESC)
SELECT c.category, t.id, t.price
FROM (SELECT DISTINCT category FROM products) c
CROSS JOIN LATERAL (SELECT id, price FROM products p
                    WHERE p.category = c.category
                    ORDER BY price DESC LIMIT 3) t;

-- on the newsletter but never bought
SELECT email FROM newsletter EXCEPT SELECT email FROM buyers;

-- keyset pagination: the next page after the last row seen
WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20

The traps

  • A scalar subquery that can return two rows passes tests and fails in production the day a duplicate appears. Back it with a unique key or aggregate it.
  • A scalar subquery that finds nothing is NULL, and the outer row silently disappears.
  • NOT IN against a nullable column - an empty result.
  • > ALL over an empty set - true for everything.
  • UNION out of habit drops legitimately identical rows (two equal refunds) and costs a dedup over the whole result.
  • Swapped columns in one branch of a UNION mix data without an error when the types match.

Staff-level points, one line each

  • Three fates of a subquery in PostgreSQL: uncorrelated scalar → InitPlan (run once); EXISTS/IN/ANY → pulled up into a semi- or anti-join; correlated scalar value → SubPlan, run once per outer row.
  • A correlated AVG per category showed loops=5000 and read 25 million rows; the derived-table rewrite read 10,000. Look for SubPlan with a big loops count.
  • ORMs generate per-row scalar subqueries in the SELECT list ((SELECT name FROM customers WHERE id = o.customer_id)); a LEFT JOIN replaces them.
  • SQL Server and Oracle decorrelate many scalar subqueries automatically; MySQL 8.0 can for some (subquery_to_derived); PostgreSQL doesn't - so write the derived table yourself on big tables.
  • LATERAL + an index reads N rows per group (150 rows for top 3 of 50 categories); a window function numbers every row first. Few big groups → LATERAL; many tiny groups → window function. SQL Server/Oracle call it CROSS APPLY.
  • Plans: UNION ALL = Append; UNION = Append + HashAggregate over everything; EXCEPT = HashSetOp Except; NOT EXISTS = Hash Anti Join.
  • Row comparisons go element by element: ROW(1, NULL) = ROW(1, NULL) is NULL; IS NOT DISTINCT FROM is the NULL-safe equality; (1, 2) < (1, 3) compares like a dictionary.
  • Keyset pagination costs the same on page 1 and page 10,000; OFFSET 200000 reads and discards 200,000 rows.
  • Huge app-built IN (...) lists hit the 65,535-parameter limit of the PostgreSQL protocol and slow planning; pass one array: WHERE id = ANY($1).

Say it in an interview

  • "That's a correlated scalar subquery - in Postgres it runs per row. I'll aggregate once in a derived table and join."
  • "For top 3 per category with a few huge categories, LATERAL with an index on (category, price DESC) reads only 3 rows per category."
  • "I'd paginate with a keyset on (created_at, id), not OFFSET."
  • "UNION ALL here - the branches can't overlap, so there's nothing to de-duplicate."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Subqueries and Set Operations free

Bought Coffee, Never Gear

MediumProSQL problem

The gear team wants to cross-sell grinders and mugs to coffee lovers who haven't bought any equipment yet.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Merge Leads From Two Lists

HardProSQL problem

Sales wants one list of leads: people who signed up for the newsletter or attended a webinar, and who aren't customers yet.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Subqueries and Set Operations free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime