In this chapter
We'll cover every major way a subquery gets used — scalar, IN/NOT IN, EXISTS, correlated subqueries, ANY/ALL, derived tables, HAVING, nested subqueries, and CTEs — comparing subqueries directly against JOINs along the way.
The Problem in Real Life
Mike asks a question that sounds simple and isn't: which customers owe more than usual? Sarah realizes there's no single column anywhere that says what "usual" even means — she'd have to compute the average balance first, then compare every customer against it.
That's two questions stacked on top of each other, not one. Sarah could run the average separately, write the number down, then paste it into a second query by hand — or she could let one query answer both questions at once.
I need an answer before I can even ask the real question.
Sarah
Two Separate Queries vs. One Query, Nested
Some questions need an answer before they can be asked
"Who owes more than average" can't be answered until the average itself is computed first.
Checking membership vs. checking existence
IN needs a full list of values to compare against; EXISTS just needs to know whether any matching row is out there at all.
A correlated subquery runs once per row, not once total
Reference the outer row inside the subquery, and it re-evaluates for every single row being checked — not just once, up front.
The same problem, several different ways to write it
NOT IN, NOT EXISTS, a LEFT JOIN, HAVING with a literal, HAVING with a subquery — picking the clearest one is its own skill.
What Are Subqueries and CTEs, Really?
A subquery is a query nested inside another one. The inner query runs, and its result gets used by the outer query, all in one statement — but there's more than one way to actually use that result, more than one place a subquery can sit inside a bigger query, and more than one way to solve the exact same problem.
Here's the complete set for this chapter — one concept at a time, each definition sitting right above the exact query that runs it:
A scalar subquery returns a single value, usable anywhere a single value is expected, like inside a WHERE comparison.
SELECT Name, BalanceDueFROM CustomersWHERE BalanceDue > (SELECT AVG(BalanceDue) FROM Customers);
The subquery in parentheses runs first, producing one number — the average. The outer query then compares every customer's balance against that already-computed value.
IN / NOT IN compares a value against a whole list of results returned by a subquery.
SELECT NameFROM ProductsWHERE ProductID NOT IN (SELECT ProductID FROM OrderItems);
Here, the subquery returns every ProductID that has ever appeared in an online order, and NOT IN keeps only the products whose ID is missing from that entire list.
EXISTS checks whether a subquery returns any rows at all — it doesn't care what those rows contain, only whether at least one exists. Notice this subquery references c.CustomerID from the outer query — a subquery that does that is called a correlated subquery, and it re-runs once for every row the outer query is checking, not just once.
SELECT NameFROM Customers cWHERE EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID);
Priya, Maria, and Raj come back — the exact three customers with a real row in Orders.
NOT EXISTS is the mirror of EXISTS — true only when the correlated subquery finds no matching row at all.
SELECT NameFROM Customers cWHERE NOT EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID);
This returns the exact same eight customers as the LEFT JOIN ... WHERE ... IS NULL query back in the JOIN chapter — a third way to ask the identical question, alongside NOT IN and a LEFT JOIN.
Subquery vs. JOIN: the EXISTS query above and this JOIN answer the exact same question, two different ways. A JOIN naturally returns one row per match — a customer with two orders would show up twice — while EXISTS only ever checks whether a match exists at all.
SELECT DISTINCT c.NameFROM Customers cJOIN Orders o ON c.CustomerID = o.CustomerID;
Same three names — Priya, Maria, Raj — once DISTINCT removes Priya's duplicate row (she has two orders). Rule of thumb: reach for a JOIN when the result needs actual columns from both tables; reach for EXISTS when it's purely a filter, with nothing from the other table in the final output.
The same correlated idea works inside SELECT too, not just WHERE — a subquery computed fresh for every single row, using that row's own values.
SELECT Name,(SELECT COUNT(*) FROM Orders o WHERE o.CustomerID = c.CustomerID) AS OrderCountFROM Customers c;
Priya shows 2, Maria and Raj each show 1, and everyone else shows 0 — one full scan of Orders per customer, recomputed for every row in Customers.
This is the classic correlated-subquery pattern: comparing each row to the average of its own group — here, each sale against the average price of everything sold on that exact same day, correlated on s2.SaleDate = s.SaleDate.
SELECT s.SaleDate, p.Name, p.PriceFROM Sales sJOIN Products p ON s.ProductID = p.ProductIDWHERE p.Price > (SELECT AVG(p2.Price)FROM Sales s2JOIN Products p2 ON s2.ProductID = p2.ProductIDWHERE s2.SaleDate = s.SaleDate);
Only Apples (twice) and Rice (twice) show up — the only sales priced above whatever else sold on their particular day.
ANY (sometimes written SOME) makes a comparison true if it holds against at least one value a subquery returns — > ANY (list) always means the same thing as > MIN(list). SQLite, and this Playground, doesn't support the ANY keyword directly, so this is written as the equivalent MIN comparison — same idea, syntax that actually runs.
SELECT Name, BalanceDueFROM CustomersWHERE BalanceDue > (SELECT MIN(BalanceDue) FROM Customers WHERE BalanceDue > 0);
Four customers beat the smallest actual debtor (Leah, at 15) — Priya, Maria, Farah, and Raj.
ALL requires the comparison to hold against every value a subquery returns — > ALL (list) always means the same thing as > MAX(list). Same SQLite limitation as ANY; the MAX rewrite is the portable, always-correct equivalent.
SELECT Name, BalanceDueFROM CustomersWHERE BalanceDue > (SELECT MAX(BalanceDue) FROM Customers WHERE BalanceDue > 0);
Zero rows — nobody currently owes more than Priya's own 140, the largest balance in the list. Zero is the honest, correct answer here, not a bug.
A subquery can also sit inside FROM, acting as a temporary, unnamed table the rest of the query selects from — often called a derived table.
SELECT * FROM (SELECT p.Name, COUNT(*) AS UnitsSoldFROM Sales sJOIN Products p ON s.ProductID = p.ProductIDGROUP BY p.Name) AS ProductSalesWHERE UnitsSold > 1ORDER BY UnitsSold DESC;
This is the exact same result as chapter 3's HAVING query — the inner subquery does the grouping, and the outer WHERE filters the already-grouped rows, instead of HAVING filtering them in one step.
A subquery can sit inside HAVING too, filtering groups against a computed value instead of a literal number. This one is also a nested subquery — a subquery (the AVG) built directly on top of another subquery (the derived table counting units per product).
SELECT p.Name, COUNT(*) AS UnitsSoldFROM Sales sJOIN Products p ON s.ProductID = p.ProductIDGROUP BY p.NameHAVING COUNT(*) > (SELECT AVG(UnitsSold) FROM (SELECT COUNT(*) AS UnitsSold FROM Sales GROUP BY ProductID));
Every product sells 2 units on average; only Apples sells more (3), so only Apples survives HAVING.
A CTE (Common Table Expression, written as WITH ... AS) names a subquery upfront so the main query can read cleanly, especially useful when the same logic would otherwise be repeated.
WITH ProductSales AS (SELECT p.Name, COUNT(*) AS UnitsSoldFROM Sales sJOIN Products p ON s.ProductID = p.ProductIDGROUP BY p.Name)SELECT * FROM ProductSales ORDER BY UnitsSold DESC;
This is the identical derived table from two snippets ago, just given a real name — ProductSales — instead of sitting anonymously inside FROM.
WITH ProductSales AS (SELECT p.Name, COUNT(*) AS UnitsSoldFROM Sales sJOIN Products p ON s.ProductID = p.ProductIDGROUP BY p.Name),TopSeller AS (SELECT Name FROM ProductSales ORDER BY UnitsSold DESC LIMIT 1)SELECT * FROM TopSeller;
TopSeller is built directly on top of ProductSales, defined just above it in the same WITH clause — a multi-step question, answered one readable, named piece at a time. A derived table can't do this; it has no name for a later part of the query to build on.
Notice how many of these produced results already seen elsewhere in this course: NOT EXISTS and the JOIN both matched the LEFT JOIN chapter's customer list exactly, and the derived table matched chapter 3's HAVING query exactly. Different syntax, identical answers — subqueries rarely do something a JOIN or an aggregate couldn't also do, they just approach it from a different angle, sometimes more clearly, sometimes not.
The one real dividing line running through most of this is correlated vs. non-correlated: a non-correlated subquery (the average, the product list, ANY, ALL) computes one fixed answer up front. A correlated subquery (EXISTS, NOT EXISTS, the per-customer order count, the per-day price comparison) re-runs once per outer row, checking something specific to that row every time.
One last judgment call worth having ready: turn a subquery into a CTE once it needs to be written more than once, or once several subqueries nested inside each other make a query hard to read top to bottom. A single subquery used exactly once is often perfectly fine left inline — a CTE isn't automatically better, just more readable once real complexity shows up.
Mike can get these answers now, but he still has to ask Sarah to run the query every single day. That's not a subquery problem — it's the next chapter's problem entirely.
Key Takeaway
A subquery can be non-correlated (computed once) or correlated (re-run once per outer row), and it can sit inside WHERE, SELECT, FROM, or HAVING — a CTE doesn't change any of that, it just gives a subquery a name.
Why This Matters
The moment a real question needs an intermediate answer computed first — an average, a list of exclusions, a per-row lookup, a filtered subset — a subquery or CTE is almost always the tool. Correlated subqueries, EXISTS, and the choice between a subquery and a JOIN specifically show up constantly in real interview questions and real production queries, because "does this row have a matching row somewhere else" is one of the single most common questions a database ever gets asked.
GreenMart can now answer questions that need an answer of their own first — including ones where the answer depends on which row is being checked, or which group that row belongs to. The next chapter is about a query Mike wants to run every single day, without anyone retyping it from scratch.
