← Learn SQL

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.

intermediate#sql#intermediate#subqueries#in#having#grain

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:

See here how to connect with CloudBeaver, and how to run the koans

The question and the data

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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?

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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.

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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?

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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?

about 80 ordersbundled withDataPallasTinySQL beginnerabout 10k ordersnorthwind_co_sbundled withDataPallasSmallSQL intermediateabout a million ordersinstall whena course asksfor itLargelearn about SQL performance
Mnemosyne, talking
Mnemosyne

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.

Leo, smiling
Leo

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.

Leo, smiling
Leo

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

Mnemosyne, questioning
Mnemosyne

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";
Average price
24.01
Leo, smiling
Leo

24.01. One table, one number. And now I write it down.

Mnemosyne, talking
Mnemosyne

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;
Leo, proud
Leo

WHERE "UnitPrice" > 24.01. Two queries, one number copied across — that's exactly how I'd do it at work.

30 rows — the products above the average
ProductName
UnitPrice
Harvest Chicken Pie
81
Spiced Meatballs
61.12
… +27 more products
…
Classic Tonic
24.07
the average24.01
1
2
30
Mnemosyne, pointing-right
Mnemosyne

30 products, and every one of them is right — today.

Leo, questioning
Leo

Why only today?

Mnemosyne, pointing-right
Mnemosyne

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.

Leo, questioning
Leo

So the average has to come from the database every time the query runs — not from me.

Mnemosyne, questioning
Mnemosyne

Then let the database work out the average. Where does it go?

Leo, confused
Leo

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: 71
Mnemosyne, talking
Mnemosyne

Leo, 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.

Leo, questioning
Leo

But the average exists, Mnemosyne. I worked it out a minute ago.

Mnemosyne, talking
Mnemosyne

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.

Mnemosyne, proud
Mnemosyne

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;
Leo, excited
Leo

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.

Mnemosyne, pointing-right
Mnemosyne

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.

Leo, thinking
Leo

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

Leo, surprised
Leo

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 expression
Mnemosyne, questioning
Mnemosyne

How many beverages are there, Leo?

Leo, surprised
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.

Mnemosyne, proud
Mnemosyne

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;
ProductName
UnitPrice
14 rows fetched
Leo, excited
Leo

14 products dearer than our dearest drink. So the error was really asking me a question — which of the ten did I mean?

Mnemosyne, pointing-right
Mnemosyne

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;
ProductName
UnitPrice
no rows
Leo, confused
Leo

Running it… and nothing. 0 rows — and no error. Did it work?

Mnemosyne, pointing-right
Mnemosyne

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.

Leo, confused
Leo

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

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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

Mnemosyne, questioning
Mnemosyne

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";
Mnemosyne, questioning
Mnemosyne

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.

Leo, thinking
Leo

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";
Mnemosyne, questioning
Mnemosyne

9 rows. Write that number down. Now run the whole query — and watch how many come back.

Leo, thinking
Leo

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";
Leo, thinking
Leo

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.

Mnemosyne, pointing-right
Mnemosyne

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.

CompanyName
Alpine Provisions
Golden Food Hall
Golden Pantry
Nordic Delicatessen
Tundra Foods
Yarrow Pantry
Yarrow Trading
Zenith Food Hall
one row per order line
ALPI2
GOLDE
GOLD4
NORD1
TUNDR
YARR4
YARR4
YARR1
ZENI4
main querysubquerytwo lines — one customer
8 customers
SELECT CompanyName FROM Customers
WHERE "CustomerID" IN
9 order lines
(JOIN with Order Details)
Leo, thinking
Leo

And out here a row is one customer — a different grain. So Yarrow Pantry can only be one of the 8.

Mnemosyne, pointing-right
Mnemosyne

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?

Mnemosyne, talking
Mnemosyne

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";
9 rows, one per order line
CustomerID
ALPI2
GOLDE
GOLD4
NORD1
TUNDR
YARR4
YARR4
YARR1
ZENI4
Yarrow Pantry
Yarrow Pantry
Leo, proud
Leo

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?

Mnemosyne, talking
Mnemosyne

Fair question, Leo, and you are right: you could use a JOIN with DISTINCT to return the correct 8 customers here.

grain is per "Order Details" line
SELECT DISTINCT name ... JOIN "Order Details"
-> 8 rows
DISTINCT is a hack to remove the duplicates afterwards
grain is per "Order Details" line
SELECT DISTINCT name, order id ... JOIN "Order Details"
-> 9 rows Yarrow Pantry twice
DISTINCT name, order id brings the duplication back
grain is what we need: per customer
SELECT .. FROM "Customers" IN (subquery)
-> 8 rows one row per customer
No hack needed: SELECT .. FROM "Customers" defines the grain

SELECT .. FROM "Customers" IN (subquery)

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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.

Mnemosyne, talking
Mnemosyne

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

Mnemosyne, talking
Mnemosyne

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.

Mnemosyne, talking
Mnemosyne

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.

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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.

