Subqueries & CTEs

4.Digging Deeper Without Repeating Yourself

M

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.

24–28 min

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.

S

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.

A Scalar Subquery — Customers Who Owe More Than Average
SELECT Name, BalanceDue
FROM Customers
WHERE 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.

IN / NOT IN — Products Never Ordered Online
SELECT Name
FROM Products
WHERE 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.

EXISTS — Customers Who Have Placed at Least One Order
SELECT Name
FROM Customers c
WHERE 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.

NOT EXISTS — Customers Who Have Never Ordered Online
SELECT Name
FROM Customers c
WHERE 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.

Subquery vs. JOIN — the Same Question, Solved Two Ways
SELECT DISTINCT c.Name
FROM Customers c
JOIN 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.

A Correlated Subquery Inside SELECT — Each Customer's Own Order Count
SELECT Name,
(SELECT COUNT(*) FROM Orders o WHERE o.CustomerID = c.CustomerID) AS OrderCount
FROM 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.

A Correlated Subquery in WHERE — Sales Priced Above Their Own Day's Average
SELECT s.SaleDate, p.Name, p.Price
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
WHERE p.Price > (
SELECT AVG(p2.Price)
FROM Sales s2
JOIN Products p2 ON s2.ProductID = p2.ProductID
WHERE 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.

ANY — Greater Than at Least One Value
SELECT Name, BalanceDue
FROM Customers
WHERE 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.

ALL — Greater Than Every Value
SELECT Name, BalanceDue
FROM Customers
WHERE 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.

A Subquery Inside FROM — a Derived Table
SELECT * FROM (
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name
) AS ProductSales
WHERE UnitsSold > 1
ORDER 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).

A Subquery Inside HAVING — Products That Outsell the Average Product
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP BY p.Name
HAVING 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.

The Best-Sellers Query, Rewritten as a CTE
WITH ProductSales AS (
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP 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.

A CTE Built on Another CTE
WITH ProductSales AS (
SELECT p.Name, COUNT(*) AS UnitsSold
FROM Sales s
JOIN Products p ON s.ProductID = p.ProductID
GROUP 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.

Next