Subqueries (v2 pilot) — Using One Query's Result Inside Another
Put a query in brackets inside another query: compare with the value it returns, test a list with IN, or query the table it builds, and check which average you are really comparing with.
This article is part of the Learn SQL hands-on course series.
Prerequisite — this is a hands-on lesson. Reading it is not enough. Run every query yourself in CloudBeaver, against the Northwind Company sample database on PostgreSQL — and solve the koans yourself on DuckDB, the same Northwind Company data.
Not set up yet? It only takes a couple of minutes:
The question and the data
Welcome to Series 2, Intermediate SQL course, Leo. This series is about answering bigger questions in steps — subqueries, CTEs, window functions — and today we start with the first: subqueries, a query inside another query.
Good morning, Mnemo — and wait till you hear this. Remember my Series 1 top ten customers report? I sent it to my manager. No "well done", no "nice work" — it came straight back with another question: which customers spend more than the average customer?
Ha — you wanted a thank-you and got more work. That is the thank-you, Leo: she trusts your numbers, so she wants more of them. And her question looks like one — it's really two, and one is hidden inside the other.
I can see both halves. First the average — then everybody above it. I'd run one query, copy the number, and paste it into a second one. For now, that's the only way I know how to do it.
One thing before we continue. Like in Series 1, the database comes bundled with DataPallas — only with a lot more data this time. Connect to it in CloudBeaver, then run SET search_path TO northwind_co_s first.
Mnemo — I'd just started to love the old Northwind database. We spent a whole series together. And now you want me to change it?
Your Series 1 queries ran on the starter data, and the company has grown since. So have you: the old database was tiny on purpose — few enough rows that you could picture every one. You write real queries now.
OK — Series 1's data was tiny on purpose, so a complete beginner like me could see every row. And now the data has to grow with me. All right, I'm sold.
HOW MUCH DATA?
Leo, see these: Tiny, from the beginner series, and the Small northwind_co_s dataset, used by many courses — including this one — both come with DataPallas. Large is needed in a handful of episodes, like making your queries faster — there I'll show you how to install it.
Give me a few moments, Mnemo — let me read the video description to see how to connect CloudBeaver to northwind_co_s. Around 10k orders, you said, a bit bigger than Northwind Tiny: enough for meaningful data, yet still small, so our queries stay snappy.
OK — mostly the Northwind tables I know from Series 1. Every query I wrote there still runs, just on more data now.
From a pasted number to a subquery
OK, enough housekeeping Leo — let's come back to your manager's question: which customers spend more than the average customer? This is our goal for today and you'll answer it, but first I'll warm you up on something smaller: which products cost more than the average price? Question one: what's the average?
SELECT ROUND(AVG("UnitPrice"), 2)
AS "Average price"
FROM "Products";24.01. One table, one number. And now I write it down.
Question two. Paste your number into a WHERE, and ask for every product above it.
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > 24.01
ORDER BY "UnitPrice" DESC;WHERE "UnitPrice" > 24.01. Two queries, one number copied across — that's exactly how I'd do it at work.
30 products, and every one of them is right — today.
Why only today?
Because 24.01 is a number you pasted in, and Classic Tonic is on the list by six cents. Change one price tomorrow and the real average moves — but WHERE "UnitPrice" > 24.01 keeps answering yesterday's question, and nothing tells you.
So the average has to come from the database every time the query runs — not from me.
Then let the database work out the average. Where does it go?
Right where my 24.01 was: AVG("UnitPrice"). And it won't run — aggregate functions are not allowed in WHERE?
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > AVG("UnitPrice");SQL Error [42803]: ERROR: aggregate functions are not allowed in WHERE
Position: 71Leo, you've met this refusal before, in the lesson on HAVING. WHERE looks at one row at a time, before anything has been added up — so at that moment there is no average to compare with.
But the average exists, Mnemosyne. I worked it out a minute ago.
Yes, you worked it out with a different query. So, for getting it to work, try handing the WHERE that exact query, the one that gave you the average. Put it in brackets, right where your number was — and the database runs that inner query first.
Here is your first subquery, in between brackets, right where your number was. (SELECT AVG("UnitPrice") FROM "Products") runs first and hands back one value — 24.01 today — and the outer query uses it exactly like the number you pasted in.
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > (SELECT AVG("UnitPrice")
FROM "Products")
ORDER BY "UnitPrice" DESC;Brackets, like in maths, so the inside is worked out first. Today AVG hands back 24.01, tomorrow it hands back tomorrow's average, and I never have to update that number myself.
Here's the habit that saves you hours with subqueries: in CloudBeaver, select just the subquery and run it on its own. If it doesn't hand back what you expect, the whole query can't either.
A subquery — a query inside a query. Of course. So I test the inner one like any other query — before I trust the outer one.
A subquery that returns one value
Let me try one of my own: which products cost more than our drinks? The subquery hands back the prices of category 1, the beverages.
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > (SELECT "UnitPrice"
FROM "Products"
WHERE "CategoryID" = 1);SQL Error [21000]: ERROR: more than one row returned by a subquery used as an expressionHow many beverages are there, Leo?
10… so that's 10 prices. And, row by row, WHERE "UnitPrice" > can't compare one price with 10 — more than one row returned by a subquery.
So decide which one you mean. More than every beverage is more than the dearest beverage: MAX("UnitPrice"). Ten prices go in, one value comes out — or, if you meant any of the ten, use IN, which we'll meet shortly.
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > (SELECT MAX("UnitPrice")
FROM "Products"
WHERE "CategoryID" = 1)
ORDER BY "UnitPrice" DESC;14 products dearer than our dearest drink. So the error was really asking me a question — which of the ten did I mean?
Now a quiet one, Leo. Take the same query and change a single digit: category 9.
SELECT "ProductName", "UnitPrice"
FROM "Products"
WHERE "UnitPrice" > (SELECT MAX("UnitPrice")
FROM "Products"
WHERE "CategoryID" = 9)
ORDER BY "UnitPrice" DESC;Running it… and nothing. 0 rows — and no error. Did it work?
Look at the inside. I picked category 9 on purpose, because I know there is no category 9. So the brackets found no products, and a subquery that finds nothing hands back NULL. Nothing is ever greater than NULL, so the WHERE keeps no rows, and raises no error.
So zero rows can mean two things: there really is no answer, or my question was wrong. Running the inside alone shows me which — a NULL means the question needs fixing.
The three types of subquery
Good, Leo. So far the subqueries we tried have handed back one value, like the average or the maximum price you just saw — but they can also hand back a list, or a whole table. What a subquery hands back decides where it can go. One value goes where a number could go. A list goes inside IN (…). And a whole table goes where a table goes — in the FROM. You've just done the first one.
So subqueries can return either a single value, a list or a whole table. And the main query depends on what comes out of the subquery.
A subquery that returns a list
Now let's see where a subquery that returns a list helps. Royal Raisins has been discontinued, and sales wants to tell everyone who bought it this year — which customers bought it in 2024?
SELECT "CompanyName"
FROM "Customers"
WHERE "CustomerID" IN (
SELECT o."CustomerID"
FROM "Orders" o
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
WHERE d."ProductID" = 39
AND o."OrderDate" >= DATE '2024-01-01'
AND o."OrderDate" < DATE '2025-01-01'
)
ORDER BY "CompanyName";The subquery hands back the customer of every 2024 order line for product id 39 (Royal Raisins), and IN keeps each customer whose id is somewhere in that list.
You told me to run the inside on its own first. Let me do that now — select just the subquery, and run it.
SELECT "CompanyName"
FROM "Customers"
WHERE "CustomerID" IN (
SELECT o."CustomerID"
FROM "Orders" o
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
WHERE d."ProductID" = 39
AND o."OrderDate" >= DATE '2024-01-01'
AND o."OrderDate" < DATE '2025-01-01'
)
ORDER BY "CompanyName";9 rows. Write that number down. Now run the whole query — and watch how many come back.
One moment — selecting all of it, and running.
SELECT "CompanyName"
FROM "Customers"
WHERE "CustomerID" IN (
SELECT o."CustomerID"
FROM "Orders" o
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
WHERE d."ProductID" = 39
AND o."OrderDate" >= DATE '2024-01-01'
AND o."OrderDate" < DATE '2025-01-01'
)
ORDER BY "CompanyName";8? The inside handed me 9… ah. One customer bought Royal Raisins twice in 2024, so the Order Details join inside hands their id back twice — but IN asks once per customer, so they come back once.
You worked that out exactly right, Leo. In the subquery a row is one order line — that is its grain. 9 of them, because Royal Raisins sold 9 times in 2024, and Yarrow Pantry is two of the 9.
WHERE "CustomerID" INAnd out here a row is one customer — a different grain. So Yarrow Pantry can only be one of the 8.
Exactly. IN never counts lines: it asks each customer one question — is your id in that list? — and a yes is a yes however often the id appears.
Subquery or join?
Leo, you might think the outer query isn't doing much, and that the join inside could answer on its own. As we already saw, this join returns 9 rows, with Yarrow Pantry twice.
SELECT "CompanyName"
FROM "Customers"
WHERE "CustomerID" IN (
SELECT o."CustomerID"
FROM "Orders" o
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
WHERE d."ProductID" = 39
AND o."OrderDate" >= DATE '2024-01-01'
AND o."OrderDate" < DATE '2025-01-01'
)
ORDER BY "CompanyName";You have a point, Mnemosyne — the outer part looks anemic. The join does most of the work, and DISTINCT could remove the duplicates. So tell me: with a JOIN together with DISTINCT, do I need subqueries at all?
Fair question, Leo, and you are right: you could use a JOIN with DISTINCT to return the correct 8 customers here.
SELECT .. FROM "Customers" IN (subquery)
But it would be fragile. SELECT DISTINCT name does return the 8 customers here. But the moment a second column joins it, as in SELECT DISTINCT name, order id — and believe me, people do this often — the duplicates come back. The real problem is deeper: the JOIN itself is at the per-order-line grain, and you were looking for an answer about customers.
Mnemo, I think what you're telling me is that it should feel strange to answer a question about customers with a query on "Order Details" — even if a hack like DISTINCT can patch the result to bring back the correct customers here.
Exactly, Leo. Instead of returning order lines and then patching them to get customers, it is better to ask for customers directly.
Subquery and join: which one, when
Leo, subqueries and joins are both tools you have at your disposal now. Depending on the context you will use either one, and many times both together in the same query. Here are some hints for when you need each.
Look at what the answer has to contain. Customers who bought Royal Raisins in 2024? Only customer names — the Royal Raisins product is just how you pick them. So: SELECT "CompanyName" … WHERE "CustomerID" IN (subquery) is asking for customers directly.
Now change the question: which customers bought Royal Raisins, and how many boxes each? The request needs information from two tables, Customers and Order Details — and only a join can put both side by side.
I get it! If the answer needs columns from only one table and the other table just picks the rows, I use a subquery. If it needs columns from both tables, I use a join. But is one of them faster? I keep hearing that subqueries are slow.
Not by nature. Databases often run both the same way, so choose by what the answer needs. When a query really is slow, you read its plan and measure — you don't guess. In Series 3, I'll teach you how to tune SQL query performance and how to read execution plans.
So I choose by what the answer needs, not by speed. And in some bigger queries I'll have to use subqueries and joins together anyway.
Your turn, Leo.
How many products sell at or below the average price?
Thirty are above it. Predict the rest before you run it.
▶ Continue once you've run it.
SELECT count(*) AS "Products"
FROM "Products"
WHERE "UnitPrice" <= ___;Back to the manager's question
Leo, that was quite a long detour. Let's now come back to your manager's question. Customers who spend more than the average — as we discussed, that is really two questions, so, question one: what is the average?
SELECT ROUND(AVG("UnitPrice" * "Quantity"
* (1 - "Discount")), 2) AS "Average"
FROM "Order Details";Same move as the prices. Average the money — price times quantity, less the discount, over every line. 595.13.
Then the second part: customers, grouped by what they spend. I remember this query — it's the report I built at the end of the last series, now with a HAVING on the end: total sales above 595.13.
SELECT c."CompanyName",
ROUND(SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")), 2) AS "Total sales"
FROM "Customers" c
JOIN "Orders" o
ON o."CustomerID" = c."CustomerID"
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
GROUP BY c."CompanyName"
HAVING SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")) > 595.13
ORDER BY "Total sales" DESC;Run it. And count what comes back.
118 rows. Of the 119 customers who ever ordered, 118 are above average — even Tundra Grocers, on 1239.25.
Can nearly every customer be above the average customer?
…No. If almost all of them are above it, it isn't their average.
Right. So whose average is 595.13?
It averaged the money column — and every row of that is one order line. So 595.13 is the average order line.
SELECT ROUND(AVG("UnitPrice" * "Quantity"
* (1 - "Discount")), 2) AS "Average"
FROM "Order Details";It is not correct to compare customers' total spending with an average of order lines.
One row per what — the question from the report lesson, back again. A customer's total is lines added together, so it almost always beats one line. By coincidence, the one customer left out, Upland Food Hall, bought only two lines in five years — that is why it was the only one below your number.
So I wrongly compared customers' total spending against order lines — that is why you keep reminding me to watch the grain. I need one total per customer first — and then the average of those.
Leo, I'll use this opportunity to show a subquery which returns a table. As I told you, a subquery can return a number, a list or a whole table. This one is the query you wrote earlier: it builds one row per customer, with that customer's total — and the outer query averages that table.
SELECT ROUND(AVG("Total"), 2) AS "Average customer"
FROM (SELECT o."CustomerID",
SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount"))
AS "Total"
FROM "Orders" o
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
GROUP BY o."CustomerID") AS t;A table, right inside the FROM. But why does it need that AS t at the end?
Because FROM expects a table, and every table needs a name — so the outer query can say what it is reading from. Some databases refuse a subquery in the FROM without one.
A query I can query. The average customer is 126193.54 — over two hundred times the average line.
Average order line
SELECT ROUND(AVG("UnitPrice" * "Quantity"
* (1 - "Discount")), 2) AS "Average"
FROM "Order Details";Result: 595.13
Average customer spend
SELECT ROUND(AVG(t."Total"), 2) AS "Average"
FROM (SELECT o."CustomerID",
SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")) AS "Total"
FROM "Orders" o
JOIN "Order Details" d ON d."OrderID" = o."OrderID"
GROUP BY o."CustomerID") AS t;Result: 126193.54
The only difference is what one row is.
These are two very different averages — over two hundred times apart. Neither of them is wrong. They answer different questions — and only one of them is your manager's.
Measured against 126193.54, the answer can't be nearly everybody any more. Show me who's left.
The monster query
This is the whole monster query, Leo — let's see if you can read it. It is the query you built earlier, and at the end, a HAVING that compares each customer's total with a subquery that builds a table of customer totals and hands back their average. A table, inside a value, inside a query.
SELECT c."CompanyName",
ROUND(SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")), 2) AS "Total sales"
FROM "Customers" c
JOIN "Orders" o
ON o."CustomerID" = c."CustomerID"
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
GROUP BY c."CompanyName"
HAVING SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount"))
> (SELECT AVG(t."Total")
FROM (SELECT o2."CustomerID",
SUM(d2."UnitPrice"
* d2."Quantity"
* (1 - d2."Discount")) AS "Total"
FROM "Orders" o2
JOIN "Order Details" d2
ON d2."OrderID" = o2."OrderID"
GROUP BY o2."CustomerID") AS t)
ORDER BY "Total sales" DESC;Indeed — what a monster, Mnemo. Let me see if I understand it. I start from the deepest subquery, the way you taught me: that one is my query, the spend per customer. The next one, with AVG, turns all those spends into a single average.
Then the HAVING keeps only customers who spent more than that average. Wait — it checks every customer against it. Does the database work the average out again for each customer? With 119 customers, that sounds slow.
Good instinct, Leo, but no. That subquery never mentions the customer being checked, so it does not change from row to row. The database works it out once and reuses the number — exactly like the 24.01 you used to paste in. Subqueries that do depend on the outer row exist too — those are worked out row by row, and that is for later in this series.
Right — once, like my old 24.01, only now the database fills it in. So: the joins, from Customers down to Order Details, reach the money. On top, the SELECT shows CompanyName and Total sales — and last, ORDER BY puts the biggest at the top.
Leo, you explained the query better than I could. Here is what it returns: 30 customers who spend more than the average, not 118. Emerald Grocers is the last one above the line, on 175249.7.
And Kestrel Pantry, on 121607.73, is the first one below it. The report used to say nearly everybody was above the average. The only thing I changed was the inner query: instead of the average order line — the wrong grain — it now uses the average customer spend.
I see once more why you insist on the grain. Get it wrong and the query raises no error — it just hands back wrong results. Get it right, and the results are correct.
That's the power and the danger of a subquery in one sentence. The outer query trusts whatever the subquery hands it.
Can I say something, Mnemosyne? It works — and I can't read it. Subqueries inside subqueries inside a HAVING. If somebody handed me that on a Monday, I'd need ten minutes to work out what it does.
SELECT c."CompanyName",
ROUND(SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")), 2) AS "Total sales"
FROM "Customers" c
JOIN "Orders" o
ON o."CustomerID" = c."CustomerID"
JOIN "Order Details" d
ON d."OrderID" = o."OrderID"
GROUP BY c."CompanyName"
HAVING SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount"))
> (SELECT AVG(t."Total")
FROM (SELECT o2."CustomerID",
SUM(d2."UnitPrice"
* d2."Quantity"
* (1 - d2."Discount")) AS "Total"
FROM "Orders" o2
JOIN "Order Details" d2
ON d2."OrderID" = o2."OrderID"
GROUP BY o2."CustomerID") AS t)
ORDER BY "Total sales" DESC;Keep that complaint, Leo — it's a good one. Later in this series we'll improve the situation: every one of those subqueries gets a name, and the query reads top to bottom. For today: read it from the inside out.
But I disagree that you can't read it — a moment ago you explained this monster better than I could. And ten minutes to understand a query like this is perfectly normal.
Orders versus invoices
Let's move on, Leo. Before sending these 30 largest spenders back, read the second line of your manager's note. 2024: orders are up 10 per cent on last year — but invoicing is down. Which is it?
That's two totals for 2024: what customers ordered, and what we invoiced. Two values — side by side, in one query?
Two numbers, so two subqueries — and a value can go anywhere one value can go, even in the select list. The first is your report's money, for orders placed in 2024. The second reads "Invoices": one row for every invoice the company sent, with its date and its amount.
SELECT (SELECT ROUND(SUM(d."UnitPrice" * d."Quantity"
* (1 - d."Discount")), 2)
FROM "Order Details" d
JOIN "Orders" o
ON o."OrderID" = d."OrderID"
WHERE o."OrderDate" >= DATE '2024-01-01'
AND o."OrderDate" < DATE '2025-01-01') AS "Ordered 2024",
(SELECT ROUND(SUM("Amount"), 2)
FROM "Invoices"
WHERE "InvoiceDate" >= DATE '2024-01-01'
AND "InvoiceDate" < DATE '2025-01-01') AS "Invoiced 2024";One query, one row, two numbers side by side. Each subquery runs on its own first: the first returns the total ordered in 2024, the second returns what was invoiced in 2024. Then the outer SELECT on the first line shows both values next to each other.
About 3.6 million ordered, but only 3.2 million invoiced. Quite a big difference between them.
So which one is wrong?
Neither. They answer different questions. Ordered measures demand — what customers asked us for. Invoiced is what the company actually billed. Your largest spenders query summed order lines, so it was about the first one.
Then the question is the gap between them. Orders grew, and invoicing fell — why?
That is our mystery. Throughout this series you'll gather your SQL knowledge piece by piece, and we'll apply it, step by step, to solve it.
What to take away, and what is next
Leo, we're almost done. Before we go: three checks before you trust a subquery. Run the inside on its own. Check its shape, because the shape decides where it can go. And check its grain, because that is the one that never raises an error.
The right subquery shape stops the error. The right grain stops a silent wrong answer — 118 of 119 customers "above average", because I compared their totals with an average taken per order line.
Your turn, Leo.
▶ Walk the path to enlightenment.
cd c:/DataPallas/db/datazeus
.\zeus.bat koans learnsql series2 _00- a value in brackets: freight above the average
- a value in brackets can be the whole comparison
- one value only: turn a column of values into one
- …and seven more, ending with two you write yourself
…/learnsql/series2/_00/SubqueriesKoans.groovy
That's subqueries, Leo. The subquery runs first, and what it hands back — a value, a list or a table — decides where it can go. You caught the trap yourself: the right average, of the wrong thing.
And I've got my question for this series: why did invoicing fall while orders grew? What's missing, where, since when — and how much?
That's all, folks!
We'll do it all, Leo. Next lesson is CASE: labels for rows, and several kinds of order counted side by side — a column each, in one query.
That's all, folks!
- Subqueries — one query's result inside another
- CASE — SQL's if/then for labels & buckets
- Dates & Times — ranges and boundaries
- Multi-Table JOINs & Grain — rows dropped or multiplied
- CTEs (WITH) — a long query, in named steps
- VIEWs, CREATE TABLE AS & Temp Tables — query or answer
- EXISTS & Correlated Subqueries — and the NOT IN trap
- Self-JOINs & Hierarchies — rows in the same table
- Window Functions I: OVER, PARTITION BY & ROW_NUMBER
- String Functions, LIKE & Regex — clean up messy data
- Window Functions II: LAG/LEAD, Running Totals & Frames
- UNION, INTERSECT, EXCEPT — stack and reconcile lists
- GROUPING SETS, ROLLUP & CUBE — subtotals in one pass
- INSERT, UPDATE, DELETE & Transactions — safely
- UPSERT & MERGE — insert or update, safely re-run
- Median and Percentiles — WITHIN GROUP, not AVG
- Subqueries + CTEs + Window Functions — a real case
Found this to be a little gem? Help others find it too — someone out there is stuck trying to learn SQL right now. The button opens Reddit with the link and title already filled in, so you just pick a community and post.
Share on Reddit