Mnemosyne, talking
Mnemosyne

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.

Leo, thinking
Leo

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

Mnemosyne, questioning
Mnemosyne

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";
Average
595.13
Leo, excited
Leo

Same move as the prices. Average the money — price times quantity, less the discount, over every line. 595.13.

Leo, proud
Leo

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;
Mnemosyne, questioning
Mnemosyne

Run it. And count what comes back.

118 rows — of the 119 customers who ever ordered
CompanyName
Total sales
Fjord Foods
797164
Yarrow Pantry
786022.56
… +114 more customers
…
Willow Wholefoods
1662.83
Tundra Grocers
1239.25
the average595.13
Upland Food Hall465.75
1
2
117
118
Leo, surprised
Leo

118 rows. Of the 119 customers who ever ordered, 118 are above average — even Tundra Grocers, on 1239.25.

Mnemosyne, questioning
Mnemosyne

Can nearly every customer be above the average customer?

Leo, surprised
Leo

…No. If almost all of them are above it, it isn't their average.

Mnemosyne, questioning
Mnemosyne

Right. So whose average is 595.13?

Leo, thinking
Leo

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";
CompanyName
Total sales
Fjord Foods
797164
Yarrow Pantry
786022.56
… +114 more customers
…
Willow Wholefoods
1662.83
Tundra Grocers
1239.25
average order line595.13
Upland Food Hall465.75
1
2
117
118

It is not correct to compare customers' total spending with an average of order lines.

Mnemosyne, pointing-right
Mnemosyne

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.

Leo, thinking
Leo

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.

Mnemosyne, talking
Mnemosyne

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;
Leo, excited
Leo

A table, right inside the FROM. But why does it need that AS t at the end?

Mnemosyne, talking
Mnemosyne

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.

Average customer
126193.54
Leo, excited
Leo

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.

Mnemosyne, proud
Mnemosyne

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.

Leo, excited
Leo

Measured against 126193.54, the answer can't be nearly everybody any more. Show me who's left.

The monster query

Mnemosyne, questioning
Mnemosyne

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;
Leo, surprised
Leo

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.

Leo, thinking
Leo

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.

Mnemosyne, questioning
Mnemosyne

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.

Leo, thinking
Leo

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.

30 customers above the average, 119 customers in total
CompanyName
Total sales
Fjord Foods
797164
Yarrow Pantry
786022.56
… +27 more customers
…
Emerald Grocers
175249.7
the average customer126193.54
Kestrel Pantry121607.73
… +88 more customers
1
2
30
Mnemosyne, proud
Mnemosyne

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.

Leo, thinking
Leo

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.

Leo, thinking
Leo

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.

Mnemosyne, proud
Mnemosyne

That's the power and the danger of a subquery in one sentence. The outer query trusts whatever the subquery hands it.

Leo, confused
Leo

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;
Mnemosyne, smiling
Mnemosyne

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.

Mnemosyne, smiling
Mnemosyne

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

Mnemosyne, questioning
Mnemosyne

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?

Leo, thinking
Leo

That's two totals for 2024: what customers ordered, and what we invoiced. Two values — side by side, in one query?

Mnemosyne, talking
Mnemosyne

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";
Leo, excited
Leo

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.

Ordered 2024
Invoiced 2024
3628572.55
3190372.69
Mnemosyne, pointing-right
Mnemosyne

About 3.6 million ordered, but only 3.2 million invoiced. Quite a big difference between them.

Leo, surprised
Leo

So which one is wrong?

Mnemosyne, pointing-right
Mnemosyne

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.

Leo, surprised
Leo

Then the question is the gap between them. Orders grew, and invoicing fell — why?

Mnemosyne, pointing-right
Mnemosyne

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

Mnemosyne, talking
Mnemosyne

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.

Leo, smiling
Leo

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

Mnemosyne, excited
Mnemosyne

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.

Leo, excited
Leo

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!

Mnemosyne, excited
Mnemosyne

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!

  1. Subqueries — one query's result inside another
  2. CASE — SQL's if/then for labels & buckets
  3. Dates & Times — ranges and boundaries
  4. Multi-Table JOINs & Grain — rows dropped or multiplied
  5. CTEs (WITH) — a long query, in named steps
  6. VIEWs, CREATE TABLE AS & Temp Tables — query or answer
  7. EXISTS & Correlated Subqueries — and the NOT IN trap
  8. Self-JOINs & Hierarchies — rows in the same table
  9. Window Functions I: OVER, PARTITION BY & ROW_NUMBER
  10. String Functions, LIKE & Regex — clean up messy data
  11. Window Functions II: LAG/LEAD, Running Totals & Frames
  12. UNION, INTERSECT, EXCEPT — stack and reconcile lists
  13. GROUPING SETS, ROLLUP & CUBE — subtotals in one pass
  14. INSERT, UPDATE, DELETE & Transactions — safely
  15. UPSERT & MERGE — insert or update, safely re-run
  16. Median and Percentiles — WITHIN GROUP, not AVG
  17. 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